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.
This commit is contained in:
HueByte
2026-02-20 23:02:51 +01:00
committed by Hue
parent 9265a9f312
commit 50817ce6e9
9 changed files with 1323 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
using EchoHub.Client.UI;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}
+462
View File
@@ -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);
}
}
@@ -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);
}
}
+1
View File
@@ -21,6 +21,7 @@
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" /> <ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" /> <ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -53,4 +53,48 @@ public class FileValidationHelperTests
FileValidationHelper.IsValidImage(stream); FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position); 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));
}
} }
@@ -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);
}
}
@@ -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<EmbedDto>? 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<EmbedDto>
{
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]);
}
}
+195
View File
@@ -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<string, string?>
{
["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<string, string?>
{
["Jwt:Issuer"] = TestIssuer,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingIssuer_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingAudience_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Issuer"] = TestIssuer,
})
.Build();
Assert.Throws<InvalidOperationException>(() => 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);
}
}
+192
View File
@@ -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<string> ExtractUrls(string content) =>
(List<string>)ExtractUrlsMethod.Invoke(null, [content])!;
private static bool IsPrivateHost(Uri uri) =>
(bool)IsPrivateHostMethod.Invoke(null, [uri])!;
private static Dictionary<string, string> ParseOgTags(string html) =>
(Dictionary<string, string>)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 = """
<html><head>
<meta property="og:title" content="Test Title" />
<meta property="og:description" content="A description" />
<meta property="og:site_name" content="TestSite" />
</head></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 = """<meta content="Reversed Title" property="og:title" />""";
var tags = ParseOgTags(html);
Assert.Equal("Reversed Title", tags["title"]);
}
[Fact]
public void ParseOgTags_NoOgTags_ReturnsEmptyDictionary()
{
var html = "<html><head><title>Page</title></head></html>";
var tags = ParseOgTags(html);
Assert.Empty(tags);
}
[Fact]
public void ParseOgTags_SingleQuotes_ParsedCorrectly()
{
var html = """<meta property='og:title' content='Single Quoted' />""";
var tags = ParseOgTags(html);
Assert.Equal("Single Quoted", tags["title"]);
}
[Fact]
public void ParseOgTags_DuplicateKeys_FirstWins()
{
var html = """
<meta property="og:title" content="First" />
<meta property="og:title" content="Second" />
""";
var tags = ParseOgTags(html);
Assert.Equal("First", tags["title"]);
}
}