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.
This commit is contained in:
HueByte
2026-02-21 11:25:34 +01:00
parent 3bc1446d1c
commit c0d38015ff
14 changed files with 1907 additions and 23 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.4</Version>
<Version>0.2.5</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
@@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable
public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; }
// Channel state
public HashSet<string> JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase);
// Channel state — thread-safe: written by command handler, read by broadcaster threads
private readonly HashSet<string> _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<string> GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; }
public IrcClientConnection(TcpClient tcpClient, Stream stream)
{
_tcpClient = tcpClient;
+20 -16
View File
@@ -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<bool> 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;
}
+4 -3
View File
@@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
{
return _connections.Values
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
.Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
}
public IEnumerable<IrcClientConnection> GetAllConnections()
@@ -120,8 +120,9 @@ public sealed class IrcGatewayService : BackgroundService
try
{
chatService = _services.GetRequiredService<IChatService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
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(
@@ -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<IOptions<IrcOptions>>(Options.Create(_options))
.BuildServiceProvider();
_gateway = new IrcGatewayService(
Options.Create(_options), services, NullLogger<IrcGatewayService>.Instance);
_broadcaster = new IrcBroadcaster(_gateway, _encryption);
}
/// <summary>
/// Injects a test connection into the gateway's internal connection map.
/// </summary>
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<string, IrcClientConnection>)_gateway.Connections;
connections[conn.ConnectionId] = conn;
return conn;
}
private static List<string> 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");
}
/// <summary>
/// Creates a connection that can capture output and injects it into the gateway.
/// </summary>
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<string, IrcClientConnection>)_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());
}
}
@@ -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);
}
}
@@ -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<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? 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<List<string>> RunAuthenticated(string[] inputLines,
string nickname = "alice", Guid? userId = null,
Action<IrcClientConnection>? 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);
}
}
@@ -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<EmbedDto>? 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<EmbedDto>
{
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<EmbedDto>
{
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<EmbedDto>
{
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));
}
}
+183
View File
@@ -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);
}
}
+229
View File
@@ -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;
/// <summary>
/// A duplex stream that reads from one buffer and writes to another,
/// allowing test code to inject input and capture output.
/// </summary>
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<string> 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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
_readBuffer.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> 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<byte> 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);
}
}
/// <summary>
/// Creates IrcClientConnections backed by test streams for unit testing.
/// </summary>
internal static class TestIrcConnectionFactory
{
/// <summary>
/// Creates a test IRC connection with the given input lines.
/// Returns the connection and the test stream (for inspecting output).
/// </summary>
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);
}
/// <summary>
/// Creates a pre-authenticated, registered IRC connection.
/// </summary>
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);
}
}
/// <summary>
/// Fake encryption service that uses a simple reversible prefix-based scheme.
/// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello".
/// </summary>
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;
}
/// <summary>
/// Fake chat service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChatService : IChatService
{
// Recorded calls
public List<string> ConnectedUsers { get; } = [];
public List<string> 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<MessageDto> 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<ChannelListItem> ChannelListToReturn { get; set; } = [];
public List<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
public Task UserConnectedAsync(string connectionId, Guid userId, string username)
{
ConnectedUsers.Add(username);
return Task.CompletedTask;
}
public Task<string?> UserDisconnectedAsync(string connectionId)
{
DisconnectedConnections.Add(connectionId);
return Task.FromResult<string?>(null);
}
public Task<(List<MessageDto> 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<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
{
SentMessages.Add((channelName, content));
return Task.FromResult(SendMessageError);
}
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
{
StatusUpdates.Add((username, status));
return Task.FromResult<string?>(null);
}
public Task<List<UserPresenceDto>> 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<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) =>
Task.FromResult(TopicResult);
public Task<List<ChannelListItem>> GetChannelListAsync() =>
Task.FromResult(ChannelListToReturn);
public Task<List<string>> GetChannelsForUserAsync(string username) =>
Task.FromResult(ChannelsForUserToReturn);
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult);
}