mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-06 15:46:03 +02:00
Add support for IRCv3 server-time, message-tags, echo-message, batch, draft/multiline extensions
This commit is contained in:
@@ -335,6 +335,151 @@ public class IrcBroadcasterTests
|
||||
|
||||
// ── SendUserStatusChangedAsync ───────────────────────────────────────
|
||||
|
||||
// ── IRCv3 Tags (server-time, msgid, reply) ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithServerTimeCap_IncludesTimeTag()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("server-time");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
||||
new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.StartsWith("@time=2024-01-15T10:30:00.000Z"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithoutServerTimeCap_NoTimeTag()
|
||||
{
|
||||
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.All(output, l => Assert.DoesNotContain("@time=", l));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithMessageTagsCap_IncludesMsgid()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("message-tags");
|
||||
|
||||
var msgId = Guid.NewGuid();
|
||||
var message = new MessageDto(
|
||||
msgId, _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.Contains($"msgid={msgId:D}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithMessageTagsAndReply_IncludesReplyTag()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("message-tags");
|
||||
|
||||
var replyToId = Guid.NewGuid();
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hello!"), "alice", null, "general", DateTimeOffset.UtcNow,
|
||||
ReplyTo: new ReplyRefDto(replyToId, "bob", _encryption.Encrypt("Original")));
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.Contains($"+reply={replyToId:D}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithAllTags_FormatsCorrectly()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("server-time");
|
||||
conn.EnableCap("message-tags");
|
||||
|
||||
var msgId = Guid.NewGuid();
|
||||
var sentAt = new DateTimeOffset(2024, 6, 15, 14, 30, 0, TimeSpan.Zero);
|
||||
var message = new MessageDto(
|
||||
msgId, _encryption.Encrypt("Hey"), "alice", null, "general", sentAt);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.StartsWith("@time=2024-06-15T14:30:00.000Z;msgid="));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendUserJoined_WithServerTime_IncludesTimeTag()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("server-time");
|
||||
|
||||
await _broadcaster.SendUserJoinedAsync("general", "alice", null);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.StartsWith("@time=") && l.Contains("JOIN"));
|
||||
}
|
||||
|
||||
// ── Multiline Batch ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithMultilineCap_WrapsInBatch()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("draft/multiline");
|
||||
conn.EnableCap("batch");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
// BATCH start should use the user's prefix, not the server name
|
||||
Assert.Contains(output, l => l.Contains(":alice!alice@echohub BATCH +") && l.Contains("draft/multiline"));
|
||||
Assert.Contains(output, l => l.Contains("BATCH -"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithoutMultilineCap_SendsLinesDirectly()
|
||||
{
|
||||
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_SingleLineWithMultilineCap_NoBatch()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("draft/multiline");
|
||||
conn.EnableCap("batch");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Just one line"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendUserStatusChanged_IsNoOp()
|
||||
{
|
||||
|
||||
@@ -164,11 +164,59 @@ public class IrcCommandHandlerTests
|
||||
// ── CAP / SASL ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CapLs_AdvertisesSasl()
|
||||
public async Task CapLs_AdvertisesCapabilities()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP LS"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("server-time"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("message-tags"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("batch"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapLs302_AdvertisesCapabilitiesWithValues()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP LS 302"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline=max-bytes=40000"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapLs_SuspendsRegistration()
|
||||
{
|
||||
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
|
||||
|
||||
var lines = await RunAndCapture([
|
||||
"CAP LS",
|
||||
"PASS secret123",
|
||||
"NICK alice",
|
||||
"USER alice 0 * :Alice"
|
||||
]);
|
||||
|
||||
// Should NOT get welcome (001) since CAP END wasn't sent
|
||||
Assert.DoesNotContain(lines, l => l.Contains("001") && l.Contains("Welcome"));
|
||||
|
||||
// Registration should still be pending
|
||||
Assert.True(lines.All(l => !l.Contains("001")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapEnd_ResumesRegistration()
|
||||
{
|
||||
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
|
||||
|
||||
var lines = await RunAndCapture([
|
||||
"CAP LS",
|
||||
"PASS secret123",
|
||||
"NICK alice",
|
||||
"USER alice 0 * :Alice",
|
||||
"CAP END"
|
||||
]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -179,12 +227,61 @@ public class IrcCommandHandlerTests
|
||||
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapReqMultiple_Acknowledged()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP REQ :server-time message-tags"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("server-time") && l.Contains("message-tags"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapReqUnknown_GetsNak()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("NAK"));
|
||||
Assert.Contains(lines, l => l.Contains("NAK") && l.Contains("multi-prefix"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapReqDisableCap_DisablesAndAcks()
|
||||
{
|
||||
var (conn, stream) = TestIrcConnectionFactory.Create(
|
||||
"CAP REQ :message-tags\r\nCAP REQ :-message-tags\r\nCAP END\r\nNICK alice\r\nUSER alice 0 * :Alice\r\n".Split("\r\n", StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
var handler = new IrcCommandHandler(conn, new IrcOptions { ServerName = "testserver" },
|
||||
_chatService, _userService, _channelService, _encryption, NullLogger.Instance);
|
||||
await handler.RunAsync(CancellationToken.None);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
|
||||
// First REQ should ACK
|
||||
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("message-tags"));
|
||||
|
||||
// Second REQ with - should ACK with - prefix
|
||||
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("-message-tags"));
|
||||
|
||||
// Cap should be disabled
|
||||
Assert.False(conn.HasCap("message-tags"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapList_AfterEnable_ShowsEnabledCaps()
|
||||
{
|
||||
var lines = await RunAndCapture([
|
||||
"CAP REQ :server-time",
|
||||
"CAP LIST"
|
||||
]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("LIST") && l.Contains("server-time"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapUnknownSubcommand_Gets410()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP FOO"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("410") && l.Contains("FOO"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -693,6 +790,49 @@ public class IrcCommandHandlerTests
|
||||
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
|
||||
}
|
||||
|
||||
// ── BATCH (Multiline) ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_Multiline_CollectsAndFlushes()
|
||||
{
|
||||
var lines = await RunAuthenticated([
|
||||
"BATCH +abc123 draft/multiline #general",
|
||||
"@batch=abc123 PRIVMSG #general :Hello",
|
||||
"@batch=abc123 PRIVMSG #general :world",
|
||||
"BATCH -abc123"
|
||||
]);
|
||||
|
||||
// Should send a single message with lines joined by newlines
|
||||
Assert.Single(_chatService.SentMessages);
|
||||
Assert.Equal("general", _chatService.SentMessages[0].Channel);
|
||||
Assert.Equal("Hello\nworld", _chatService.SentMessages[0].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_MultilineWithConcat_JoinsWithoutNewline()
|
||||
{
|
||||
var lines = await RunAuthenticated([
|
||||
"BATCH +abc123 draft/multiline #general",
|
||||
"@batch=abc123 PRIVMSG #general :hello ",
|
||||
"@batch=abc123;draft/multiline-concat PRIVMSG #general :world",
|
||||
"BATCH -abc123"
|
||||
]);
|
||||
|
||||
Assert.Single(_chatService.SentMessages);
|
||||
Assert.Equal("hello world", _chatService.SentMessages[0].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_MultilineEmptyBatch_DoesNothing()
|
||||
{
|
||||
var lines = await RunAuthenticated([
|
||||
"BATCH +abc123 draft/multiline #general",
|
||||
"BATCH -abc123"
|
||||
]);
|
||||
|
||||
Assert.Empty(_chatService.SentMessages);
|
||||
}
|
||||
|
||||
// ── Unknown command ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -180,4 +180,128 @@ public class IrcMessageTests
|
||||
var msg = IrcMessage.Parse("MODE #channel +o alice");
|
||||
Assert.Equal("alice", msg.Trailing);
|
||||
}
|
||||
|
||||
// ── IRCv3 Message Tags ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithTags_ExtractsTags()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z;msgid=abc PRIVMSG #channel :Hello");
|
||||
|
||||
Assert.Equal(2, msg.Tags.Count);
|
||||
Assert.Equal("2024-01-01T12:00:00.000Z", msg.Tags["time"]);
|
||||
Assert.Equal("abc", msg.Tags["msgid"]);
|
||||
Assert.Equal("PRIVMSG", msg.Command);
|
||||
Assert.Equal("Hello", msg.Parameters[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithClientOnlyTags_ExtractsPlusPrefix()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@+reply=abc123;+example.com/tag=val PRIVMSG #channel :Hello");
|
||||
|
||||
Assert.Equal(2, msg.Tags.Count);
|
||||
Assert.Equal("abc123", msg.Tags["+reply"]);
|
||||
Assert.Equal("val", msg.Tags["+example.com/tag"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithTagsAndPrefix_ExtractsBoth()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z :alice!user@host PRIVMSG #channel :Hello");
|
||||
|
||||
Assert.Single(msg.Tags);
|
||||
Assert.Equal("alice!user@host", msg.Prefix);
|
||||
Assert.Equal("PRIVMSG", msg.Command);
|
||||
Assert.Equal("Hello", msg.Parameters[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TagWithEscapedValue_Unescapes()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@key=hello\\sworld\\:! PRIVMSG #channel :Hi");
|
||||
|
||||
Assert.Single(msg.Tags);
|
||||
Assert.Equal("hello world;!", msg.Tags["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TagWithNoValue_StoresNull()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@empty;key=val PRIVMSG #channel :Hi");
|
||||
|
||||
Assert.Equal(2, msg.Tags.Count);
|
||||
Assert.Null(msg.Tags["empty"]);
|
||||
Assert.Equal("val", msg.Tags["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BatchTag_ParsesMultilineContext()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@batch=abc123 PRIVMSG #channel :line content");
|
||||
|
||||
Assert.Single(msg.Tags);
|
||||
Assert.Equal("abc123", msg.Tags["batch"]);
|
||||
Assert.Equal("PRIVMSG", msg.Command);
|
||||
Assert.Equal("line content", msg.Parameters[1]);
|
||||
}
|
||||
|
||||
// ── Tag Escaping ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TagEscape_HandlesSpecialChars()
|
||||
{
|
||||
Assert.Equal("hello\\sworld", IrcMessage.TagEscape("hello world"));
|
||||
Assert.Equal("a\\:b", IrcMessage.TagEscape("a;b"));
|
||||
Assert.Equal("a\\\\b", IrcMessage.TagEscape("a\\b"));
|
||||
Assert.Equal("a\\rb\\n", IrcMessage.TagEscape("a\rb\n"));
|
||||
|
||||
// Regular chars pass through unchanged
|
||||
Assert.Equal("plain-text_123", IrcMessage.TagEscape("plain-text_123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagUnescape_HandlesEscapeSequences()
|
||||
{
|
||||
Assert.Equal("hello world", IrcMessage.TagUnescape("hello\\sworld"));
|
||||
Assert.Equal("a;b", IrcMessage.TagUnescape("a\\:b"));
|
||||
Assert.Equal("a\\b", IrcMessage.TagUnescape("a\\\\b"));
|
||||
Assert.Equal("a\rb\n", IrcMessage.TagUnescape("a\\rb\\n"));
|
||||
}
|
||||
|
||||
// ── BuildTagPrefix ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_NoTags_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal("", IrcMessage.BuildTagPrefix());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_SingleTag_FormatsCorrectly()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"));
|
||||
Assert.Equal("@time=2024-01-01T12:00:00.000Z ", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_MultipleTags_SemicolonSeparated()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"), ("msgid", "abc123"));
|
||||
Assert.Equal("@time=2024-01-01T12:00:00.000Z;msgid=abc123 ", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_TagWithNullValue_OmitsEquals()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("tag-only", null));
|
||||
Assert.Equal("@tag-only ", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_ClientOnlyTag_IncludesPlusPrefix()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("+reply", "msg-123"));
|
||||
Assert.Equal("@+reply=msg-123 ", result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user