From d81ea2b61a72ff62d5f0dec6e5add361fab21973 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 31 Mar 2026 20:07:34 -0700 Subject: [PATCH] add comprehensive IRC integration test with two clients Adds integration_test.go with four test functions that exercise all major IRC features using real TCP connections: TestIntegrationTwoClients: sequential two-client test covering NICK/USER registration, JOIN, PRIVMSG (channel + DM), NOTICE (channel + DM), TOPIC (set/get/lock/unlock), MODE (query, +m, +v, -t/+t), NAMES, LIST, WHO, WHOIS (with channels), LUSERS, NICK change (with relay), duplicate NICK, KICK (with relay + reason), KICK non-op error, PING/PONG, unknown command, MOTD, AWAY (set/clear/RPL_AWAY on DM), PASS post-registration, PART (with reason + relay), PART non-existent channel, user MODE query, multi-channel messaging, and QUIT (with relay). TestIntegrationModeSecret: verifies +s mode can be set and is reflected in MODE queries. TestIntegrationModeModerated: verifies +m blocks non-voiced users and +v enables sending in moderated channels. TestIntegrationThirdClientObserver: verifies three-client channel message fanout. closes #97 --- internal/ircserver/integration_test.go | 913 +++++++++++++++++++++++++ 1 file changed, 913 insertions(+) create mode 100644 internal/ircserver/integration_test.go diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go new file mode 100644 index 0000000..d6da16b --- /dev/null +++ b/internal/ircserver/integration_test.go @@ -0,0 +1,913 @@ +package ircserver_test + +import ( + "strings" + "testing" + "time" +) + +// TestIntegrationTwoClients is a comprehensive integration +// test that spawns the IRC server programmatically, connects +// two real TCP clients, and verifies all major IRC features +// including cross-client message delivery. +// +// The test runs sequentially through IRC features because +// both clients share the same channel state. Each section +// builds on the previous one (e.g. alice and bob must be +// JOINed before PRIVMSG can be tested). +// +//nolint:cyclop,funlen,maintidx // integration test +func TestIntegrationTwoClients(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + bob := env.dial(t) + + // ── Registration ────────────────────────────────── + + aliceWelcome := alice.register("alice") + assertContains( + t, aliceWelcome, " 001 ", "RPL_WELCOME alice", + ) + assertContains( + t, aliceWelcome, " 002 ", "RPL_YOURHOST alice", + ) + assertContains( + t, aliceWelcome, " 003 ", "RPL_CREATED alice", + ) + assertContains( + t, aliceWelcome, " 004 ", "RPL_MYINFO alice", + ) + assertContains( + t, aliceWelcome, "alice", + "nick in welcome burst", + ) + + bobWelcome := bob.register("bob") + assertContains( + t, bobWelcome, " 001 ", "RPL_WELCOME bob", + ) + assertContains( + t, bobWelcome, "bob", + "nick in welcome burst", + ) + + // ── JOIN and cross-client visibility ────────────── + + alice.send("JOIN #integration") + aliceJoinLines := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 366 ") + }) + assertContains( + t, aliceJoinLines, "JOIN", + "alice receives JOIN echo", + ) + assertContains( + t, aliceJoinLines, " 366 ", + "RPL_ENDOFNAMES for alice", + ) + + bob.send("JOIN #integration") + bobJoinLines := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 366 ") + }) + assertContains( + t, bobJoinLines, "JOIN", + "bob receives JOIN echo", + ) + + // Alice should see bob's JOIN via relay. + aliceSeesBob := alice.readUntil(func(l string) bool { + return strings.Contains(l, "JOIN") && + strings.Contains(l, "bob") + }) + assertContains( + t, aliceSeesBob, "bob", + "alice sees bob's JOIN", + ) + + // ── PRIVMSG (channel) — alice to bob ────────────── + + alice.send("PRIVMSG #integration :hello from alice") + + bobGetsMsg := bob.readUntil(func(l string) bool { + return strings.Contains(l, "hello from alice") + }) + assertContains( + t, bobGetsMsg, "hello from alice", + "bob receives alice's channel message", + ) + + // ── PRIVMSG (channel) — bob to alice ────────────── + + bob.send("PRIVMSG #integration :hello from bob") + + aliceGetsMsg := alice.readUntil(func(l string) bool { + return strings.Contains(l, "hello from bob") + }) + assertContains( + t, aliceGetsMsg, "hello from bob", + "alice receives bob's channel message", + ) + + // ── PRIVMSG (DM) — alice to bob ────────────────── + + alice.send("PRIVMSG bob :secret message") + + bobDM := bob.readUntil(func(l string) bool { + return strings.Contains(l, "secret message") + }) + assertContains( + t, bobDM, "secret message", + "bob receives alice's DM", + ) + assertContains( + t, bobDM, "alice", + "DM from field is alice", + ) + + // ── PRIVMSG (DM) — bob to alice ────────────────── + + bob.send("PRIVMSG alice :reply to you") + + aliceDM := alice.readUntil(func(l string) bool { + return strings.Contains(l, "reply to you") + }) + assertContains( + t, aliceDM, "reply to you", + "alice receives bob's DM", + ) + + // ── NOTICE (channel) ────────────────────────────── + + alice.send("NOTICE #integration :notice msg") + + bobNotice := bob.readUntil(func(l string) bool { + return strings.Contains(l, "notice msg") + }) + assertContains( + t, bobNotice, "NOTICE", + "bob receives NOTICE command", + ) + assertContains( + t, bobNotice, "notice msg", + "bob receives NOTICE text", + ) + + // ── NOTICE (DM) ────────────────────────────────── + + bob.send("NOTICE alice :dm notice") + + aliceNotice := alice.readUntil(func(l string) bool { + return strings.Contains(l, "dm notice") + }) + assertContains( + t, aliceNotice, "dm notice", + "alice receives DM NOTICE", + ) + + // ── TOPIC ───────────────────────────────────────── + // alice is the channel creator so she is +o. + + alice.send("TOPIC #integration :Integration Test Topic") + + aliceTopic := alice.readUntil(func(l string) bool { + return strings.Contains( + l, "Integration Test Topic", + ) + }) + assertContains( + t, aliceTopic, "Integration Test Topic", + "alice sees TOPIC echo", + ) + + bobTopic := bob.readUntil(func(l string) bool { + return strings.Contains( + l, "Integration Test Topic", + ) + }) + assertContains( + t, bobTopic, "Integration Test Topic", + "bob receives TOPIC change", + ) + + // ── MODE (query) ────────────────────────────────── + + alice.send("MODE #integration") + + aliceMode := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 324 ") + }) + assertContains( + t, aliceMode, " 324 ", + "RPL_CHANNELMODEIS", + ) + + // ── MODE (+m moderated, then -m) ────────────────── + + alice.send("MODE #integration +m") + + aliceModeM := alice.readUntil(func(l string) bool { + return strings.Contains(l, "MODE") && + strings.Contains(l, "+m") + }) + assertContains( + t, aliceModeM, "+m", + "alice sees MODE +m echo", + ) + + bobModeM := bob.readUntil(func(l string) bool { + return strings.Contains(l, "+m") + }) + assertContains( + t, bobModeM, "+m", + "bob sees MODE +m relay", + ) + + // Revert moderated mode. + alice.send("MODE #integration -m") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "-m") + }) + bob.readUntil(func(l string) bool { + return strings.Contains(l, "-m") + }) + + // ── MODE (+v voice, then -v) ────────────────────── + + alice.send("MODE #integration +v bob") + + aliceVoice := alice.readUntil(func(l string) bool { + return strings.Contains(l, "+v") + }) + assertContains( + t, aliceVoice, "+v", + "alice sees +v echo", + ) + + bobVoice := bob.readUntil(func(l string) bool { + return strings.Contains(l, "+v") + }) + assertContains( + t, bobVoice, "+v", + "bob receives +v relay", + ) + + // Remove voice. + alice.send("MODE #integration -v bob") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "-v") + }) + bob.readUntil(func(l string) bool { + return strings.Contains(l, "-v") + }) + + // ── NAMES ───────────────────────────────────────── + + alice.send("NAMES #integration") + + aliceNames := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 366 ") + }) + assertContains( + t, aliceNames, " 353 ", + "RPL_NAMREPLY", + ) + assertContains( + t, aliceNames, " 366 ", + "RPL_ENDOFNAMES", + ) + + // Both nicks should appear in the name list. + foundBothNames := false + for _, line := range aliceNames { + if strings.Contains(line, " 353 ") && + strings.Contains(line, "alice") && + strings.Contains(line, "bob") { + foundBothNames = true + + break + } + } + + if !foundBothNames { + t.Error("NAMES reply should list both alice and bob") + } + + // ── LIST ────────────────────────────────────────── + + alice.send("LIST") + + aliceList := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 323 ") + }) + assertContains( + t, aliceList, " 322 ", + "RPL_LIST entry", + ) + assertContains( + t, aliceList, "#integration", + "LIST includes #integration", + ) + assertContains( + t, aliceList, " 323 ", //nolint:misspell // IRC RPL_LISTEND + "RPL_LISTEND", //nolint:misspell // IRC term + ) + + // ── WHO ─────────────────────────────────────────── + + bob.send("WHO #integration") + + bobWho := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 315 ") + }) + assertContains( + t, bobWho, " 352 ", + "RPL_WHOREPLY", + ) + assertContains( + t, bobWho, " 315 ", + "RPL_ENDOFWHO", + ) + + // ── WHOIS ───────────────────────────────────────── + + alice.send("WHOIS bob") + + aliceWhois := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 318 ") + }) + assertContains( + t, aliceWhois, " 311 ", + "RPL_WHOISUSER", + ) + assertContains( + t, aliceWhois, " 312 ", + "RPL_WHOISSERVER", + ) + assertContains( + t, aliceWhois, " 318 ", + "RPL_ENDOFWHOIS", + ) + + // ── WHOIS with channels ─────────────────────────── + + bob.send("WHOIS alice") + + bobWhois := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 318 ") + }) + assertContains( + t, bobWhois, " 319 ", + "RPL_WHOISCHANNELS", + ) + assertContains( + t, bobWhois, "#integration", + "WHOIS shows #integration channel", + ) + + // ── LUSERS ──────────────────────────────────────── + + alice.send("LUSERS") + + aliceLusers := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 255 ") + }) + assertContains( + t, aliceLusers, " 251 ", + "RPL_LUSERCLIENT", + ) + assertContains( + t, aliceLusers, " 255 ", + "RPL_LUSERME", + ) + + // ── NICK change ─────────────────────────────────── + + bob.send("NICK bobby") + + bobNick := bob.readUntil(func(l string) bool { + return strings.Contains(l, "NICK") && + strings.Contains(l, "bobby") + }) + assertContains( + t, bobNick, "bobby", + "bob sees NICK change to bobby", + ) + + // alice should see the nick change relayed. + aliceNick := alice.readUntil(func(l string) bool { + return strings.Contains(l, "bobby") + }) + assertContains( + t, aliceNick, "NICK", + "alice sees NICK command", + ) + assertContains( + t, aliceNick, "bobby", + "alice sees new nick bobby", + ) + + // Change it back for remaining tests. + bob.send("NICK bob") + bob.readUntil(func(l string) bool { + return strings.Contains(l, "bob") + }) + alice.readUntil(func(l string) bool { + return strings.Contains(l, "NICK") && + strings.Contains(l, "bob") + }) + + // ── Duplicate NICK ──────────────────────────────── + + bob.send("NICK alice") + + bobDupNick := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 433 ") + }) + assertContains( + t, bobDupNick, " 433 ", + "ERR_NICKNAMEINUSE", + ) + + // ── KICK ────────────────────────────────────────── + // alice is op; she kicks bob. + + alice.send("KICK #integration bob :testing kick") + + aliceKick := alice.readUntil(func(l string) bool { + return strings.Contains(l, "KICK") + }) + assertContains( + t, aliceKick, "KICK", + "alice sees KICK echo", + ) + assertContains( + t, aliceKick, "bob", + "KICK mentions bob", + ) + + bobKick := bob.readUntil(func(l string) bool { + return strings.Contains(l, "KICK") + }) + assertContains( + t, bobKick, "KICK", + "bob receives KICK", + ) + assertContains( + t, bobKick, "testing kick", + "KICK reason is relayed", + ) + + // bob rejoins. + bob.joinAndDrain("#integration") + + // Drain alice's view of the rejoin. + alice.readUntil(func(l string) bool { + return strings.Contains(l, "JOIN") && + strings.Contains(l, "bob") + }) + + // ── KICK non-op should fail ─────────────────────── + + bob.send("KICK #integration alice :nope") + + bobKickFail := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 482 ") + }) + assertContains( + t, bobKickFail, " 482 ", + "ERR_CHANOPRIVSNEEDED", + ) + + // ── TOPIC lock (+t default) ─────────────────────── + // +t is default, so bob should not be able to set + // topic. + + bob.send("TOPIC #integration :bob tries topic") + + bobTopicFail := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 482 ") + }) + assertContains( + t, bobTopicFail, " 482 ", + "ERR_CHANOPRIVSNEEDED for topic", + ) + + // ── PING / PONG ─────────────────────────────────── + + alice.send("PING :testtoken") + + alicePong := alice.readUntil(func(l string) bool { + return strings.Contains(l, "PONG") + }) + assertContains( + t, alicePong, "PONG", + "PONG response received", + ) + + // ── Unknown command ─────────────────────────────── + + bob.send("FOOBAR") + + bobUnknown := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 421 ") + }) + assertContains( + t, bobUnknown, " 421 ", + "ERR_UNKNOWNCOMMAND", + ) + + // ── MOTD ────────────────────────────────────────── + + alice.send("MOTD") + + aliceMOTD := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 376 ") + }) + assertContains( + t, aliceMOTD, " 376 ", + "RPL_ENDOFMOTD", + ) + + // ── AWAY (set, check via DM, clear) ─────────────── + + alice.send("AWAY :gone fishing") + + aliceAway := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 306 ") + }) + assertContains( + t, aliceAway, " 306 ", + "RPL_NOWAWAY", + ) + + // bob DMs alice — should get RPL_AWAY. + bob.send("PRIVMSG alice :are you there?") + + bobAwayReply := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 301 ") + }) + assertContains( + t, bobAwayReply, " 301 ", + "RPL_AWAY for bob when messaging alice", + ) + assertContains( + t, bobAwayReply, "gone fishing", + "away message relayed", + ) + + // Clear away. + alice.send("AWAY") + alice.readUntil(func(l string) bool { + return strings.Contains(l, " 305 ") + }) + + // ── PASS (set password post-registration) ───────── + + alice.send("PASS :mypassword123") + + alicePass := alice.readUntil(func(l string) bool { + return strings.Contains(l, "Password set") + }) + assertContains( + t, alicePass, "Password set", + "password set confirmation", + ) + + // ── MODE -t/+t topic lock toggle ────────────────── + + alice.send("MODE #integration -t") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "-t") + }) + bob.readUntil(func(l string) bool { + return strings.Contains(l, "-t") + }) + + // Now bob should be able to set topic. + bob.send("TOPIC #integration :bob sets topic now") + + bobTopicOK := bob.readUntil(func(l string) bool { + return strings.Contains(l, "bob sets topic now") + }) + assertContains( + t, bobTopicOK, "bob sets topic now", + "bob can set topic after -t", + ) + + // alice sees the topic change. + aliceTopicRelay := alice.readUntil(func(l string) bool { + return strings.Contains(l, "bob sets topic now") + }) + assertContains( + t, aliceTopicRelay, "bob sets topic now", + "alice sees bob's topic after -t", + ) + + // Restore +t. + alice.send("MODE #integration +t") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "+t") + }) + bob.readUntil(func(l string) bool { + return strings.Contains(l, "+t") + }) + + // ── DM to nonexistent nick ──────────────────────── + + alice.send("PRIVMSG nobody123 :hello") + + aliceNoSuch := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 401 ") + }) + assertContains( + t, aliceNoSuch, " 401 ", + "ERR_NOSUCHNICK", + ) + + // ── PART with reason ────────────────────────────── + + bob.send("PART #integration :bye for now") + + bobPart := bob.readUntil(func(l string) bool { + return strings.Contains(l, "PART") + }) + assertContains( + t, bobPart, "PART", + "bob sees PART echo", + ) + + // alice sees bob PART via relay. + alicePart := alice.readUntil(func(l string) bool { + return strings.Contains(l, "PART") && + strings.Contains(l, "bob") + }) + assertContains( + t, alicePart, "bob", + "alice sees bob's PART", + ) + assertContains( + t, alicePart, "bye for now", + "PART reason is relayed", + ) + + // bob rejoins for remaining tests. + bob.joinAndDrain("#integration") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "JOIN") && + strings.Contains(l, "bob") + }) + + // ── PART non-existent channel ───────────────────── + + bob.send("PART #nonexistent") + + bobPartFail := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 403 ") || + strings.Contains(l, " 442 ") + }) + + foundPartErr := false + for _, line := range bobPartFail { + if strings.Contains(line, " 403 ") || + strings.Contains(line, " 442 ") { + foundPartErr = true + + break + } + } + + if !foundPartErr { + t.Error( + "expected ERR_NOSUCHCHANNEL or " + + "ERR_NOTONCHANNEL", + ) + } + + // ── User MODE query ─────────────────────────────── + + alice.send("MODE alice") + + aliceUMode := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 221 ") + }) + assertContains( + t, aliceUMode, " 221 ", + "RPL_UMODEIS", + ) + + // ── Multiple channel operation ──────────────────── + + alice.send("JOIN #second") + alice.readUntil(func(l string) bool { + return strings.Contains(l, " 366 ") + }) + + bob.send("JOIN #second") + bob.readUntil(func(l string) bool { + return strings.Contains(l, " 366 ") + }) + + // Drain alice seeing bob join. + alice.readUntil(func(l string) bool { + return strings.Contains(l, "JOIN") && + strings.Contains(l, "bob") + }) + + alice.send("PRIVMSG #second :cross-channel test") + + bobCross := bob.readUntil(func(l string) bool { + return strings.Contains(l, "cross-channel test") + }) + assertContains( + t, bobCross, "cross-channel test", + "bob receives message in #second", + ) + + // Clean up #second. + alice.send("PART #second") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "PART") + }) + bob.send("PART #second") + bob.readUntil(func(l string) bool { + return strings.Contains(l, "PART") + }) + + // ── QUIT ────────────────────────────────────────── + + bob.send("QUIT :integration test done") + + bobQuit := bob.readUntil(func(l string) bool { + return strings.Contains(l, "ERROR") + }) + assertContains( + t, bobQuit, "integration test done", + "QUIT reason echoed", + ) + + // alice should see bob's QUIT via relay. + aliceQuit := alice.readUntil(func(l string) bool { + return strings.Contains(l, "QUIT") && + strings.Contains(l, "bob") + }) + assertContains( + t, aliceQuit, "bob", + "alice sees bob's QUIT", + ) +} + +// TestIntegrationModeSecret tests +s (secret) channel +// mode — verifies that +s can be set and the mode is +// reflected in MODE queries. +func TestIntegrationModeSecret(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + alice.joinAndDrain("#secretroom") + + // Set +s. + alice.send("MODE #secretroom +s") + aliceLines := alice.readUntil(func(l string) bool { + return strings.Contains(l, "+s") + }) + assertContains( + t, aliceLines, "+s", + "alice sees MODE +s confirmation", + ) + + // Verify mode is reflected in query. + alice.send("MODE #secretroom") + modeLines := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 324 ") + }) + assertContains( + t, modeLines, "s", + "channel mode includes s", + ) +} + +// TestIntegrationModeModerated tests +m (moderated) mode +// — non-voiced users cannot send. +func TestIntegrationModeModerated(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + bob := env.dial(t) + bob.register("bob") + + alice.joinAndDrain("#modtest") + bob.joinAndDrain("#modtest") + + // Drain alice's view of bob's join. + alice.readUntil(func(l string) bool { + return strings.Contains(l, "JOIN") && + strings.Contains(l, "bob") + }) + + // Set +m. + alice.send("MODE #modtest +m") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "+m") + }) + bob.readUntil(func(l string) bool { + return strings.Contains(l, "+m") + }) + + // bob should get an error trying to send. + bob.send("PRIVMSG #modtest :should fail") + bobLines := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 404 ") || + strings.Contains(l, " 482 ") + }) + + foundModErr := false + for _, line := range bobLines { + if strings.Contains(line, " 404 ") || + strings.Contains(line, " 482 ") { + foundModErr = true + + break + } + } + + if !foundModErr { + t.Error( + "non-voiced user should not be able to send " + + "in +m channel", + ) + } + + // Grant +v to bob, then he should be able to send. + alice.send("MODE #modtest +v bob") + alice.readUntil(func(l string) bool { + return strings.Contains(l, "+v") + }) + bob.readUntil(func(l string) bool { + return strings.Contains(l, "+v") + }) + + bob.send("PRIVMSG #modtest :voiced message") + aliceLines := alice.readUntil(func(l string) bool { + return strings.Contains(l, "voiced message") + }) + assertContains( + t, aliceLines, "voiced message", + "alice receives voiced bob's message", + ) +} + +// TestIntegrationThirdClientObserver verifies that a third +// client observing the same channel receives messages from +// the other two. +func TestIntegrationThirdClientObserver(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + bob := env.dial(t) + bob.register("bob") + + carol := env.dial(t) + carol.register("carol") + + alice.joinAndDrain("#trio") + bob.joinAndDrain("#trio") + carol.joinAndDrain("#trio") + + // Drain join notifications. + time.Sleep(100 * time.Millisecond) + + // alice sends; both bob and carol should receive. + alice.send("PRIVMSG #trio :hello trio") + + bobLines := bob.readUntil(func(l string) bool { + return strings.Contains(l, "hello trio") + }) + assertContains( + t, bobLines, "hello trio", + "bob receives trio message", + ) + + carolLines := carol.readUntil(func(l string) bool { + return strings.Contains(l, "hello trio") + }) + assertContains( + t, carolLines, "hello trio", + "carol receives trio message", + ) +}