mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: Add support for message attachments
- Introduced Attachment model to handle file attachments associated with messages. - Updated ModerationController to manage message deletions and attachment cleanup. - Enhanced ChatService to include attachments in message retrieval. - Implemented migration for legacy single-attachment messages to the new Attachments model. - Added unit tests for attachment handling in message formatting and parsing. - Updated database context and migrations to support new Attachments table.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
using EchoHub.Client.UI.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
public class DroppedFileParserTests
|
||||
{
|
||||
// ── LooksLikePath ─────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("C:\\Users\\me\\cat.png")]
|
||||
[InlineData("D:/photos/pic.jpg")]
|
||||
[InlineData("\"C:\\My Files\\a b.png\"")]
|
||||
[InlineData("/home/me/song.mp3")]
|
||||
[InlineData("\\\\server\\share\\file.txt")]
|
||||
public void LooksLikePath_PathLikeInput_ReturnsTrue(string text)
|
||||
{
|
||||
Assert.True(DroppedFileParser.LooksLikePath(text));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hello world")]
|
||||
[InlineData("check out my cat")]
|
||||
[InlineData("no")]
|
||||
[InlineData("")]
|
||||
[InlineData("@someone hi")]
|
||||
public void LooksLikePath_NormalChat_ReturnsFalse(string text)
|
||||
{
|
||||
Assert.False(DroppedFileParser.LooksLikePath(text));
|
||||
}
|
||||
|
||||
// ── TryGetFiles (injected existence check) ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_SingleWindowsPath_Detected()
|
||||
{
|
||||
var exists = Exists("C:\\Users\\me\\cat.png");
|
||||
Assert.True(DroppedFileParser.TryGetFiles("C:\\Users\\me\\cat.png", out var files, exists));
|
||||
Assert.Equal(["C:\\Users\\me\\cat.png"], files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_QuotedPathWithSpaces_StripsQuotes()
|
||||
{
|
||||
var path = "C:\\My Files\\a b.png";
|
||||
Assert.True(DroppedFileParser.TryGetFiles($"\"{path}\"", out var files, Exists(path)));
|
||||
Assert.Equal([path], files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_MultipleQuotedPaths_Detected()
|
||||
{
|
||||
var a = "C:\\a.png";
|
||||
var b = "C:\\b.mp3";
|
||||
Assert.True(DroppedFileParser.TryGetFiles($"\"{a}\" \"{b}\"", out var files, Exists(a, b)));
|
||||
Assert.Equal([a, b], files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_PosixAbsolutePath_Detected()
|
||||
{
|
||||
// Path.IsPathFullyQualified treats "/x" as fully qualified only on non-Windows;
|
||||
// this asserts the parser defers that judgment to the platform.
|
||||
var isPosix = !OperatingSystem.IsWindows();
|
||||
var detected = DroppedFileParser.TryGetFiles("/home/me/song.mp3", out var files, Exists("/home/me/song.mp3"));
|
||||
Assert.Equal(isPosix, detected);
|
||||
if (isPosix)
|
||||
Assert.Equal(["/home/me/song.mp3"], files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_NonExistentPath_ReturnsFalse()
|
||||
{
|
||||
Assert.False(DroppedFileParser.TryGetFiles("C:\\nope\\missing.png", out _, _ => false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_PartialPathDuringTyping_ReturnsFalseUntilComplete()
|
||||
{
|
||||
// Only the fully typed path exists; prefixes do not.
|
||||
var full = "C:\\Users\\me\\cat.png";
|
||||
var exists = Exists(full);
|
||||
Assert.False(DroppedFileParser.TryGetFiles("C:\\Users\\me\\ca", out _, exists));
|
||||
Assert.True(DroppedFileParser.TryGetFiles(full, out _, exists));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_OneMissingAmongMultiple_ReturnsFalse()
|
||||
{
|
||||
var a = "C:\\a.png";
|
||||
Assert.False(DroppedFileParser.TryGetFiles($"\"{a}\" \"C:\\gone.png\"", out _, Exists(a)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFiles_RealTempFile_DetectedWithDefaultExists()
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), $"echohub_drop_{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(temp, "x");
|
||||
try
|
||||
{
|
||||
Assert.True(DroppedFileParser.TryGetFiles(temp, out var files));
|
||||
Assert.Single(files);
|
||||
Assert.Equal(temp, files[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
}
|
||||
|
||||
private static Func<string, bool> Exists(params string[] existing)
|
||||
{
|
||||
var set = new HashSet<string>(existing, StringComparer.OrdinalIgnoreCase);
|
||||
return set.Contains;
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,7 @@ public class IrcBroadcasterTests
|
||||
|
||||
var encryptedContent = _encryption.Encrypt("Hello world!");
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), encryptedContent, "alice", null, "general",
|
||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
||||
Guid.NewGuid(), encryptedContent, "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
@@ -97,8 +96,7 @@ public class IrcBroadcasterTests
|
||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
@@ -116,8 +114,7 @@ public class IrcBroadcasterTests
|
||||
var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
|
||||
@@ -340,8 +340,7 @@ public class IrcCommandHandlerTests
|
||||
var encryptedContent = _encryption.Encrypt("Hello from history!");
|
||||
_chatService.HistoryToReturn =
|
||||
[
|
||||
new(Guid.NewGuid(), encryptedContent, "bob", null, "general",
|
||||
MessageType.Text, null, null, DateTimeOffset.UtcNow)
|
||||
new(Guid.NewGuid(), encryptedContent, "bob", null, "general", DateTimeOffset.UtcNow)
|
||||
];
|
||||
|
||||
var lines = await RunAuthenticated(["JOIN #general"]);
|
||||
|
||||
@@ -11,32 +11,31 @@ public class IrcMessageFormatterTests
|
||||
string channel = "general", List<EmbedDto>? embeds = null)
|
||||
{
|
||||
return new MessageDto(
|
||||
Guid.NewGuid(), content, sender, null, channel,
|
||||
MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds);
|
||||
Guid.NewGuid(), content, sender, null, channel, DateTimeOffset.UtcNow, Embeds: embeds);
|
||||
}
|
||||
|
||||
private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",
|
||||
string url = "https://example.com/image.png", string sender = "alice", string channel = "general")
|
||||
{
|
||||
return new MessageDto(
|
||||
Guid.NewGuid(), asciiArt, sender, null, channel,
|
||||
MessageType.Image, url, fileName, DateTimeOffset.UtcNow);
|
||||
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||
[new AttachmentDto(AttachmentKind.Image, url, fileName, 0, asciiArt)]);
|
||||
}
|
||||
|
||||
private static MessageDto CreateFileMessage(string fileName = "doc.pdf",
|
||||
string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general")
|
||||
{
|
||||
return new MessageDto(
|
||||
Guid.NewGuid(), "", sender, null, channel,
|
||||
MessageType.File, url, fileName, DateTimeOffset.UtcNow);
|
||||
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||
[new AttachmentDto(AttachmentKind.File, url, fileName, 0)]);
|
||||
}
|
||||
|
||||
private static MessageDto CreateAudioMessage(string fileName = "song.mp3",
|
||||
string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general")
|
||||
{
|
||||
return new MessageDto(
|
||||
Guid.NewGuid(), "", sender, null, channel,
|
||||
MessageType.Audio, url, fileName, DateTimeOffset.UtcNow);
|
||||
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||
[new AttachmentDto(AttachmentKind.Audio, url, fileName, 0)]);
|
||||
}
|
||||
|
||||
// ── FormatMessage ────────────────────────────────────────────────────
|
||||
@@ -115,8 +114,7 @@ public class IrcMessageFormatterTests
|
||||
var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg");
|
||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]"));
|
||||
Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg"));
|
||||
Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]") && l.Contains("https://example.com/photo.jpg"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -8,22 +8,18 @@ namespace EchoHub.Tests;
|
||||
public class IrcMessageFormatterTests
|
||||
{
|
||||
private static MessageDto CreateMessage(
|
||||
MessageType type = MessageType.Text,
|
||||
string content = "hello",
|
||||
string sender = "alice",
|
||||
string channel = "general",
|
||||
string? attachmentUrl = null,
|
||||
string? attachmentFileName = null,
|
||||
List<AttachmentDto>? attachments = null,
|
||||
List<EmbedDto>? embeds = null) => new(
|
||||
Id: Guid.NewGuid(),
|
||||
Content: content,
|
||||
SenderUsername: sender,
|
||||
SenderNicknameColor: null,
|
||||
ChannelName: channel,
|
||||
Type: type,
|
||||
AttachmentUrl: attachmentUrl,
|
||||
AttachmentFileName: attachmentFileName,
|
||||
SentAt: DateTimeOffset.UtcNow,
|
||||
Attachments: attachments,
|
||||
Embeds: embeds);
|
||||
|
||||
// ── FormatMessage ─────────────────────────────────────────────────
|
||||
@@ -51,34 +47,29 @@ public class IrcMessageFormatterTests
|
||||
|
||||
Assert.True(lines.Count >= 2);
|
||||
Assert.Contains("PRIVMSG #general :check this out", lines[0]);
|
||||
// Embed lines contain the Unicode pipe char and site/title
|
||||
Assert.Contains("GitHub", lines[1]);
|
||||
Assert.Contains("Repo Title", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatMessage_ImageMessage_IncludesImageTagAndDownloadUrl()
|
||||
public void FormatMessage_ImageAttachment_IncludesImageTagAndDownloadUrl()
|
||||
{
|
||||
var msg = CreateMessage(
|
||||
type: MessageType.Image,
|
||||
content: "{F:FF0000}\u2588{X}",
|
||||
attachmentUrl: "/api/files/abc",
|
||||
attachmentFileName: "photo.png");
|
||||
content: "",
|
||||
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
|
||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||
|
||||
Assert.True(lines.Count >= 2);
|
||||
Assert.Contains("[Image: photo.png]", lines[0]);
|
||||
Assert.Contains("Download: /api/files/abc", lines[1]);
|
||||
Assert.Contains("/api/files/abc", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatMessage_FileMessage_IncludesFileTag()
|
||||
public void FormatMessage_FileAttachment_IncludesFileTag()
|
||||
{
|
||||
var msg = CreateMessage(
|
||||
type: MessageType.File,
|
||||
content: "report.pdf",
|
||||
attachmentUrl: "/api/files/xyz",
|
||||
attachmentFileName: "report.pdf");
|
||||
content: "",
|
||||
attachments: [new AttachmentDto(AttachmentKind.File, "/api/files/xyz", "report.pdf", 0)]);
|
||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||
|
||||
Assert.Single(lines);
|
||||
@@ -87,21 +78,49 @@ public class IrcMessageFormatterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag()
|
||||
public void FormatMessage_AudioAttachment_IncludesMusicNoteAndAudioTag()
|
||||
{
|
||||
var msg = CreateMessage(
|
||||
type: MessageType.Audio,
|
||||
content: "song.mp3",
|
||||
attachmentUrl: "/api/files/def",
|
||||
attachmentFileName: "song.mp3");
|
||||
content: "",
|
||||
attachments: [new AttachmentDto(AttachmentKind.Audio, "/api/files/def", "song.mp3", 0)]);
|
||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("\u266a", lines[0]); // ♪
|
||||
Assert.Contains("♪", lines[0]);
|
||||
Assert.Contains("[Audio: song.mp3]", lines[0]);
|
||||
Assert.Contains("/api/files/def", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatMessage_CaptionWithAttachment_RendersBoth()
|
||||
{
|
||||
var msg = CreateMessage(
|
||||
content: "check this photo",
|
||||
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/p", "pic.png", 0, null)]);
|
||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("check this photo"));
|
||||
Assert.Contains(lines, l => l.Contains("[Image: pic.png]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatMessage_MultipleAttachments_RendersEach()
|
||||
{
|
||||
var msg = CreateMessage(
|
||||
content: "",
|
||||
attachments:
|
||||
[
|
||||
new AttachmentDto(AttachmentKind.Image, "/api/files/1", "a.png", 0, null),
|
||||
new AttachmentDto(AttachmentKind.Audio, "/api/files/2", "b.mp3", 0),
|
||||
new AttachmentDto(AttachmentKind.File, "/api/files/3", "c.pdf", 0),
|
||||
]);
|
||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("[Image: a.png]"));
|
||||
Assert.Contains(lines, l => l.Contains("[Audio: b.mp3]"));
|
||||
Assert.Contains(lines, l => l.Contains("[File: c.pdf]"));
|
||||
}
|
||||
|
||||
// ── ColorTagsToAnsi ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -179,7 +198,6 @@ public class IrcMessageFormatterTests
|
||||
var longWord = new string('a', 500);
|
||||
var result = IrcMessageFormatter.SplitMessage(longWord, 400);
|
||||
|
||||
// Single word can't be split at word boundary, so it stays as one chunk
|
||||
Assert.Single(result);
|
||||
Assert.Equal(longWord, result[0]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user