From 50817ce6e9150da3ac3d1a43a467599bd27ae1f1 Mon Sep 17 00:00:00 2001 From: HueByte Date: Fri, 20 Feb 2026 21:09:33 +0100 Subject: [PATCH] Add unit tests for various services and functionalities - Implement tests for ChatLine utility methods including color tag detection and stripping. - Create comprehensive tests for CommandHandler to validate command handling and status updates. - Add tests for DataMigrationService to ensure correct ANSI to color tag conversion. - Enhance FileValidationHelperTests with audio file validation tests. - Introduce ImageToAsciiServiceTests to verify image dimension retrieval. - Develop IrcMessageFormatterTests for message formatting and color tag conversion. - Add JwtTokenServiceTests to validate JWT token generation and hashing. - Implement LinkEmbedServiceTests for URL extraction and Open Graph tag parsing. - Update EchoHub.Tests.csproj to include necessary project references. --- src/EchoHub.Tests/ChatLineTests.cs | 107 ++++ src/EchoHub.Tests/CommandHandlerTests.cs | 462 ++++++++++++++++++ .../DataMigrationServiceTests.cs | 74 +++ src/EchoHub.Tests/EchoHub.Tests.csproj | 1 + .../FileValidationHelperTests.cs | 44 ++ src/EchoHub.Tests/ImageToAsciiServiceTests.cs | 62 +++ src/EchoHub.Tests/IrcMessageFormatterTests.cs | 186 +++++++ src/EchoHub.Tests/JwtTokenServiceTests.cs | 195 ++++++++ src/EchoHub.Tests/LinkEmbedServiceTests.cs | 192 ++++++++ 9 files changed, 1323 insertions(+) create mode 100644 src/EchoHub.Tests/ChatLineTests.cs create mode 100644 src/EchoHub.Tests/CommandHandlerTests.cs create mode 100644 src/EchoHub.Tests/DataMigrationServiceTests.cs create mode 100644 src/EchoHub.Tests/ImageToAsciiServiceTests.cs create mode 100644 src/EchoHub.Tests/IrcMessageFormatterTests.cs create mode 100644 src/EchoHub.Tests/JwtTokenServiceTests.cs create mode 100644 src/EchoHub.Tests/LinkEmbedServiceTests.cs 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..0e92dfa --- /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"]); + } +}