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:
HueByte
2026-07-16 05:02:12 +02:00
parent e05b420ce9
commit 2d33773c24
33 changed files with 1843 additions and 454 deletions
+116
View File
@@ -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;
}
}
+3 -6
View File
@@ -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]
+43 -25
View File
@@ -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]);
}