From c0d38015ff8188e7127aa3bb9dbbdf6629696607 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sat, 21 Feb 2026 11:06:00 +0100 Subject: [PATCH] Add comprehensive unit tests for IRC command handling and message formatting - Implemented IrcCommandHandlerTests to cover various IRC commands including PING, JOIN, PART, and authentication scenarios. - Added IrcMessageFormatterTests to validate message formatting for different message types such as text, images, files, and audio. - Created IrcMessageTests to ensure correct parsing of IRC messages and handling of various command formats. - Introduced TestHelpers to facilitate testing with mock connections and streams, including a FakeChatService and FakeEncryptionService for simulating chat behavior. --- docs/changelog/index.md | 2 + docs/changelog/toc.yml | 2 + docs/changelog/v0.2.4.md | 2 +- docs/changelog/v0.2.5.md | 14 + src/Directory.Build.props | 2 +- src/EchoHub.Server.Irc/IrcClientConnection.cs | 10 +- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 36 +- src/EchoHub.Server.Irc/IrcGatewayService.cs | 7 +- src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs | 316 +++++++++ .../Irc/IrcClientConnectionTests.cs | 213 ++++++ .../Irc/IrcCommandHandlerTests.cs | 620 ++++++++++++++++++ .../Irc/IrcMessageFormatterTests.cs | 294 +++++++++ src/EchoHub.Tests/Irc/IrcMessageTests.cs | 183 ++++++ src/EchoHub.Tests/Irc/TestHelpers.cs | 229 +++++++ 14 files changed, 1907 insertions(+), 23 deletions(-) create mode 100644 docs/changelog/v0.2.5.md create mode 100644 src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs create mode 100644 src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs create mode 100644 src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs create mode 100644 src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs create mode 100644 src/EchoHub.Tests/Irc/IrcMessageTests.cs create mode 100644 src/EchoHub.Tests/Irc/TestHelpers.cs diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 647908c..b0b84e2 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,8 @@ Release history for EchoHub. ## Releases +- [v0.2.5](v0.2.5.md) - IRC Gateway Fixes & Test Coverage +- [v0.2.4](v0.2.4.md) - E2E Message Encryption - [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 60d383d..1079748 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.5 + href: v0.2.5.md - name: v0.2.4 href: v0.2.4.md - name: v0.2.3 diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md index 6663103..2bae94e 100644 --- a/docs/changelog/v0.2.4.md +++ b/docs/changelog/v0.2.4.md @@ -28,5 +28,5 @@ - `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest - DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000 - EF Core migration: `AddEncryptionSupport` -- Encryption test suite: server-side, client-side, and cross-compatibility tests (87 total) +- Encryption test suite: server-side, client-side, and cross-compatibility tests - Documentation article: `docs/articles/encryption.md` diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md new file mode 100644 index 0000000..63161ab --- /dev/null +++ b/docs/changelog/v0.2.5.md @@ -0,0 +1,14 @@ +# v0.2.5 - IRC Gateway Fixes & Test Coverage + +## Fixes + +### IRC Gateway + +- **Fixed: IRC JOIN history replay showed encrypted gibberish** — `IrcCommandHandler.HandleJoinAsync` now decrypts channel history before formatting for IRC clients (history was encrypted for SignalR transport but sent raw to IRC) +- **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes +- **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns + +## Infrastructure + +- IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster (214 total tests) +- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeEncryptionService` for IRC unit testing without network I/O diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e2dfc80..381226e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.4 + 0.2.5 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs index 8566268..bbce314 100644 --- a/src/EchoHub.Server.Irc/IrcClientConnection.cs +++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs @@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable public bool IsSasl { get; set; } public bool CapNegotiating { get; set; } - // Channel state - public HashSet JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase); + // Channel state — thread-safe: written by command handler, read by broadcaster threads + private readonly HashSet _joinedChannels = new(StringComparer.OrdinalIgnoreCase); + private readonly object _channelLock = new(); // Away state public string? AwayMessage { get; set; } public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub"; + public void JoinChannel(string channel) { lock (_channelLock) _joinedChannels.Add(channel); } + public void LeaveChannel(string channel) { lock (_channelLock) _joinedChannels.Remove(channel); } + public bool IsInChannel(string channel) { lock (_channelLock) return _joinedChannels.Contains(channel); } + public List GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; } + public IrcClientConnection(TcpClient tcpClient, Stream stream) { _tcpClient = tcpClient; diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 1bf1a6b..d1b413e 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -12,6 +12,7 @@ public sealed class IrcCommandHandler private readonly IrcClientConnection _conn; private readonly IrcOptions _options; private readonly IChatService _chatService; + private readonly IMessageEncryptionService _encryption; private readonly ILogger _logger; private string ServerName => _options.ServerName; @@ -20,11 +21,13 @@ public sealed class IrcCommandHandler IrcClientConnection conn, IrcOptions options, IChatService chatService, + IMessageEncryptionService encryption, ILogger logger) { _conn = conn; _options = options; _chatService = chatService; + _encryption = encryption; _logger = logger; } @@ -319,7 +322,7 @@ public sealed class IrcCommandHandler private async Task HandleJoinAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) { @@ -350,7 +353,7 @@ public sealed class IrcCommandHandler continue; } - _conn.JoinedChannels.Add(channelName); + _conn.JoinChannel(channelName); // Confirm JOIN to the client await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}"); @@ -361,10 +364,11 @@ public sealed class IrcCommandHandler // Send NAMES list await SendNamesReplyAsync(channelName); - // Replay history + // Replay history (decrypt — history is encrypted for SignalR transport) foreach (var m in history) { - var lines = IrcMessageFormatter.FormatMessage(m); + var decrypted = m with { Content = _encryption.Decrypt(m.Content) }; + var lines = IrcMessageFormatter.FormatMessage(decrypted); foreach (var line in lines) await _conn.SendAsync(line); } @@ -373,7 +377,7 @@ public sealed class IrcCommandHandler private async Task HandlePartAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); @@ -385,7 +389,7 @@ public sealed class IrcCommandHandler if (channelName is null) continue; await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName); - _conn.JoinedChannels.Remove(channelName); + _conn.LeaveChannel(channelName); await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" + (partMessage is not null ? $" :{partMessage}" : "")); @@ -394,7 +398,7 @@ public sealed class IrcCommandHandler private async Task HandlePrivmsgAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 2) { @@ -436,7 +440,7 @@ public sealed class IrcCommandHandler private async Task HandleNamesAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channelName = IrcToEchoHubChannel(msg.Parameters[0]); @@ -458,7 +462,7 @@ public sealed class IrcCommandHandler private async Task HandleTopicAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channelName = IrcToEchoHubChannel(msg.Parameters[0]); @@ -495,7 +499,7 @@ public sealed class IrcCommandHandler private async Task HandleWhoAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channelName = IrcToEchoHubChannel(msg.Parameters[0]); @@ -516,7 +520,7 @@ public sealed class IrcCommandHandler private async Task HandleWhoisAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var nick = msg.Parameters[^1].ToLowerInvariant(); @@ -559,7 +563,7 @@ public sealed class IrcCommandHandler private async Task HandleAwayAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0])) { @@ -581,7 +585,7 @@ public sealed class IrcCommandHandler private async Task HandleListAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; var channels = await _chatService.GetChannelListAsync(); @@ -597,7 +601,7 @@ public sealed class IrcCommandHandler private async Task HandleModeAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var target = msg.Parameters[0]; @@ -621,11 +625,11 @@ public sealed class IrcCommandHandler // ── Helpers ────────────────────────────────────────────────────────────── - private bool RequireRegistered() + private async Task RequireRegisteredAsync() { if (_conn.IsRegistered) return true; - _ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED, + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED, ":You have not registered"); return false; } diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index e09ec61..b0fe30a 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService public IEnumerable GetConnectionsInChannel(string channelName) { return _connections.Values - .Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName)); + .Where(c => c.IsAuthenticated && c.IsInChannel(channelName)); } public IEnumerable GetAllConnections() @@ -120,8 +120,9 @@ public sealed class IrcGatewayService : BackgroundService try { chatService = _services.GetRequiredService(); + var encryption = _services.GetRequiredService(); var handler = new IrcCommandHandler( - connection, _options, chatService, _logger); + connection, _options, chatService, encryption, _logger); await handler.RunAsync(ct); } @@ -133,7 +134,7 @@ public sealed class IrcGatewayService : BackgroundService { if (connection.IsAuthenticated) { - foreach (var ch in connection.JoinedChannels.ToList()) + foreach (var ch in connection.GetJoinedChannels()) { if (chatService is null) break; await chatService.LeaveChannelAsync( diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs new file mode 100644 index 0000000..eca1170 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -0,0 +1,316 @@ +using System.Collections.Concurrent; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcBroadcasterTests +{ + private readonly IrcOptions _options = new() { ServerName = "testserver", Enabled = true }; + private readonly FakeEncryptionService _encryption = new(); + private readonly IrcGatewayService _gateway; + private readonly IrcBroadcaster _broadcaster; + + public IrcBroadcasterTests() + { + var services = new ServiceCollection() + .AddSingleton>(Options.Create(_options)) + .BuildServiceProvider(); + + _gateway = new IrcGatewayService( + Options.Create(_options), services, NullLogger.Instance); + + _broadcaster = new IrcBroadcaster(_gateway, _encryption); + } + + /// + /// Injects a test connection into the gateway's internal connection map. + /// + private IrcClientConnection AddConnection(string nickname, params string[] channels) + { + var (conn, _) = TestIrcConnectionFactory.CreateAuthenticated(nickname); + + foreach (var ch in channels) + conn.JoinChannel(ch); + + // Insert into gateway's ConcurrentDictionary via the public IReadOnlyDictionary + var connections = (ConcurrentDictionary)_gateway.Connections; + connections[conn.ConnectionId] = conn; + + return conn; + } + + private static List CaptureOutput(IrcClientConnection conn) + { + // We need to get the stream from the connection — but it's private. + // Since we used TestIrcConnectionFactory, the TestDuplexStream was passed to the constructor. + // We can't easily access it. Instead, we create connections differently for these tests. + // Let's use a different approach. + throw new NotSupportedException("Use AddConnectionWithCapture instead"); + } + + /// + /// Creates a connection that can capture output and injects it into the gateway. + /// + private (IrcClientConnection Connection, TestDuplexStream Stream) AddConnectionWithCapture( + string nickname, params string[] channels) + { + var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname); + + foreach (var ch in channels) + conn.JoinChannel(ch); + + var connections = (ConcurrentDictionary)_gateway.Connections; + connections[conn.ConnectionId] = conn; + + return (conn, stream); + } + + // ── SendMessageToChannelAsync ──────────────────────────────────────── + + [Fact] + public async Task SendMessage_DecryptsContent() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var encryptedContent = _encryption.Encrypt("Hello world!"); + var message = new MessageDto( + Guid.NewGuid(), encryptedContent, "alice", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow); + + await _broadcaster.SendMessageToChannelAsync("general", message); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("Hello world!")); + Assert.DoesNotContain(output, l => l.Contains("$ENC$")); + } + + [Fact] + public async Task SendMessage_SkipsSender() + { + var (_, aliceStream) = AddConnectionWithCapture("alice", "general"); + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + + var message = new MessageDto( + Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow); + + await _broadcaster.SendMessageToChannelAsync("general", message); + + // Alice (sender) should NOT receive the message + Assert.Empty(aliceStream.GetOutputLines()); + + // Bob should receive it + Assert.NotEmpty(bobStream.GetOutputLines()); + } + + [Fact] + public async Task SendMessage_OnlySendsToChannelMembers() + { + var (_, generalStream) = AddConnectionWithCapture("bob", "general"); + var (_, randomStream) = AddConnectionWithCapture("charlie", "random"); + + var message = new MessageDto( + Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow); + + await _broadcaster.SendMessageToChannelAsync("general", message); + + Assert.NotEmpty(generalStream.GetOutputLines()); + Assert.Empty(randomStream.GetOutputLines()); + } + + // ── SendUserJoinedAsync ────────────────────────────────────────────── + + [Fact] + public async Task SendUserJoined_NotifiesOtherMembers() + { + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendUserJoinedAsync("general", "alice"); + + var output = bobStream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice")); + } + + [Fact] + public async Task SendUserJoined_ExcludesConnectionId() + { + var (conn, excludedStream) = AddConnectionWithCapture("alice", "general"); + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId); + + // Excluded connection should not get the message + Assert.Empty(excludedStream.GetOutputLines()); + Assert.NotEmpty(bobStream.GetOutputLines()); + } + + // ── SendUserLeftAsync ──────────────────────────────────────────────── + + [Fact] + public async Task SendUserLeft_NotifiesOtherMembers() + { + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendUserLeftAsync("general", "alice"); + + var output = bobStream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("PART #general") && l.Contains("alice")); + } + + [Fact] + public async Task SendUserLeft_SkipsSender() + { + var (_, aliceStream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendUserLeftAsync("general", "alice"); + + Assert.Empty(aliceStream.GetOutputLines()); + } + + // ── SendChannelUpdatedAsync ────────────────────────────────────────── + + [Fact] + public async Task SendChannelUpdated_WithTopic_SendsTopicMessage() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var channel = new ChannelDto( + Guid.NewGuid(), "general", "New topic!", true, 0, DateTimeOffset.UtcNow); + + await _broadcaster.SendChannelUpdatedAsync(channel, "general"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("TOPIC #general") && l.Contains("New topic!")); + } + + [Fact] + public async Task SendChannelUpdated_NullTopic_DoesNotSend() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var channel = new ChannelDto( + Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow); + + await _broadcaster.SendChannelUpdatedAsync(channel, "general"); + + Assert.Empty(stream.GetOutputLines()); + } + + // ── SendErrorAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task SendError_IrcConnection_SendsNotice() + { + var (conn, stream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendErrorAsync(conn.ConnectionId, "Something went wrong"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("Something went wrong")); + } + + [Fact] + public async Task SendError_NonIrcConnection_DoesNothing() + { + // SignalR connection IDs don't start with "irc-" + await _broadcaster.SendErrorAsync("signalr-connection-123", "Error"); + // No crash, no output — the method silently returns + } + + // ── SendUserKickedAsync ────────────────────────────────────────────── + + [Fact] + public async Task SendUserKicked_NotifiesChannel() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendUserKickedAsync("general", "alice", "Spam"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("KICK #general alice") && l.Contains("Spam")); + } + + // ── SendUserBannedAsync ────────────────────────────────────────────── + + [Fact] + public async Task SendUserBanned_NotifiesBannedUser() + { + var (_, stream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendUserBannedAsync("alice", "Repeated violations"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("banned")); + } + + // ── SendMessageDeletedAsync ────────────────────────────────────────── + + [Fact] + public async Task SendMessageDeleted_NotifiesChannel() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + var msgId = Guid.NewGuid(); + + await _broadcaster.SendMessageDeletedAsync("general", msgId); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("deleted") && l.Contains(msgId.ToString())); + } + + // ── SendChannelNukedAsync ──────────────────────────────────────────── + + [Fact] + public async Task SendChannelNuked_NotifiesChannel() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendChannelNukedAsync("general"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("cleared")); + } + + // ── ForceDisconnectUserAsync ───────────────────────────────────────── + + [Fact] + public async Task ForceDisconnect_IrcConnection_SendsErrorAndCloses() + { + var (conn, stream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.ForceDisconnectUserAsync([conn.ConnectionId], "Banned"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("ERROR") && l.Contains("Banned")); + } + + [Fact] + public async Task ForceDisconnect_NonIrcConnection_Ignores() + { + // Should not throw when given SignalR connection IDs + await _broadcaster.ForceDisconnectUserAsync(["signalr-abc", "signalr-def"], "Banned"); + } + + // ── SendUserStatusChangedAsync ─────────────────────────────────────── + + [Fact] + public async Task SendUserStatusChanged_IsNoOp() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var presence = new UserPresenceDto( + "alice", null, null, UserStatus.Away, "brb", ServerRole.Member); + + await _broadcaster.SendUserStatusChangedAsync(["general"], presence); + + // IRC doesn't push status changes — clients use WHOIS/WHO + Assert.Empty(stream.GetOutputLines()); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs b/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs new file mode 100644 index 0000000..e753ea9 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs @@ -0,0 +1,213 @@ +using EchoHub.Server.Irc; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcClientConnectionTests +{ + // ── Connection identity ────────────────────────────────────────────── + + [Fact] + public void ConnectionId_StartsWithIrcPrefix() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + Assert.StartsWith("irc-", conn.ConnectionId); + } + + [Fact] + public void ConnectionId_IsUnique() + { + var (conn1, _) = TestIrcConnectionFactory.Create(); + var (conn2, _) = TestIrcConnectionFactory.Create(); + Assert.NotEqual(conn1.ConnectionId, conn2.ConnectionId); + } + + // ── Hostmask ───────────────────────────────────────────────────────── + + [Fact] + public void Hostmask_WithNicknameAndUsername_FormatsCorrectly() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.Nickname = "alice"; + conn.Username = "alice_user"; + + Assert.Equal("alice!alice_user@echohub", conn.Hostmask); + } + + [Fact] + public void Hostmask_WithoutUsername_FallsBackToNickname() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.Nickname = "alice"; + + Assert.Equal("alice!alice@echohub", conn.Hostmask); + } + + // ── I/O ────────────────────────────────────────────────────────────── + + [Fact] + public async Task ReadLineAsync_ReturnsInputLines() + { + var (conn, _) = TestIrcConnectionFactory.Create("PING", "PONG"); + + var line1 = await conn.ReadLineAsync(CancellationToken.None); + var line2 = await conn.ReadLineAsync(CancellationToken.None); + + Assert.Equal("PING", line1); + Assert.Equal("PONG", line2); + } + + [Fact] + public async Task ReadLineAsync_EndOfStream_ReturnsNull() + { + var (conn, _) = TestIrcConnectionFactory.Create("PING"); + + await conn.ReadLineAsync(CancellationToken.None); // consume "PING" + var result = await conn.ReadLineAsync(CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task SendAsync_WritesToOutput() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + + await conn.SendAsync(":server 001 alice :Welcome!"); + + var output = stream.GetOutputLines(); + Assert.Single(output); + Assert.Equal(":server 001 alice :Welcome!", output[0]); + } + + [Fact] + public async Task SendAsync_MultipleLines_AllCaptured() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + + await conn.SendAsync("line1"); + await conn.SendAsync("line2"); + await conn.SendAsync("line3"); + + var output = stream.GetOutputLines(); + Assert.Equal(3, output.Count); + } + + [Fact] + public async Task SendNumericAsync_FormatsCorrectly() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + conn.Nickname = "alice"; + + await conn.SendNumericAsync("testserver", "001", ":Welcome!"); + + var output = stream.GetOutputLines(); + Assert.Single(output); + Assert.Equal(":testserver 001 alice :Welcome!", output[0]); + } + + [Fact] + public async Task SendNumericAsync_NoNickname_UsesStar() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + + await conn.SendNumericAsync("testserver", "451", ":Not registered"); + + var output = stream.GetOutputLines(); + Assert.Contains("*", output[0]); + } + + // ── Thread-safe channel operations ─────────────────────────────────── + + [Fact] + public void JoinChannel_AddsChannel() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("general"); + + Assert.True(conn.IsInChannel("general")); + } + + [Fact] + public void LeaveChannel_RemovesChannel() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("general"); + conn.LeaveChannel("general"); + + Assert.False(conn.IsInChannel("general")); + } + + [Fact] + public void IsInChannel_CaseInsensitive() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("General"); + + Assert.True(conn.IsInChannel("general")); + Assert.True(conn.IsInChannel("GENERAL")); + } + + [Fact] + public void GetJoinedChannels_ReturnsSnapshot() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("general"); + conn.JoinChannel("random"); + + var channels = conn.GetJoinedChannels(); + Assert.Equal(2, channels.Count); + Assert.Contains("general", channels); + Assert.Contains("random", channels); + + // Modifying the returned list shouldn't affect the connection state + channels.Clear(); + Assert.True(conn.IsInChannel("general")); + } + + [Fact] + public async Task JoinedChannels_ConcurrentAccess_DoesNotThrow() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + + // Simulate concurrent reads and writes (broadcaster reads while handler writes) + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var writerTask = Task.Run(async () => + { + for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++) + { + conn.JoinChannel($"channel-{i}"); + await Task.Yield(); + if (i % 3 == 0) conn.LeaveChannel($"channel-{i}"); + } + }, cts.Token); + + var readerTask = Task.Run(async () => + { + for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++) + { + _ = conn.IsInChannel($"channel-{i}"); + _ = conn.GetJoinedChannels(); + await Task.Yield(); + } + }, cts.Token); + + // Should complete without exceptions + await Task.WhenAll(writerTask, readerTask); + } + + // ── Default state ──────────────────────────────────────────────────── + + [Fact] + public void NewConnection_IsNotRegistered() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + + Assert.False(conn.IsRegistered); + Assert.False(conn.IsAuthenticated); + Assert.Null(conn.Nickname); + Assert.Null(conn.Username); + Assert.Null(conn.UserId); + Assert.Null(conn.AwayMessage); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs new file mode 100644 index 0000000..f569055 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -0,0 +1,620 @@ +using System.Text; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcCommandHandlerTests +{ + private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null }; + private readonly FakeChatService _chatService = new(); + private readonly FakeEncryptionService _encryption = new(); + + private IrcCommandHandler CreateHandler(IrcClientConnection conn) => + new(conn, _options, _chatService, _encryption, NullLogger.Instance); + + private async Task> RunAndCapture(string[] inputLines, + Action? setup = null) + { + var (conn, stream) = TestIrcConnectionFactory.Create(inputLines); + setup?.Invoke(conn); + + var handler = CreateHandler(conn); + await handler.RunAsync(CancellationToken.None); + + return stream.GetOutputLines(); + } + + private async Task> RunAuthenticated(string[] inputLines, + string nickname = "alice", Guid? userId = null, + Action? setup = null) + { + var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname, userId, inputLines); + setup?.Invoke(conn); + + var handler = CreateHandler(conn); + await handler.RunAsync(CancellationToken.None); + + return stream.GetOutputLines(); + } + + // ── PING / PONG ────────────────────────────────────────────────────── + + [Fact] + public async Task Ping_RespondsWithPong() + { + var lines = await RunAuthenticated(["PING :mytoken"]); + + Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("mytoken")); + } + + [Fact] + public async Task Ping_NoToken_UsesServerName() + { + var lines = await RunAuthenticated(["PING"]); + + Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("testserver")); + } + + // ── Unregistered commands ──────────────────────────────────────────── + + [Fact] + public async Task UnregisteredUser_ChannelCommand_GetsNotRegisteredError() + { + var lines = await RunAndCapture(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("451") && l.Contains("not registered")); + } + + [Fact] + public async Task UnregisteredUser_PrivmsgCommand_GetsNotRegisteredError() + { + var lines = await RunAndCapture(["PRIVMSG #general :hello"]); + + Assert.Contains(lines, l => l.Contains("451")); + } + + // ── PASS / NICK / USER registration ────────────────────────────────── + + [Fact] + public async Task PassNickUser_ValidCredentials_Registers() + { + var userId = Guid.NewGuid(); + _chatService.AuthResult = (userId, "alice"); + + var lines = await RunAndCapture([ + "PASS secret123", + "NICK alice", + "USER alice 0 * :Alice Smith" + ]); + + // Should get welcome burst (001) + Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome")); + Assert.Contains("alice", _chatService.ConnectedUsers); + } + + [Fact] + public async Task NickUser_NoPassword_GetsPasswordError() + { + var lines = await RunAndCapture([ + "NICK alice", + "USER alice 0 * :Alice Smith" + ]); + + Assert.Contains(lines, l => l.Contains("464") && l.Contains("Password required")); + } + + [Fact] + public async Task PassNickUser_WrongPassword_GetsAuthError() + { + _chatService.AuthResult = null; + + var lines = await RunAndCapture([ + "PASS wrongpassword", + "NICK alice", + "USER alice 0 * :Alice Smith" + ]); + + Assert.Contains(lines, l => l.Contains("464") && l.Contains("incorrect")); + } + + [Fact] + public async Task Nick_InvalidNickname_GetsError() + { + var lines = await RunAndCapture(["NICK a"]); // too short + + Assert.Contains(lines, l => l.Contains("432") && l.Contains("Erroneous nickname")); + } + + [Fact] + public async Task Nick_NoParam_GetsNoNicknameError() + { + var lines = await RunAndCapture(["NICK"]); + + Assert.Contains(lines, l => l.Contains("431") && l.Contains("No nickname given")); + } + + [Fact] + public async Task User_AlreadyRegistered_GetsError() + { + var lines = await RunAuthenticated([ + "USER alice 0 * :Alice" + ]); + + Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister")); + } + + [Fact] + public async Task Pass_AlreadyRegistered_GetsError() + { + var lines = await RunAuthenticated([ + "PASS newpassword" + ]); + + Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister")); + } + + // ── CAP / SASL ────────────────────────────────────────────────────── + + [Fact] + public async Task CapLs_AdvertisesSasl() + { + var lines = await RunAndCapture(["CAP LS"]); + + Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl")); + } + + [Fact] + public async Task CapReqSasl_Acknowledged() + { + var lines = await RunAndCapture(["CAP REQ :sasl"]); + + Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl")); + } + + [Fact] + public async Task CapReqUnknown_GetsNak() + { + var lines = await RunAndCapture(["CAP REQ :multi-prefix"]); + + Assert.Contains(lines, l => l.Contains("NAK")); + } + + [Fact] + public async Task SaslPlain_ValidCredentials_Authenticates() + { + var userId = Guid.NewGuid(); + _chatService.AuthResult = (userId, "alice"); + + var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123")); + + var lines = await RunAndCapture([ + "CAP LS", + "CAP REQ :sasl", + $"AUTHENTICATE PLAIN", + $"AUTHENTICATE {saslPayload}", + "NICK alice", + "USER alice 0 * :Alice", + "CAP END" + ]); + + Assert.Contains(lines, l => l.Contains("903") && l.Contains("SASL authentication successful")); + Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome")); + } + + [Fact] + public async Task SaslPlain_InvalidCredentials_GetsError() + { + _chatService.AuthResult = null; + + var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd")); + + var lines = await RunAndCapture([ + "CAP LS", + "CAP REQ :sasl", + "AUTHENTICATE PLAIN", + $"AUTHENTICATE {saslPayload}", + ]); + + Assert.Contains(lines, l => l.Contains("904") && l.Contains("SASL authentication failed")); + } + + [Fact] + public async Task SaslPlain_MalformedPayload_GetsError() + { + var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("malformed")); + + var lines = await RunAndCapture([ + "CAP REQ :sasl", + "AUTHENTICATE PLAIN", + $"AUTHENTICATE {saslPayload}", + ]); + + Assert.Contains(lines, l => l.Contains("904")); + } + + // ── JOIN ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Join_ValidChannel_ConfirmsJoin() + { + _chatService.TopicResult = ("Welcome!", true); + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("JOIN #general")); + Assert.Single(_chatService.JoinedChannels); + Assert.Equal("general", _chatService.JoinedChannels[0].Channel); + } + + [Fact] + public async Task Join_SendsTopic() + { + _chatService.TopicResult = ("Welcome to general!", true); + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("332") && l.Contains("Welcome to general!")); + } + + [Fact] + public async Task Join_NoTopic_SendsNoTopicReply() + { + _chatService.TopicResult = (null, true); + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("331") && l.Contains("No topic is set")); + } + + [Fact] + public async Task Join_SendsNamesReply() + { + _chatService.OnlineUsersToReturn = + [ + new("alice", null, null, UserStatus.Online, null, ServerRole.Member), + new("bob", null, null, UserStatus.Online, null, ServerRole.Member), + ]; + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob")); + Assert.Contains(lines, l => l.Contains("366") && l.Contains("End of /NAMES")); + } + + [Fact] + public async Task Join_DecryptsHistoryForIrc() + { + // Simulate encrypted history (as ChatService returns it) + var encryptedContent = _encryption.Encrypt("Hello from history!"); + _chatService.HistoryToReturn = + [ + new(Guid.NewGuid(), encryptedContent, "bob", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow) + ]; + + var lines = await RunAuthenticated(["JOIN #general"]); + + // Should contain the DECRYPTED text, not the encrypted version + Assert.Contains(lines, l => l.Contains("Hello from history!")); + Assert.DoesNotContain(lines, l => l.Contains("$ENC$Hello from history!")); + } + + [Fact] + public async Task Join_NonexistentChannel_GetsError() + { + _chatService.JoinError = "Channel 'nope' does not exist."; + + var lines = await RunAuthenticated(["JOIN #nope"]); + + Assert.Contains(lines, l => l.Contains("403") && l.Contains("does not exist")); + } + + [Fact] + public async Task Join_InvalidChannelName_GetsError() + { + var lines = await RunAuthenticated(["JOIN invalid"]); + + Assert.Contains(lines, l => l.Contains("403") && l.Contains("Invalid channel name")); + } + + [Fact] + public async Task Join_MultipleChannels_JoinsAll() + { + var lines = await RunAuthenticated(["JOIN #general,#random"]); + + Assert.Equal(2, _chatService.JoinedChannels.Count); + Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "general"); + Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "random"); + } + + [Fact] + public async Task Join_NoParams_GetsNeedMoreParamsError() + { + var lines = await RunAuthenticated(["JOIN"]); + + Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters")); + } + + // ── PART ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Part_ValidChannel_ConfirmsPart() + { + var lines = await RunAuthenticated(["PART #general"]); + + Assert.Contains(lines, l => l.Contains("PART #general")); + Assert.Single(_chatService.LeftChannels); + Assert.Equal("general", _chatService.LeftChannels[0].Channel); + } + + [Fact] + public async Task Part_WithReason_IncludesReason() + { + var lines = await RunAuthenticated(["PART #general :Leaving for now"]); + + Assert.Contains(lines, l => l.Contains("PART #general") && l.Contains("Leaving for now")); + } + + // ── PRIVMSG ────────────────────────────────────────────────────────── + + [Fact] + public async Task Privmsg_ChannelMessage_SendsViaService() + { + var lines = await RunAuthenticated(["PRIVMSG #general :Hello everyone!"]); + + Assert.Single(_chatService.SentMessages); + Assert.Equal("general", _chatService.SentMessages[0].Channel); + Assert.Equal("Hello everyone!", _chatService.SentMessages[0].Content); + } + + [Fact] + public async Task Privmsg_PrivateMessage_GetsError() + { + var lines = await RunAuthenticated(["PRIVMSG bob :Hey bob"]); + + Assert.Contains(lines, l => l.Contains("401") && l.Contains("Private messages are not supported")); + Assert.Empty(_chatService.SentMessages); + } + + [Fact] + public async Task Privmsg_ServiceError_ReturnsError() + { + _chatService.SendMessageError = "You are muted."; + + var lines = await RunAuthenticated(["PRIVMSG #general :Hello"]); + + Assert.Contains(lines, l => l.Contains("404") && l.Contains("muted")); + } + + [Fact] + public async Task Privmsg_NoParams_GetsNeedMoreParamsError() + { + var lines = await RunAuthenticated(["PRIVMSG"]); + + Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters")); + } + + // ── QUIT ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Quit_WithMessage_SendsClosingLink() + { + var lines = await RunAuthenticated(["QUIT :Goodbye!"]); + + Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Goodbye!")); + } + + [Fact] + public async Task Quit_NoMessage_UsesDefault() + { + var lines = await RunAuthenticated(["QUIT"]); + + Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Client quit")); + } + + // ── NAMES ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Names_ReturnsUserList() + { + _chatService.OnlineUsersToReturn = + [ + new("alice", null, null, UserStatus.Online, null, ServerRole.Member), + new("bob", "Bob", null, UserStatus.Away, null, ServerRole.Mod), + ]; + + var lines = await RunAuthenticated(["NAMES #general"]); + + Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob")); + Assert.Contains(lines, l => l.Contains("366")); + } + + // ── TOPIC ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Topic_Query_ReturnsTopic() + { + _chatService.TopicResult = ("Chat about everything", true); + + var lines = await RunAuthenticated(["TOPIC #general"]); + + Assert.Contains(lines, l => l.Contains("332") && l.Contains("Chat about everything")); + } + + [Fact] + public async Task Topic_SetAttempt_GetsPermissionDenied() + { + var lines = await RunAuthenticated(["TOPIC #general :New topic"]); + + Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator")); + } + + // ── WHO ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Who_ReturnsUserListWithAwayFlags() + { + _chatService.OnlineUsersToReturn = + [ + new("alice", "Alice", null, UserStatus.Online, null, ServerRole.Member), + new("bob", "Bob", null, UserStatus.Away, "brb", ServerRole.Member), + ]; + + var lines = await RunAuthenticated(["WHO #general"]); + + Assert.Contains(lines, l => l.Contains("352") && l.Contains("alice") && l.Contains("H")); // Here + Assert.Contains(lines, l => l.Contains("352") && l.Contains("bob") && l.Contains("G")); // Gone + Assert.Contains(lines, l => l.Contains("315") && l.Contains("End of WHO")); + } + + // ── WHOIS ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Whois_ExistingUser_ReturnsInfo() + { + _chatService.ProfileToReturn = new UserProfileDto( + Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null, + UserStatus.Online, null, ServerRole.Member, + DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow); + _chatService.ChannelsForUserToReturn = ["general", "random"]; + + var lines = await RunAuthenticated(["WHOIS bob"]); + + Assert.Contains(lines, l => l.Contains("311") && l.Contains("bob") && l.Contains("Bob S.")); + Assert.Contains(lines, l => l.Contains("312") && l.Contains("testserver")); + Assert.Contains(lines, l => l.Contains("319") && l.Contains("#general") && l.Contains("#random")); + Assert.Contains(lines, l => l.Contains("317")); // idle + Assert.Contains(lines, l => l.Contains("318") && l.Contains("End of WHOIS")); + } + + [Fact] + public async Task Whois_NonexistentUser_GetsNoSuchNickError() + { + _chatService.ProfileToReturn = null; + + var lines = await RunAuthenticated(["WHOIS ghost"]); + + Assert.Contains(lines, l => l.Contains("401") && l.Contains("No such nick")); + } + + [Fact] + public async Task Whois_AwayUser_ShowsAwayMessage() + { + _chatService.ProfileToReturn = new UserProfileDto( + Guid.NewGuid(), "bob", null, null, null, null, + UserStatus.Away, "Gone fishing", ServerRole.Member, + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow); + _chatService.ChannelsForUserToReturn = []; + + var lines = await RunAuthenticated(["WHOIS bob"]); + + Assert.Contains(lines, l => l.Contains("301") && l.Contains("Gone fishing")); + } + + // ── AWAY ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Away_WithMessage_SetsAway() + { + var lines = await RunAuthenticated(["AWAY :Be right back"]); + + Assert.Contains(lines, l => l.Contains("306") && l.Contains("marked as being away")); + Assert.Single(_chatService.StatusUpdates); + Assert.Equal(UserStatus.Away, _chatService.StatusUpdates[0].Status); + } + + [Fact] + public async Task Away_NoMessage_ClearsAway() + { + var lines = await RunAuthenticated(["AWAY"]); + + Assert.Contains(lines, l => l.Contains("305") && l.Contains("no longer marked")); + Assert.Single(_chatService.StatusUpdates); + Assert.Equal(UserStatus.Online, _chatService.StatusUpdates[0].Status); + } + + // ── LIST ───────────────────────────────────────────────────────────── + + [Fact] + public async Task List_ReturnsChannels() + { + _chatService.ChannelListToReturn = + [ + new("general", "General chat", 5), + new("random", null, 2), + ]; + + var lines = await RunAuthenticated(["LIST"]); + + Assert.Contains(lines, l => l.Contains("322") && l.Contains("#general") && l.Contains("General chat")); + Assert.Contains(lines, l => l.Contains("322") && l.Contains("#random")); + Assert.Contains(lines, l => l.Contains("323") && l.Contains("End of LIST")); + } + + // ── MODE ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Mode_Channel_ReturnsChannelModes() + { + var lines = await RunAuthenticated(["MODE #general"]); + + Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general")); + } + + [Fact] + public async Task Mode_User_ReturnsUserModes() + { + var lines = await RunAuthenticated(["MODE alice"]); + + Assert.Contains(lines, l => l.Contains("221")); + } + + // ── MOTD ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Motd_NoMotdConfigured_GetsNoMotdError() + { + var lines = await RunAuthenticated(["MOTD"]); + + Assert.Contains(lines, l => l.Contains("422") && l.Contains("MOTD File is missing")); + } + + [Fact] + public async Task Motd_WithMotd_DisplaysMotd() + { + _options.Motd = "Welcome to EchoHub!\nEnjoy your stay."; + + var lines = await RunAuthenticated(["MOTD"]); + + Assert.Contains(lines, l => l.Contains("375")); // MOTDSTART + Assert.Contains(lines, l => l.Contains("372") && l.Contains("Welcome to EchoHub!")); + Assert.Contains(lines, l => l.Contains("372") && l.Contains("Enjoy your stay.")); + Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD + } + + // ── Unknown command ────────────────────────────────────────────────── + + [Fact] + public async Task UnknownCommand_GetsError() + { + var lines = await RunAuthenticated(["FOOBAR"]); + + Assert.Contains(lines, l => l.Contains("421") && l.Contains("FOOBAR") && l.Contains("Unknown command")); + } + + // ── Channel name conversion ────────────────────────────────────────── + + [Fact] + public async Task Join_ChannelNameNormalized_ToLowerCase() + { + var lines = await RunAuthenticated(["JOIN #General"]); + + Assert.Single(_chatService.JoinedChannels); + Assert.Equal("general", _chatService.JoinedChannels[0].Channel); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs new file mode 100644 index 0000000..07cc543 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs @@ -0,0 +1,294 @@ +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcMessageFormatterTests +{ + private static MessageDto CreateTextMessage(string content, string sender = "alice", + string channel = "general", List? embeds = null) + { + return new MessageDto( + Guid.NewGuid(), content, sender, null, channel, + MessageType.Text, null, null, DateTimeOffset.UtcNow, embeds); + } + + private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png", + string url = "https://example.com/image.png", string sender = "alice", string channel = "general") + { + return new MessageDto( + Guid.NewGuid(), asciiArt, sender, null, channel, + MessageType.Image, url, fileName, DateTimeOffset.UtcNow); + } + + private static MessageDto CreateFileMessage(string fileName = "doc.pdf", + string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general") + { + return new MessageDto( + Guid.NewGuid(), "", sender, null, channel, + MessageType.File, url, fileName, DateTimeOffset.UtcNow); + } + + private static MessageDto CreateAudioMessage(string fileName = "song.mp3", + string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general") + { + return new MessageDto( + Guid.NewGuid(), "", sender, null, channel, + MessageType.Audio, url, fileName, DateTimeOffset.UtcNow); + } + + // ── FormatMessage ──────────────────────────────────────────────────── + + [Fact] + public void FormatMessage_TextMessage_FormatsAsPrivmsg() + { + var msg = CreateTextMessage("Hello world"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Equal(":alice!alice@echohub PRIVMSG #general :Hello world", lines[0]); + } + + [Fact] + public void FormatMessage_TextMessage_IncludesChannelHash() + { + var msg = CreateTextMessage("test", channel: "random"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains("#random", lines[0]); + } + + [Fact] + public void FormatMessage_TextWithEmbeds_AppendsEmbedLines() + { + var embeds = new List + { + new("Example Site", "Page Title", "A description of the page", null, "https://example.com") + }; + var msg = CreateTextMessage("Check this: https://example.com", embeds: embeds); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.True(lines.Count >= 2); + Assert.Contains("Check this: https://example.com", lines[0]); + // Embed header + Assert.Contains("Example Site", lines[1]); + Assert.Contains("Page Title", lines[1]); + } + + [Fact] + public void FormatMessage_EmbedWithDescription_IncludesDescription() + { + var embeds = new List + { + new("Site", "Title", "This is a description", null, "https://example.com") + }; + var msg = CreateTextMessage("url", embeds: embeds); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.True(lines.Count >= 3); + Assert.Contains("This is a description", lines[2]); + } + + [Fact] + public void FormatMessage_EmbedWithLongDescription_Truncates() + { + var longDesc = new string('x', 300); + var embeds = new List + { + new("Site", "Title", longDesc, null, "https://example.com") + }; + var msg = CreateTextMessage("url", embeds: embeds); + var lines = IrcMessageFormatter.FormatMessage(msg); + + var descLine = lines.First(l => l.Contains("xxx")); + Assert.Contains("...", descLine); + // Should be truncated to ~200 chars + var descContent = descLine[(descLine.LastIndexOf(':') + 2)..]; // after ":│ " + Assert.True(descContent.Length <= 210); + } + + [Fact] + public void FormatMessage_ImageMessage_IncludesFileNameAndUrl() + { + var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]")); + Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg")); + } + + [Fact] + public void FormatMessage_ImageMessage_IncludesAsciiArt() + { + var msg = CreateImageMessage("line1\nline2"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains(lines, l => l.Contains("line1")); + Assert.Contains(lines, l => l.Contains("line2")); + } + + [Fact] + public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines() + { + var msg = CreateImageMessage("line1\n\nline2"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + // Empty lines should be skipped + var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList(); + Assert.Equal(2, asciiLines.Count); + } + + [Fact] + public void FormatMessage_FileMessage_FormatsCorrectly() + { + var msg = CreateFileMessage("report.pdf", "https://example.com/report.pdf"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Contains("[File: report.pdf]", lines[0]); + Assert.Contains("https://example.com/report.pdf", lines[0]); + } + + [Fact] + public void FormatMessage_AudioMessage_FormatsWithMusicNote() + { + var msg = CreateAudioMessage("track.mp3", "https://example.com/track.mp3"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Contains("\u266a", lines[0]); // ♪ + Assert.Contains("[Audio: track.mp3]", lines[0]); + Assert.Contains("https://example.com/track.mp3", lines[0]); + } + + // ── SplitMessage ───────────────────────────────────────────────────── + + [Fact] + public void SplitMessage_ShortMessage_ReturnsSingleChunk() + { + var chunks = IrcMessageFormatter.SplitMessage("Hello", 400); + Assert.Single(chunks); + Assert.Equal("Hello", chunks[0]); + } + + [Fact] + public void SplitMessage_ExactlyAtLimit_ReturnsSingleChunk() + { + var msg = new string('a', 400); + var chunks = IrcMessageFormatter.SplitMessage(msg, 400); + Assert.Single(chunks); + } + + [Fact] + public void SplitMessage_LongMessage_SplitsAtWordBoundary() + { + // Create a message that's longer than 50 bytes + var words = string.Join(" ", Enumerable.Repeat("hello", 20)); // 20 * 6 - 1 = 119 bytes + var chunks = IrcMessageFormatter.SplitMessage(words, 50); + + Assert.True(chunks.Count > 1); + // Each chunk should be roughly <= 50 bytes + foreach (var chunk in chunks) + { + Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 55, + $"Chunk too long: {chunk.Length} chars"); + } + // Reassembled content should match original + var reassembled = string.Join(" ", chunks); + Assert.Equal(words, reassembled); + } + + [Fact] + public void SplitMessage_SingleLongWord_ForcedIntoOneChunk() + { + var longWord = new string('a', 500); + var chunks = IrcMessageFormatter.SplitMessage(longWord, 400); + // A single word can't be split at word boundaries, so it stays as one chunk + Assert.Single(chunks); + Assert.Equal(longWord, chunks[0]); + } + + [Fact] + public void SplitMessage_EmptyString_ReturnsSingleEmpty() + { + var chunks = IrcMessageFormatter.SplitMessage("", 400); + Assert.Single(chunks); + Assert.Equal("", chunks[0]); + } + + [Fact] + public void SplitMessage_UnicodeContent_CountsUtf8Bytes() + { + // Japanese text: each char is 3 bytes in UTF-8 + var text = string.Join(" ", Enumerable.Repeat("\u3042\u3044\u3046", 50)); + var chunks = IrcMessageFormatter.SplitMessage(text, 100); + + Assert.True(chunks.Count > 1); + foreach (var chunk in chunks) + { + Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 110, + $"Chunk too long in bytes: {System.Text.Encoding.UTF8.GetByteCount(chunk)}"); + } + } + + // ── ColorTagsToAnsi ────────────────────────────────────────────────── + + [Fact] + public void ColorTagsToAnsi_NoTags_ReturnsUnchanged() + { + Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world")); + } + + [Fact] + public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text"); + Assert.Equal("\x1b[38;2;255;0;0mRed text", result); + } + + [Fact] + public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg"); + Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result); + } + + [Fact] + public void ColorTagsToAnsi_ResetTag_ConvertsToReset() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal"); + Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", 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); + } + + [Fact] + public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text"); + Assert.Equal("\x1b[38;2;255;136;0mtext", result); + } + + [Fact] + public void ColorTagsToAnsi_NoBraces_SkipsProcessing() + { + var text = "plain text without braces"; + Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); + } + + [Fact] + public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged() + { + var text = "\x1b[31mAlready colored\x1b[0m"; + Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcMessageTests.cs b/src/EchoHub.Tests/Irc/IrcMessageTests.cs new file mode 100644 index 0000000..566af96 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcMessageTests.cs @@ -0,0 +1,183 @@ +using EchoHub.Server.Irc; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcMessageTests +{ + [Fact] + public void Parse_SimpleCommand_ExtractsCommand() + { + var msg = IrcMessage.Parse("PING"); + Assert.Equal("PING", msg.Command); + Assert.Null(msg.Prefix); + Assert.Empty(msg.Parameters); + } + + [Fact] + public void Parse_CommandWithOneParam_ExtractsParam() + { + var msg = IrcMessage.Parse("NICK alice"); + Assert.Equal("NICK", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("alice", msg.Parameters[0]); + } + + [Fact] + public void Parse_CommandWithTrailing_ExtractsTrailingAsLastParam() + { + var msg = IrcMessage.Parse("PRIVMSG #general :Hello world!"); + Assert.Equal("PRIVMSG", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("Hello world!", msg.Parameters[1]); + Assert.Equal("Hello world!", msg.Trailing); + } + + [Fact] + public void Parse_MessageWithPrefix_ExtractsPrefix() + { + var msg = IrcMessage.Parse(":alice!alice@echohub PRIVMSG #general :hi"); + Assert.Equal("alice!alice@echohub", msg.Prefix); + Assert.Equal("PRIVMSG", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("hi", msg.Parameters[1]); + } + + [Fact] + public void Parse_MultipleParams_ExtractsAll() + { + var msg = IrcMessage.Parse("USER alice 0 * :Alice Smith"); + Assert.Equal("USER", msg.Command); + Assert.Equal(4, msg.Parameters.Count); + Assert.Equal("alice", msg.Parameters[0]); + Assert.Equal("0", msg.Parameters[1]); + Assert.Equal("*", msg.Parameters[2]); + Assert.Equal("Alice Smith", msg.Parameters[3]); + } + + [Fact] + public void Parse_PingWithToken_ExtractsToken() + { + var msg = IrcMessage.Parse("PING :server.example.com"); + Assert.Equal("PING", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("server.example.com", msg.Parameters[0]); + } + + [Fact] + public void Parse_CapLs_ParsesSubcommand() + { + var msg = IrcMessage.Parse("CAP LS 302"); + Assert.Equal("CAP", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("LS", msg.Parameters[0]); + Assert.Equal("302", msg.Parameters[1]); + } + + [Fact] + public void Parse_CapReqWithTrailing_ParsesSasl() + { + var msg = IrcMessage.Parse("CAP REQ :sasl"); + Assert.Equal("CAP", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("REQ", msg.Parameters[0]); + Assert.Equal("sasl", msg.Parameters[1]); + } + + [Fact] + public void Parse_JoinMultipleChannels_ExtractsCsv() + { + var msg = IrcMessage.Parse("JOIN #general,#random"); + Assert.Equal("JOIN", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("#general,#random", msg.Parameters[0]); + } + + [Fact] + public void Parse_PartWithReason_ExtractsReason() + { + var msg = IrcMessage.Parse("PART #general :Leaving for now"); + Assert.Equal("PART", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("Leaving for now", msg.Parameters[1]); + } + + [Fact] + public void Parse_EmptyTrailing_ExtractsEmptyString() + { + var msg = IrcMessage.Parse("PRIVMSG #general :"); + Assert.Equal("PRIVMSG", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("", msg.Parameters[1]); + } + + [Fact] + public void Parse_TrailingWithColons_PreservesColons() + { + var msg = IrcMessage.Parse("PRIVMSG #general :time is 12:30:00"); + Assert.Equal("time is 12:30:00", msg.Parameters[1]); + } + + [Fact] + public void Parse_CrLfTrimmed() + { + var msg = IrcMessage.Parse("PING\r\n"); + Assert.Equal("PING", msg.Command); + Assert.Empty(msg.Parameters); + } + + [Fact] + public void Parse_ExtraSpaces_Handled() + { + var msg = IrcMessage.Parse("NICK alice"); + Assert.Equal("NICK", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("alice", msg.Parameters[0]); + } + + [Fact] + public void Parse_Authenticate_Base64Payload() + { + var payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("\0alice\0secret")); + var msg = IrcMessage.Parse($"AUTHENTICATE {payload}"); + Assert.Equal("AUTHENTICATE", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal(payload, msg.Parameters[0]); + } + + [Fact] + public void Parse_PassCommand_ExtractsPassword() + { + var msg = IrcMessage.Parse("PASS mysecretpassword"); + Assert.Equal("PASS", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("mysecretpassword", msg.Parameters[0]); + } + + [Fact] + public void Parse_QuitWithMessage_ExtractsMessage() + { + var msg = IrcMessage.Parse("QUIT :Goodbye!"); + Assert.Equal("QUIT", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("Goodbye!", msg.Parameters[0]); + } + + [Fact] + public void Trailing_NoParams_ReturnsNull() + { + var msg = IrcMessage.Parse("PING"); + Assert.Null(msg.Trailing); + } + + [Fact] + public void Trailing_WithParams_ReturnsLastParam() + { + var msg = IrcMessage.Parse("MODE #channel +o alice"); + Assert.Equal("alice", msg.Trailing); + } +} diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs new file mode 100644 index 0000000..41b7712 --- /dev/null +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -0,0 +1,229 @@ +using System.Net.Sockets; +using System.Text; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; + +namespace EchoHub.Tests.Irc; + +/// +/// A duplex stream that reads from one buffer and writes to another, +/// allowing test code to inject input and capture output. +/// +internal sealed class TestDuplexStream : Stream +{ + private readonly MemoryStream _readBuffer; + private readonly MemoryStream _writeBuffer = new(); + + public TestDuplexStream(string input = "") + { + _readBuffer = new MemoryStream(Encoding.UTF8.GetBytes(input)); + } + + public string GetOutput() + { + var raw = Encoding.UTF8.GetString(_writeBuffer.ToArray()); + // Strip UTF-8 BOM emitted by StreamWriter + return raw.TrimStart('\uFEFF'); + } + + public List GetOutputLines() => + GetOutput().Split("\r\n", StringSplitOptions.RemoveEmptyEntries).ToList(); + + // Read from the input buffer + public override int Read(byte[] buffer, int offset, int count) => + _readBuffer.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + _readBuffer.ReadAsync(buffer, offset, count, ct); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => + _readBuffer.ReadAsync(buffer, ct); + + // Write to the output buffer + public override void Write(byte[] buffer, int offset, int count) => + _writeBuffer.Write(buffer, offset, count); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + _writeBuffer.WriteAsync(buffer, offset, count, ct); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) => + _writeBuffer.WriteAsync(buffer, ct); + + public override void Flush() => _writeBuffer.Flush(); + public override Task FlushAsync(CancellationToken ct) => _writeBuffer.FlushAsync(ct); + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _readBuffer.Dispose(); + _writeBuffer.Dispose(); + } + base.Dispose(disposing); + } +} + +/// +/// Creates IrcClientConnections backed by test streams for unit testing. +/// +internal static class TestIrcConnectionFactory +{ + /// + /// Creates a test IRC connection with the given input lines. + /// Returns the connection and the test stream (for inspecting output). + /// + public static (IrcClientConnection Connection, TestDuplexStream Stream) Create(params string[] inputLines) + { + var input = string.Join("\r\n", inputLines); + if (inputLines.Length > 0) input += "\r\n"; + + var stream = new TestDuplexStream(input); + var tcpClient = new TcpClient(); + var conn = new IrcClientConnection(tcpClient, stream); + return (conn, stream); + } + + /// + /// Creates a pre-authenticated, registered IRC connection. + /// + public static (IrcClientConnection Connection, TestDuplexStream Stream) CreateAuthenticated( + string nickname = "alice", Guid? userId = null, params string[] inputLines) + { + var (conn, stream) = Create(inputLines); + conn.Nickname = nickname; + conn.Username = nickname; + conn.UserId = userId ?? Guid.NewGuid(); + conn.IsRegistered = true; + conn.IsAuthenticated = true; + return (conn, stream); + } +} + +/// +/// Fake encryption service that uses a simple reversible prefix-based scheme. +/// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello". +/// +internal sealed class FakeEncryptionService : IMessageEncryptionService +{ + private const string Prefix = "$ENC$"; + + public bool EncryptDatabaseEnabled => true; + + public string Encrypt(string plaintext) => $"{Prefix}{plaintext}"; + + public string Decrypt(string content) + { + if (content.StartsWith(Prefix)) + return content[Prefix.Length..]; + return content; + } + + public string? EncryptNullable(string? value) => + value is not null ? Encrypt(value) : null; + + public string? DecryptNullable(string? value) => + value is not null ? Decrypt(value) : null; +} + +/// +/// Fake chat service that records method calls and returns pre-configured results. +/// +internal sealed class FakeChatService : IChatService +{ + // Recorded calls + public List ConnectedUsers { get; } = []; + public List DisconnectedConnections { get; } = []; + public List<(string Channel, string Username)> JoinedChannels { get; } = []; + public List<(string Channel, string Username)> LeftChannels { get; } = []; + public List<(string Channel, string Content)> SentMessages { get; } = []; + public List<(string Username, UserStatus Status)> StatusUpdates { get; } = []; + + // Configurable results + public List HistoryToReturn { get; set; } = []; + public string? JoinError { get; set; } + public string? SendMessageError { get; set; } + public (Guid UserId, string Username)? AuthResult { get; set; } + public UserProfileDto? ProfileToReturn { get; set; } + public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true); + public List ChannelListToReturn { get; set; } = []; + public List ChannelsForUserToReturn { get; set; } = []; + public List OnlineUsersToReturn { get; set; } = []; + + public Task UserConnectedAsync(string connectionId, Guid userId, string username) + { + ConnectedUsers.Add(username); + return Task.CompletedTask; + } + + public Task UserDisconnectedAsync(string connectionId) + { + DisconnectedConnections.Add(connectionId); + return Task.FromResult(null); + } + + public Task<(List History, string? Error)> JoinChannelAsync( + string connectionId, Guid userId, string username, string channelName) + { + JoinedChannels.Add((channelName, username)); + return Task.FromResult((HistoryToReturn, JoinError)); + } + + public Task LeaveChannelAsync(string connectionId, string username, string channelName) + { + LeftChannels.Add((channelName, username)); + return Task.CompletedTask; + } + + public Task SendMessageAsync(Guid userId, string username, string channelName, string content) + { + SentMessages.Add((channelName, content)); + return Task.FromResult(SendMessageError); + } + + public Task> GetChannelHistoryAsync(string channelName, int count) => + Task.FromResult(HistoryToReturn); + + public Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) + { + StatusUpdates.Add((username, status)); + return Task.FromResult(null); + } + + public Task> GetOnlineUsersAsync(string channelName) => + Task.FromResult(OnlineUsersToReturn); + + public Task BroadcastMessageAsync(string channelName, MessageDto message) => + Task.CompletedTask; + + public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) => + Task.CompletedTask; + + public Task GetUserProfileAsync(string username) => + Task.FromResult(ProfileToReturn); + + public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) => + Task.FromResult(TopicResult); + + public Task> GetChannelListAsync() => + Task.FromResult(ChannelListToReturn); + + public Task> GetChannelsForUserAsync(string username) => + Task.FromResult(ChannelsForUserToReturn); + + public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) => + Task.FromResult(AuthResult); +}