mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user