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", ) }