diff --git a/src/EchoHub.Tests/ChatLineTests.cs b/src/EchoHub.Tests/ChatLineTests.cs
new file mode 100644
index 0000000..16abab5
--- /dev/null
+++ b/src/EchoHub.Tests/ChatLineTests.cs
@@ -0,0 +1,107 @@
+using EchoHub.Client.UI;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+///
+/// Tests for static string-utility methods on ChatLine.
+/// Note: Tests that construct ChatLine/ChatListSource or use Terminal.Gui types
+/// (Attribute, Color) are excluded because Terminal.Gui's module initializer
+/// requires a display driver which is unavailable in CI/test environments.
+///
+public class ChatLineTests
+{
+ // ── HasColorTags ──────────────────────────────────────────────────
+
+ [Fact]
+ public void HasColorTags_ForegroundTag_ReturnsTrue()
+ {
+ Assert.True(ChatLine.HasColorTags("Hello {F:FF0000}world"));
+ }
+
+ [Fact]
+ public void HasColorTags_BackgroundTag_ReturnsTrue()
+ {
+ Assert.True(ChatLine.HasColorTags("Hello {B:00FF00}world"));
+ }
+
+ [Fact]
+ public void HasColorTags_ResetTag_ReturnsTrue()
+ {
+ Assert.True(ChatLine.HasColorTags("Hello{X}"));
+ }
+
+ [Fact]
+ public void HasColorTags_NoTags_ReturnsFalse()
+ {
+ Assert.False(ChatLine.HasColorTags("Hello world"));
+ }
+
+ [Fact]
+ public void HasColorTags_PartialTag_ReturnsFalse()
+ {
+ // {Z:...} is not a valid tag (only F or B)
+ Assert.False(ChatLine.HasColorTags("Hello {Z:000000}"));
+ }
+
+ [Fact]
+ public void HasColorTags_EmptyString_ReturnsFalse()
+ {
+ Assert.False(ChatLine.HasColorTags(""));
+ }
+
+ [Theory]
+ [InlineData("{F:AABBCC}text")]
+ [InlineData("prefix{B:112233}suffix")]
+ [InlineData("a{X}b")]
+ [InlineData("{F:000000}{B:FFFFFF}{X}")]
+ public void HasColorTags_VariousValidTags_ReturnsTrue(string input)
+ {
+ Assert.True(ChatLine.HasColorTags(input));
+ }
+
+ // ── StripColorTags ────────────────────────────────────────────────
+
+ [Fact]
+ public void StripColorTags_RemovesAllTags()
+ {
+ var result = ChatLine.StripColorTags("{F:FF0000}red{B:00FF00}green{X}");
+ Assert.Equal("redgreen", result);
+ }
+
+ [Fact]
+ public void StripColorTags_NoTags_ReturnsOriginal()
+ {
+ var result = ChatLine.StripColorTags("plain text");
+ Assert.Equal("plain text", result);
+ }
+
+ [Fact]
+ public void StripColorTags_OnlyTags_ReturnsEmpty()
+ {
+ var result = ChatLine.StripColorTags("{F:AABBCC}{B:112233}{X}");
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void StripColorTags_MixedContent_KeepsText()
+ {
+ var result = ChatLine.StripColorTags("before{F:FF0000}middle{X}after");
+ Assert.Equal("beforemiddleafter", result);
+ }
+
+ [Fact]
+ public void StripColorTags_MultipleConsecutiveTags_AllStripped()
+ {
+ var result = ChatLine.StripColorTags("{F:FF0000}{B:00FF00}{X}{F:0000FF}text{X}");
+ Assert.Equal("text", result);
+ }
+
+ [Fact]
+ public void StripColorTags_PreservesNonTagBraces()
+ {
+ // {Hello} is not a valid tag and should be preserved
+ var result = ChatLine.StripColorTags("{Hello} world");
+ Assert.Equal("{Hello} world", result);
+ }
+}
diff --git a/src/EchoHub.Tests/CommandHandlerTests.cs b/src/EchoHub.Tests/CommandHandlerTests.cs
new file mode 100644
index 0000000..5c58bf7
--- /dev/null
+++ b/src/EchoHub.Tests/CommandHandlerTests.cs
@@ -0,0 +1,462 @@
+using EchoHub.Client.Commands;
+using EchoHub.Core.Models;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class CommandHandlerTests
+{
+ private CommandHandler CreateHandler() => new();
+
+ // ── IsCommand ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void IsCommand_StartsWithSlash_ReturnsTrue()
+ {
+ var handler = CreateHandler();
+ Assert.True(handler.IsCommand("/help"));
+ }
+
+ [Fact]
+ public void IsCommand_NoSlash_ReturnsFalse()
+ {
+ var handler = CreateHandler();
+ Assert.False(handler.IsCommand("hello"));
+ }
+
+ [Fact]
+ public void IsCommand_EmptyString_ReturnsFalse()
+ {
+ var handler = CreateHandler();
+ Assert.False(handler.IsCommand(""));
+ }
+
+ // ── HandleAsync — not a command ───────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_NotCommand_ReturnsFalse()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("hello world");
+
+ Assert.False(result.Handled);
+ }
+
+ // ── HandleAsync — unknown command ─────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_UnknownCommand_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/doesnotexist");
+
+ Assert.True(result.Handled);
+ Assert.True(result.IsError);
+ Assert.Contains("Unknown command", result.Message);
+ }
+
+ // ── /status ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_StatusOnline_SetsOnlineStatus()
+ {
+ var handler = CreateHandler();
+ UserStatus? capturedStatus = null;
+ handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/status online");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal(UserStatus.Online, capturedStatus);
+ }
+
+ [Fact]
+ public async Task HandleAsync_StatusAway_SetsAwayStatus()
+ {
+ var handler = CreateHandler();
+ UserStatus? capturedStatus = null;
+ handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/status away");
+ Assert.Equal(UserStatus.Away, capturedStatus);
+ }
+
+ [Fact]
+ public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage()
+ {
+ var handler = CreateHandler();
+ string? capturedMessage = null;
+ handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/status brb lunch");
+
+ Assert.True(result.Handled);
+ Assert.Contains("brb lunch", result.Message);
+ Assert.Equal("brb lunch", capturedMessage);
+ }
+
+ [Fact]
+ public async Task HandleAsync_StatusNoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/status");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Usage", result.Message);
+ }
+
+ // ── /nick ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Nick_SetsDisplayName()
+ {
+ var handler = CreateHandler();
+ string? capturedNick = null;
+ handler.OnSetNick += nick => { capturedNick = nick; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/nick Bob Smith");
+
+ Assert.True(result.Handled);
+ Assert.Equal("Bob Smith", capturedNick);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Nick_EmptyArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/nick");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Usage", result.Message);
+ }
+
+ // ── /color ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Color_ValidHex_Succeeds()
+ {
+ var handler = CreateHandler();
+ string? capturedColor = null;
+ handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/color #FF5733");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal("#FF5733", capturedColor);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_WithoutHash_AddsHash()
+ {
+ var handler = CreateHandler();
+ string? capturedColor = null;
+ handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/color FF5733");
+ Assert.Equal("#FF5733", capturedColor);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_InvalidHex_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/color #ZZZZZZ");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Invalid color", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_TooShort_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/color #FFF");
+
+ Assert.True(result.IsError);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/color");
+
+ Assert.True(result.IsError);
+ }
+
+ // ── /join ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Join_StripsHashPrefix()
+ {
+ var handler = CreateHandler();
+ string? capturedChannel = null;
+ handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/join #random");
+ Assert.Equal("random", capturedChannel);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Join_NoHash_PassedDirectly()
+ {
+ var handler = CreateHandler();
+ string? capturedChannel = null;
+ handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/join random");
+ Assert.Equal("random", capturedChannel);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Join_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/join");
+
+ Assert.True(result.IsError);
+ }
+
+ // ── /kick ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Kick_WithReason_ParsesUsernameAndReason()
+ {
+ var handler = CreateHandler();
+ string? capturedUser = null;
+ string? capturedReason = null;
+ handler.OnKickUser += (user, reason) =>
+ {
+ capturedUser = user;
+ capturedReason = reason;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/kick baduser being rude");
+ Assert.Equal("baduser", capturedUser);
+ Assert.Equal("being rude", capturedReason);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Kick_WithoutReason_NullReason()
+ {
+ var handler = CreateHandler();
+ string? capturedReason = "initial";
+ handler.OnKickUser += (user, reason) =>
+ {
+ capturedReason = reason;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/kick baduser");
+ Assert.Null(capturedReason);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Kick_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/kick");
+ Assert.True(result.IsError);
+ }
+
+ // ── /mute ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Mute_WithDuration_ParsesDuration()
+ {
+ var handler = CreateHandler();
+ int? capturedDuration = null;
+ handler.OnMuteUser += (user, duration) =>
+ {
+ capturedDuration = duration;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/mute alice 30");
+ Assert.Equal(30, capturedDuration);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Mute_WithoutDuration_NullDuration()
+ {
+ var handler = CreateHandler();
+ int? capturedDuration = -1;
+ handler.OnMuteUser += (user, duration) =>
+ {
+ capturedDuration = duration;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/mute alice");
+ Assert.Null(capturedDuration);
+ }
+
+ // ── /role ─────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("admin")]
+ [InlineData("mod")]
+ [InlineData("member")]
+ public async Task HandleAsync_Role_ValidRole_Succeeds(string role)
+ {
+ var handler = CreateHandler();
+ string? capturedRole = null;
+ handler.OnAssignRole += (user, r) => { capturedRole = r; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync($"/role alice {role}");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal(role, capturedRole);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Role_InvalidRole_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/role alice superadmin");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Invalid role", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Role_MissingRole_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/role alice");
+
+ Assert.True(result.IsError);
+ }
+
+ // ── /send ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Send_NoArgs_ReturnsUsageError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/send");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Usage", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Send_UrlInput_RecognizedAsUrl()
+ {
+ var handler = CreateHandler();
+ string? capturedTarget = null;
+ handler.OnSendFile += (target, size) => { capturedTarget = target; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/send https://example.com/image.png");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal("https://example.com/image.png", capturedTarget);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Send_UrlWithSizeFlag_ExtractsSizeCorrectly()
+ {
+ var handler = CreateHandler();
+ string? capturedSize = null;
+ handler.OnSendFile += (target, size) => { capturedSize = size; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/send https://example.com/photo.jpg -s");
+ Assert.Equal("s", capturedSize);
+ }
+
+ // ── /help ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Help_ReturnsHelpText()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/help");
+
+ Assert.True(result.Handled);
+ Assert.Contains("Available commands", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_QuestionMark_ReturnsHelp()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/?");
+
+ Assert.True(result.Handled);
+ Assert.Contains("Available commands", result.Message);
+ }
+
+ // ── /ban ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Ban_WithReason_ParsesUsernameAndReason()
+ {
+ var handler = CreateHandler();
+ string? capturedUser = null;
+ string? capturedReason = null;
+ handler.OnBanUser += (user, reason) =>
+ {
+ capturedUser = user;
+ capturedReason = reason;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/ban troll spamming links");
+ Assert.Equal("troll", capturedUser);
+ Assert.Equal("spamming links", capturedReason);
+ }
+
+ // ── /topic ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Topic_SetsTopic()
+ {
+ var handler = CreateHandler();
+ string? capturedTopic = null;
+ handler.OnSetTopic += topic => { capturedTopic = topic; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/topic Welcome to our channel!");
+
+ Assert.True(result.Handled);
+ Assert.Equal("Welcome to our channel!", capturedTopic);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Topic_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/topic");
+ Assert.True(result.IsError);
+ }
+
+ // ── /quit and /exit ───────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Quit_Handled()
+ {
+ var handler = CreateHandler();
+ var quitCalled = false;
+ handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/quit");
+ Assert.True(result.Handled);
+ Assert.True(quitCalled);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Exit_Handled()
+ {
+ var handler = CreateHandler();
+ var quitCalled = false;
+ handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/exit");
+ Assert.True(result.Handled);
+ Assert.True(quitCalled);
+ }
+}
diff --git a/src/EchoHub.Tests/DataMigrationServiceTests.cs b/src/EchoHub.Tests/DataMigrationServiceTests.cs
new file mode 100644
index 0000000..31c2198
--- /dev/null
+++ b/src/EchoHub.Tests/DataMigrationServiceTests.cs
@@ -0,0 +1,74 @@
+using EchoHub.Server.Setup;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class DataMigrationServiceTests
+{
+ // ── AnsiToColorTags ───────────────────────────────────────────────
+
+ [Fact]
+ public void AnsiToColorTags_ForegroundEscape_ConvertedToColorTag()
+ {
+ var ansi = "\x1b[38;2;255;0;0mred text";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{F:FF0000}red text", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_BackgroundEscape_ConvertedToColorTag()
+ {
+ var ansi = "\x1b[48;2;0;255;0mgreen bg";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{B:00FF00}green bg", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_ResetEscape_ConvertedToResetTag()
+ {
+ var ansi = "\x1b[0m";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{X}", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_NoEscapes_ReturnsUnchanged()
+ {
+ var text = "Hello, world!";
+ var result = DataMigrationService.AnsiToColorTags(text);
+
+ Assert.Equal("Hello, world!", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_MixedContent_ConvertsEscapesOnly()
+ {
+ var ansi = "before\x1b[38;2;100;200;50mtextafter";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("before{F:64C832}textafter", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_MultipleTags_AllConverted()
+ {
+ var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;0;255mblue bg\x1b[0mreset";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{F:FF0000}red{B:0000FF}blue bg{X}reset", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi()
+ {
+ // AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses
+ var original = "{F:FF0000}red{B:00FF00}green{X}";
+ var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original);
+ var backToTags = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal(original, backToTags);
+ }
+}
diff --git a/src/EchoHub.Tests/EchoHub.Tests.csproj b/src/EchoHub.Tests/EchoHub.Tests.csproj
index 1a9b2a8..c3d45b4 100644
--- a/src/EchoHub.Tests/EchoHub.Tests.csproj
+++ b/src/EchoHub.Tests/EchoHub.Tests.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/EchoHub.Tests/FileValidationHelperTests.cs b/src/EchoHub.Tests/FileValidationHelperTests.cs
index 4a0c3eb..59bc283 100644
--- a/src/EchoHub.Tests/FileValidationHelperTests.cs
+++ b/src/EchoHub.Tests/FileValidationHelperTests.cs
@@ -53,4 +53,48 @@ public class FileValidationHelperTests
FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position);
}
+
+ // ── IsAudioFile tests ─────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("song.mp3")]
+ [InlineData("track.wav")]
+ [InlineData("audio.ogg")]
+ [InlineData("music.flac")]
+ [InlineData("clip.aac")]
+ [InlineData("podcast.m4a")]
+ [InlineData("old.wma")]
+ public void IsAudioFile_SupportedExtensions_ReturnsTrue(string fileName)
+ {
+ Assert.True(FileValidationHelper.IsAudioFile(fileName));
+ }
+
+ [Theory]
+ [InlineData("song.MP3")]
+ [InlineData("track.Wav")]
+ [InlineData("audio.OGG")]
+ [InlineData("music.FLAC")]
+ public void IsAudioFile_CaseInsensitive_ReturnsTrue(string fileName)
+ {
+ Assert.True(FileValidationHelper.IsAudioFile(fileName));
+ }
+
+ [Theory]
+ [InlineData("document.txt")]
+ [InlineData("report.pdf")]
+ [InlineData("app.exe")]
+ [InlineData("photo.jpg")]
+ [InlineData("image.png")]
+ public void IsAudioFile_NonAudioExtension_ReturnsFalse(string fileName)
+ {
+ Assert.False(FileValidationHelper.IsAudioFile(fileName));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("noextension")]
+ public void IsAudioFile_EmptyOrNoExtension_ReturnsFalse(string fileName)
+ {
+ Assert.False(FileValidationHelper.IsAudioFile(fileName));
+ }
}
diff --git a/src/EchoHub.Tests/ImageToAsciiServiceTests.cs b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs
new file mode 100644
index 0000000..9e9238c
--- /dev/null
+++ b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs
@@ -0,0 +1,62 @@
+using EchoHub.Core.Constants;
+using EchoHub.Server.Services;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class ImageToAsciiServiceTests
+{
+ [Fact]
+ public void GetDimensions_Small_Returns40x40()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("s");
+ Assert.Equal(40, w);
+ Assert.Equal(40, h);
+ }
+
+ [Fact]
+ public void GetDimensions_Large_Returns120x120()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("l");
+ Assert.Equal(120, w);
+ Assert.Equal(120, h);
+ }
+
+ [Fact]
+ public void GetDimensions_Default_Returns80x80()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("m");
+ Assert.Equal(HubConstants.AsciiArtWidth, w);
+ Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
+ }
+
+ [Fact]
+ public void GetDimensions_Null_ReturnsDefault()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions(null);
+ Assert.Equal(HubConstants.AsciiArtWidth, w);
+ Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
+ }
+
+ [Fact]
+ public void GetDimensions_CaseInsensitive()
+ {
+ var (w1, h1) = ImageToAsciiService.GetDimensions("S");
+ var (w2, h2) = ImageToAsciiService.GetDimensions("s");
+ Assert.Equal(w1, w2);
+ Assert.Equal(h1, h2);
+
+ var (w3, h3) = ImageToAsciiService.GetDimensions("L");
+ var (w4, h4) = ImageToAsciiService.GetDimensions("l");
+ Assert.Equal(w3, w4);
+ Assert.Equal(h3, h4);
+ }
+
+ [Fact]
+ public void GetDimensions_UnknownSize_ReturnsDefault()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("xl");
+ Assert.Equal(HubConstants.AsciiArtWidth, w);
+ Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
+ }
+}
diff --git a/src/EchoHub.Tests/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/IrcMessageFormatterTests.cs
new file mode 100644
index 0000000..5e407b5
--- /dev/null
+++ b/src/EchoHub.Tests/IrcMessageFormatterTests.cs
@@ -0,0 +1,186 @@
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using EchoHub.Server.Irc;
+using Xunit;
+
+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? embeds = null) => new(
+ Id: Guid.NewGuid(),
+ Content: content,
+ SenderUsername: sender,
+ SenderNicknameColor: null,
+ ChannelName: channel,
+ Type: type,
+ AttachmentUrl: attachmentUrl,
+ AttachmentFileName: attachmentFileName,
+ SentAt: DateTimeOffset.UtcNow,
+ Embeds: embeds);
+
+ // ── FormatMessage ─────────────────────────────────────────────────
+
+ [Fact]
+ public void FormatMessage_TextMessage_FormatsAsPRIVMSG()
+ {
+ var msg = CreateMessage(content: "Hello world");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("PRIVMSG #general :Hello world", lines[0]);
+ Assert.StartsWith(":alice!alice@echohub", lines[0]);
+ }
+
+ [Fact]
+ public void FormatMessage_TextMessage_WithEmbeds_AppendsEmbedLines()
+ {
+ var embeds = new List
+ {
+ new("GitHub", "Repo Title", "A description", null, "https://github.com/test")
+ };
+ var msg = CreateMessage(content: "check this out https://github.com/test", embeds: embeds);
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ 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()
+ {
+ var msg = CreateMessage(
+ type: MessageType.Image,
+ content: "{F:FF0000}\u2588{X}",
+ attachmentUrl: "/api/files/abc",
+ attachmentFileName: "photo.png");
+ 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]);
+ }
+
+ [Fact]
+ public void FormatMessage_FileMessage_IncludesFileTag()
+ {
+ var msg = CreateMessage(
+ type: MessageType.File,
+ content: "report.pdf",
+ attachmentUrl: "/api/files/xyz",
+ attachmentFileName: "report.pdf");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("[File: report.pdf]", lines[0]);
+ Assert.Contains("/api/files/xyz", lines[0]);
+ }
+
+ [Fact]
+ public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag()
+ {
+ var msg = CreateMessage(
+ type: MessageType.Audio,
+ content: "song.mp3",
+ attachmentUrl: "/api/files/def",
+ attachmentFileName: "song.mp3");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("\u266a", lines[0]); // ♪
+ Assert.Contains("[Audio: song.mp3]", lines[0]);
+ Assert.Contains("/api/files/def", lines[0]);
+ }
+
+ // ── ColorTagsToAnsi ───────────────────────────────────────────────
+
+ [Fact]
+ public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text");
+ Assert.Contains("\x1b[38;2;255;0;0m", result);
+ Assert.Contains("text", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text");
+ Assert.Contains("\x1b[48;2;0;255;0m", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{X}");
+ Assert.Equal("\x1b[0m", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("plain text");
+ Assert.Equal("plain text", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}");
+ Assert.Contains("\x1b[38;2;255;0;0m", result);
+ Assert.Contains("\x1b[38;2;0;0;255m", result);
+ Assert.Contains("\x1b[0m", result);
+ Assert.Contains("red", result);
+ Assert.Contains("blue", result);
+ }
+
+ // ── SplitMessage ──────────────────────────────────────────────────
+
+ [Fact]
+ public void SplitMessage_ShortMessage_ReturnsSingleChunk()
+ {
+ var result = IrcMessageFormatter.SplitMessage("Hello", 400);
+ Assert.Single(result);
+ Assert.Equal("Hello", result[0]);
+ }
+
+ [Fact]
+ public void SplitMessage_LongMessage_SplitsAtWordBoundary()
+ {
+ var words = string.Join(" ", Enumerable.Repeat("word", 200));
+ var result = IrcMessageFormatter.SplitMessage(words, 50);
+
+ Assert.True(result.Count > 1);
+ foreach (var chunk in result)
+ Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 50);
+ }
+
+ [Fact]
+ public void SplitMessage_EmptyMessage_ReturnsSingleEmptyChunk()
+ {
+ var result = IrcMessageFormatter.SplitMessage("", 400);
+ Assert.Single(result);
+ Assert.Equal("", result[0]);
+ }
+
+ [Fact]
+ public void SplitMessage_SingleLongWord_KeptAsOneChunk()
+ {
+ 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]);
+ }
+}
diff --git a/src/EchoHub.Tests/JwtTokenServiceTests.cs b/src/EchoHub.Tests/JwtTokenServiceTests.cs
new file mode 100644
index 0000000..a9367c8
--- /dev/null
+++ b/src/EchoHub.Tests/JwtTokenServiceTests.cs
@@ -0,0 +1,195 @@
+using System.IdentityModel.Tokens.Jwt;
+using EchoHub.Core.Models;
+using EchoHub.Server.Auth;
+using Microsoft.Extensions.Configuration;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class JwtTokenServiceTests
+{
+ private const string TestSecret = "this_is_a_test_secret_key_that_is_long_enough_for_hmac_sha256";
+ private const string TestIssuer = "TestIssuer";
+ private const string TestAudience = "TestAudience";
+
+ private static JwtTokenService CreateService(
+ string? secret = null, string? issuer = null, string? audience = null)
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Secret"] = secret ?? TestSecret,
+ ["Jwt:Issuer"] = issuer ?? TestIssuer,
+ ["Jwt:Audience"] = audience ?? TestAudience,
+ })
+ .Build();
+
+ return new JwtTokenService(config);
+ }
+
+ private static User CreateUser(
+ string username = "alice",
+ ServerRole role = ServerRole.Member,
+ string? displayName = null) => new()
+ {
+ Id = Guid.NewGuid(),
+ Username = username,
+ PasswordHash = "hash",
+ DisplayName = displayName,
+ Role = role,
+ };
+
+ // ── Constructor ───────────────────────────────────────────────────
+
+ [Fact]
+ public void Constructor_MissingSecret_Throws()
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Issuer"] = TestIssuer,
+ ["Jwt:Audience"] = TestAudience,
+ })
+ .Build();
+
+ Assert.Throws(() => new JwtTokenService(config));
+ }
+
+ [Fact]
+ public void Constructor_MissingIssuer_Throws()
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Secret"] = TestSecret,
+ ["Jwt:Audience"] = TestAudience,
+ })
+ .Build();
+
+ Assert.Throws(() => new JwtTokenService(config));
+ }
+
+ [Fact]
+ public void Constructor_MissingAudience_Throws()
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Secret"] = TestSecret,
+ ["Jwt:Issuer"] = TestIssuer,
+ })
+ .Build();
+
+ Assert.Throws(() => new JwtTokenService(config));
+ }
+
+ // ── GenerateAccessToken ───────────────────────────────────────────
+
+ [Fact]
+ public void GenerateAccessToken_ContainsExpectedClaims()
+ {
+ var service = CreateService();
+ var user = CreateUser(username: "bob", role: ServerRole.Admin, displayName: "Bob Smith");
+
+ var (token, _) = service.GenerateAccessToken(user);
+ var handler = new JwtSecurityTokenHandler();
+ var jwt = handler.ReadJwtToken(token);
+
+ Assert.Equal(user.Id.ToString(), jwt.Claims.First(c => c.Type == "sub").Value);
+ Assert.Equal("bob", jwt.Claims.First(c => c.Type == "username").Value);
+ Assert.Equal("Bob Smith", jwt.Claims.First(c => c.Type == "display_name").Value);
+ Assert.Equal("Admin", jwt.Claims.First(c => c.Type == "role").Value);
+ Assert.NotNull(jwt.Claims.FirstOrDefault(c => c.Type == "jti"));
+ }
+
+ [Fact]
+ public void GenerateAccessToken_ExpiresIn15Minutes()
+ {
+ var service = CreateService();
+ var user = CreateUser();
+
+ var (_, expiresAt) = service.GenerateAccessToken(user);
+ var diff = expiresAt - DateTimeOffset.UtcNow;
+
+ // Should be approximately 15 minutes (allow 30s tolerance)
+ Assert.InRange(diff.TotalMinutes, 14.5, 15.5);
+ }
+
+ [Fact]
+ public void GenerateAccessToken_DifferentTokensForSameUser()
+ {
+ var service = CreateService();
+ var user = CreateUser();
+
+ var (token1, _) = service.GenerateAccessToken(user);
+ var (token2, _) = service.GenerateAccessToken(user);
+
+ Assert.NotEqual(token1, token2);
+ }
+
+ [Fact]
+ public void GenerateAccessToken_DisplayNameFallsBackToUsername()
+ {
+ var service = CreateService();
+ var user = CreateUser(username: "alice"); // DisplayName is null
+
+ var (token, _) = service.GenerateAccessToken(user);
+ var handler = new JwtSecurityTokenHandler();
+ var jwt = handler.ReadJwtToken(token);
+
+ Assert.Equal("alice", jwt.Claims.First(c => c.Type == "display_name").Value);
+ }
+
+ // ── GenerateRefreshToken ──────────────────────────────────────────
+
+ [Fact]
+ public void GenerateRefreshToken_Returns88CharBase64()
+ {
+ var token = JwtTokenService.GenerateRefreshToken();
+
+ // 64 bytes → 88 base64 characters
+ Assert.Equal(88, token.Length);
+ // Should be valid base64
+ var bytes = Convert.FromBase64String(token);
+ Assert.Equal(64, bytes.Length);
+ }
+
+ [Fact]
+ public void GenerateRefreshToken_UniqueBetweenCalls()
+ {
+ var token1 = JwtTokenService.GenerateRefreshToken();
+ var token2 = JwtTokenService.GenerateRefreshToken();
+
+ Assert.NotEqual(token1, token2);
+ }
+
+ // ── HashToken ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void HashToken_DeterministicForSameInput()
+ {
+ var hash1 = JwtTokenService.HashToken("test-token");
+ var hash2 = JwtTokenService.HashToken("test-token");
+
+ Assert.Equal(hash1, hash2);
+ }
+
+ [Fact]
+ public void HashToken_DifferentForDifferentInput()
+ {
+ var hash1 = JwtTokenService.HashToken("token-a");
+ var hash2 = JwtTokenService.HashToken("token-b");
+
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public void HashToken_ReturnsBase64String()
+ {
+ var hash = JwtTokenService.HashToken("test-token");
+
+ // SHA256 → 32 bytes → 44 base64 characters
+ var bytes = Convert.FromBase64String(hash);
+ Assert.Equal(32, bytes.Length);
+ }
+}
diff --git a/src/EchoHub.Tests/LinkEmbedServiceTests.cs b/src/EchoHub.Tests/LinkEmbedServiceTests.cs
new file mode 100644
index 0000000..735d67a
--- /dev/null
+++ b/src/EchoHub.Tests/LinkEmbedServiceTests.cs
@@ -0,0 +1,192 @@
+using System.Reflection;
+using EchoHub.Server.Services;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class LinkEmbedServiceTests
+{
+ private static readonly MethodInfo ExtractUrlsMethod = typeof(LinkEmbedService)
+ .GetMethod("ExtractUrls", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static readonly MethodInfo IsPrivateHostMethod = typeof(LinkEmbedService)
+ .GetMethod("IsPrivateHost", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static readonly MethodInfo ParseOgTagsMethod = typeof(LinkEmbedService)
+ .GetMethod("ParseOgTags", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static List ExtractUrls(string content) =>
+ (List)ExtractUrlsMethod.Invoke(null, [content])!;
+
+ private static bool IsPrivateHost(Uri uri) =>
+ (bool)IsPrivateHostMethod.Invoke(null, [uri])!;
+
+ private static Dictionary ParseOgTags(string html) =>
+ (Dictionary)ParseOgTagsMethod.Invoke(null, [html])!;
+
+ // ── ExtractUrls ───────────────────────────────────────────────────
+
+ [Fact]
+ public void ExtractUrls_SingleUrl_ReturnsIt()
+ {
+ var urls = ExtractUrls("Check this: https://example.com");
+ Assert.Single(urls);
+ Assert.Equal("https://example.com", urls[0]);
+ }
+
+ [Fact]
+ public void ExtractUrls_MultipleUrls_ReturnsAll()
+ {
+ var urls = ExtractUrls("See https://a.com and https://b.com");
+ Assert.Equal(2, urls.Count);
+ Assert.Contains("https://a.com", urls);
+ Assert.Contains("https://b.com", urls);
+ }
+
+ [Fact]
+ public void ExtractUrls_UrlWithTrailingPunctuation_Trimmed()
+ {
+ var urls = ExtractUrls("Visit https://example.com.");
+ Assert.Single(urls);
+ Assert.Equal("https://example.com", urls[0]);
+ }
+
+ [Fact]
+ public void ExtractUrls_NoUrls_ReturnsEmpty()
+ {
+ var urls = ExtractUrls("No links here");
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void ExtractUrls_MaxUrlsLimit_Respected()
+ {
+ // EmbedMaxUrlsPerMessage = 3
+ var text = "https://a.com https://b.com https://c.com https://d.com https://e.com";
+ var urls = ExtractUrls(text);
+ Assert.Equal(3, urls.Count);
+ }
+
+ [Fact]
+ public void ExtractUrls_DuplicateUrls_Deduped()
+ {
+ var urls = ExtractUrls("https://example.com and https://example.com again");
+ Assert.Single(urls);
+ }
+
+ [Fact]
+ public void ExtractUrls_HttpUrl_Extracted()
+ {
+ var urls = ExtractUrls("http://example.com");
+ Assert.Single(urls);
+ Assert.StartsWith("http://", urls[0]);
+ }
+
+ // ── IsPrivateHost ─────────────────────────────────────────────────
+
+ [Fact]
+ public void IsPrivateHost_Localhost_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://localhost/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_LoopbackIP_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://127.0.0.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_10Network_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://10.0.0.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_172_16Network_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://172.16.0.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_192_168Network_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://192.168.1.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_PublicIP_ReturnsFalse()
+ {
+ Assert.False(IsPrivateHost(new Uri("http://8.8.8.8/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_PublicDomain_ReturnsFalse()
+ {
+ Assert.False(IsPrivateHost(new Uri("https://example.com/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_ZeroIP_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://0.0.0.0/test")));
+ }
+
+ // ── ParseOgTags ───────────────────────────────────────────────────
+
+ [Fact]
+ public void ParseOgTags_StandardOgTags_ParsedCorrectly()
+ {
+ var html = """
+
+
+
+
+
+ """;
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("Test Title", tags["title"]);
+ Assert.Equal("A description", tags["description"]);
+ Assert.Equal("TestSite", tags["site_name"]);
+ }
+
+ [Fact]
+ public void ParseOgTags_ReversedOrder_ParsedCorrectly()
+ {
+ var html = """""";
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("Reversed Title", tags["title"]);
+ }
+
+ [Fact]
+ public void ParseOgTags_NoOgTags_ReturnsEmptyDictionary()
+ {
+ var html = "Page";
+ var tags = ParseOgTags(html);
+
+ Assert.Empty(tags);
+ }
+
+ [Fact]
+ public void ParseOgTags_SingleQuotes_ParsedCorrectly()
+ {
+ var html = """""";
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("Single Quoted", tags["title"]);
+ }
+
+ [Fact]
+ public void ParseOgTags_DuplicateKeys_FirstWins()
+ {
+ var html = """
+
+
+ """;
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("First", tags["title"]);
+ }
+}