package ircserver_test import ( "bufio" "context" "crypto/rand" "database/sql" "errors" "fmt" "log/slog" "net" "os" "runtime" "strings" "sync/atomic" "testing" "time" "go.uber.org/fx/fxtest" "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/logger" _ "modernc.org/sqlite" ) const testTimeout = 5 * time.Second func TestMain(m *testing.M) { db.SetBcryptCost(4) os.Exit(m.Run()) } // testEnv holds the shared test infrastructure. type testEnv struct { database *db.Database brk *broker.Broker cfg *config.Config srv *ircserver.Server } // testServerName is the SERVER_NAME of newTestEnv. const testServerName = "test.irc" func newTestEnv(t *testing.T) *testEnv { t.Helper() return newTestEnvWithConfig(t, &config.Config{ ServerName: testServerName, MOTD: "Welcome to test IRC", }) } // newTestEnvWithOper is newTestEnv with the operator name // testoper and password testpass. func newTestEnvWithOper(t *testing.T) *testEnv { t.Helper() return newTestEnvWithConfig(t, &config.Config{ ServerName: testServerName, MOTD: "Welcome to test IRC", OperName: "testoper", OperPassword: "testpass", }) } // newTestEnvWithConfig starts an IRC server with cfg on a // fresh database. func newTestEnvWithConfig( t *testing.T, cfg *config.Config, ) *testEnv { t.Helper() return newTestEnvWithPingTimes( t, cfg, ircserver.PingInterval, ircserver.PongDeadline, ) } // newTestEnvWithPingTimes is newTestEnvWithConfig, except // that the server sends a connection a PING after interval // with nothing read, and closes it when nothing is read // within deadline after that. func newTestEnvWithPingTimes( t *testing.T, cfg *config.Config, interval, deadline time.Duration, ) *testEnv { t.Helper() return newTestEnvWithResolver( t, cfg, interval, deadline, ircserver.ResolveHost, ) } // newTestEnvWithResolver is newTestEnvWithPingTimes, except // that the server looks up a client's hostname with resolve. func newTestEnvWithResolver( t *testing.T, cfg *config.Config, interval, deadline time.Duration, resolve func(ctx context.Context, addr string) string, ) *testEnv { t.Helper() // A random name, so that no other test environment, not // even an earlier run of the same test, can share it. dsn := fmt.Sprintf( "file:%s?mode=memory&cache=shared", rand.Text(), ) conn, err := sql.Open("sqlite", dsn) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { err := conn.Close() if err != nil { t.Logf("close db: %v", err) } }) conn.SetMaxOpenConns(1) _, err = conn.ExecContext( t.Context(), "PRAGMA foreign_keys = ON", ) if err != nil { t.Fatalf("pragma: %v", err) } database := db.NewTestDatabaseFromConn(conn) err = database.RunMigrations(t.Context()) if err != nil { t.Fatalf("migrate: %v", err) } brk := broker.New() var listenConfig net.ListenConfig listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } addr := listener.Addr().String() err = listener.Close() if err != nil { t.Fatalf("close listener: %v", err) } log := slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct )) srv := ircserver.NewTestServer( log, cfg, database, brk, interval, deadline, resolve, ) err = srv.Start(addr) if err != nil { t.Fatalf("start irc server: %v", err) } // Cleanups run last registered first, so the server stops // before its database is closed. t.Cleanup(srv.Stop) return &testEnv{ database: database, brk: brk, cfg: cfg, srv: srv, } } // dial connects to the test server. func (env *testEnv) dial(t *testing.T) *testClient { t.Helper() dialer := net.Dialer{Timeout: testTimeout} conn, err := dialer.DialContext( t.Context(), "tcp", env.srv.Listener().Addr().String(), ) if err != nil { t.Fatalf("dial: %v", err) } t.Cleanup(func() { err := conn.Close() if err != nil { t.Logf("close conn: %v", err) } }) return &testClient{ t: t, conn: conn, scanner: bufio.NewScanner(conn), } } // The ping interval and PONG deadline for // newTestEnvWithShortPings. They differ, so that a test can // tell them apart; the PONG deadline is how long a test // client has to answer a PING. const ( testPingInterval = time.Second testPongDeadline = 300 * time.Millisecond ) // newTestEnvWithShortPings starts an IRC server that uses // testPingInterval and testPongDeadline in place of // pingInterval and pongDeadline. func newTestEnvWithShortPings(t *testing.T) *testEnv { t.Helper() return newTestEnvWithPingTimes( t, &config.Config{ServerName: testServerName}, testPingInterval, testPongDeadline, ) } // isServerPing reports whether line is the PING the test // server sends a quiet client. func isServerPing(line string) bool { return line == "PING :"+testServerName } // testClient wraps a raw TCP connection with helpers. type testClient struct { t *testing.T conn net.Conn scanner *bufio.Scanner } func (tc *testClient) send(line string) { tc.t.Helper() _ = tc.conn.SetWriteDeadline( time.Now().Add(testTimeout), ) _, err := fmt.Fprintf(tc.conn, "%s\r\n", line) if err != nil { tc.t.Fatalf("send: %v", err) } } func (tc *testClient) readLine() string { tc.t.Helper() _ = tc.conn.SetReadDeadline( time.Now().Add(testTimeout), ) if !tc.scanner.Scan() { err := tc.scanner.Err() if err != nil { tc.t.Fatalf("read: %v", err) } tc.t.Fatal("connection closed unexpectedly") } return tc.scanner.Text() } // readUntil reads lines until one matches the predicate. func (tc *testClient) readUntil( pred func(string) bool, ) []string { tc.t.Helper() var lines []string for { line := tc.readLine() lines = append(lines, line) if pred(line) { return lines } } } // register sends NICK + USER and reads through the welcome // burst. func (tc *testClient) register(nick string) []string { tc.t.Helper() tc.send("NICK " + nick) tc.send("USER " + nick + " 0 * :Test User") return tc.readUntil(func(line string) bool { return strings.Contains(line, " 376 ") || strings.Contains(line, " 422 ") }) } // readUntilClosed returns the lines received until the // server closes the connection, and fails the test if it is // still open after testTimeout. func (tc *testClient) readUntilClosed() []string { tc.t.Helper() _ = tc.conn.SetReadDeadline( time.Now().Add(testTimeout), ) var lines []string for tc.scanner.Scan() { lines = append(lines, tc.scanner.Text()) } err := tc.scanner.Err() if err != nil { tc.t.Fatalf( "connection not closed: %v (lines: %v)", err, lines, ) } return lines } // readServerPing reads until the PING the test server sends a // quiet client, and fails the test if it came sooner than // testPingInterval after lastSent, a time just before the // client sent its last line. func (tc *testClient) readServerPing(lastSent time.Time) { tc.t.Helper() tc.readUntil(isServerPing) waited := time.Since(lastSent) if waited < testPingInterval { tc.t.Errorf( "PING came %v after the client's last line, want at least %v", waited, testPingInterval, ) } } // assertContains checks that at least one line matches the // given substring. func assertContains( t *testing.T, lines []string, substr, description string, ) { t.Helper() for _, line := range lines { if strings.Contains(line, substr) { return } } t.Errorf("did not find %q in output: %s", substr, description) } // assertNotContains checks that no line matches the given // substring. func assertNotContains( t *testing.T, lines []string, substr, description string, ) { t.Helper() for _, line := range lines { if strings.Contains(line, substr) { t.Errorf( "unexpectedly found %q in output: %s", substr, description, ) return } } } // joinAndDrain joins a channel and reads until // RPL_ENDOFNAMES. func (tc *testClient) joinAndDrain(channel string) { tc.t.Helper() tc.send("JOIN " + channel) tc.readUntil(func(line string) bool { return strings.Contains(line, " 366 ") }) } // sendAndExpect sends a command and reads until a line // containing the expected substring is found. func (tc *testClient) sendAndExpect( cmd, expect string, ) []string { tc.t.Helper() tc.send(cmd) return tc.readUntil(func(line string) bool { return strings.Contains(line, expect) }) } func TestRegistration(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) lines := client.register("alice") assertContains(t, lines, " 001 ", "RPL_WELCOME") } // TestRegistrationWhileAnotherLookupIsPending checks that a // client is accepted and registered while the hostname lookup // of an earlier client has not returned, and that it // registers with the hostname its own lookup returned. func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) { t.Parallel() const lookedUpHost = "client.example" var lookups atomic.Int32 firstLookupStarted := make(chan struct{}) // The first lookup returns only when the server stops. resolve := func(ctx context.Context, _ string) string { if lookups.Add(1) == 1 { close(firstLookupStarted) <-ctx.Done() } return lookedUpHost } env := newTestEnvWithResolver( t, &config.Config{ServerName: testServerName}, ircserver.PingInterval, ircserver.PongDeadline, resolve, ) env.dial(t) select { case <-firstLookupStarted: case <-time.After(testTimeout): t.Fatal("the first client's lookup did not start") } lines := env.dial(t).register("second") assertContains( t, lines, "second!second@"+lookedUpHost, "RPL_WELCOME with the looked-up hostname", ) } // TestEachTestEnvHasItsOwnDatabase checks that a test // environment starts on an empty database while another one // is still open. func TestEachTestEnvHasItsOwnDatabase(t *testing.T) { t.Parallel() first := newTestEnv(t) first.dial(t).register("samenick") second := newTestEnv(t) lines := second.dial(t).register("samenick") assertContains(t, lines, " 001 ", "RPL_WELCOME") } func TestWelcomeContainsNick(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) lines := client.register("bob") for _, line := range lines { if strings.Contains(line, " 001 ") && !strings.Contains(line, "bob") { t.Errorf("001 should contain nick: %s", line) } } } func TestPingPong(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("pingtest") lines := client.sendAndExpect("PING :hello", "PONG") assertContains(t, lines, "PONG", "PONG response") } // TestQuietClientAnsweringPingStaysConnected checks that a // registered client that sends nothing is sent a PING once // the ping interval has passed since its last line, and stays // connected as long as it answers each one, whether with a // PONG or with another line. func TestQuietClientAnsweringPingStaysConnected(t *testing.T) { t.Parallel() tests := []struct { name string answer string }{ {name: "PONG", answer: "PONG :" + testServerName}, {name: "PING of its own", answer: "PING :answer"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() env := newTestEnvWithShortPings(t) client := env.dial(t) lastSent := time.Now() client.register("answers") // The second PING comes later than a client that // answers none stays connected. for range 2 { client.readServerPing(lastSent) lastSent = time.Now() client.send(tt.answer) } client.sendAndExpect("PING :still here", "still here") }) } } // TestQuietClientNotAnsweringPingIsClosed checks that a // registered client that does not answer the PING is // disconnected once the PONG deadline has passed after it. func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) { t.Parallel() // latest leaves room for a loaded host, but is sooner than // a read deadline of two ping intervals would close it. const ( earliest = testPingInterval + testPongDeadline latest = 2 * testPingInterval ) env := newTestEnvWithShortPings(t) client := env.dial(t) lastSent := time.Now() client.register("silent") client.readServerPing(lastSent) client.readUntilClosed() waited := time.Since(lastSent) if waited < earliest || waited >= latest { t.Errorf( "closed %v after the client's last line, want %v to %v", waited, earliest, latest, ) } } // TestUnregisteredClientAnswersPing checks that a PONG before // registration, the answer to the PING unregistered clients // are sent too, gets no ERR_NOTREGISTERED. func TestUnregisteredClientAnswersPing(t *testing.T) { t.Parallel() env := newTestEnvWithShortPings(t) client := env.dial(t) client.readUntil(isServerPing) client.send("PONG :" + testServerName) lines := client.sendAndExpect("PING :after", "PONG") assertNotContains(t, lines, " 451 ", "ERR_NOTREGISTERED") } // TestNewPingTimes checks that a server made by New sends a // PING after 90 seconds with nothing read, and closes the // connection 30 seconds after that, as README.md states. func TestNewPingTimes(t *testing.T) { t.Parallel() const ( wantInterval = 90 * time.Second wantDeadline = 30 * time.Second ) log, err := logger.New(nil, logger.Params{ Globals: &globals.Globals{}, }) if err != nil { t.Fatal(err) } // With no IRC_LISTEN_ADDR, New starts no listener and // uses nothing else from its params. srv := ircserver.New(fxtest.NewLifecycle(t), ircserver.Params{ Logger: log, Config: &config.Config{}, }) interval, deadline := srv.PingTimes() if interval != wantInterval || deadline != wantDeadline { t.Errorf( "PING after %v and close %v later, want %v and %v", interval, deadline, wantInterval, wantDeadline, ) } } // TestIdleCleanupKeepsActiveUser checks that a user who // sends commands is kept by the idle cleanup, and a user who // sends nothing is not. func TestIdleCleanupKeepsActiveUser(t *testing.T) { t.Parallel() const idleTimeout = 500 * time.Millisecond env := newTestEnv(t) active := env.dial(t) active.register("active") idle := env.dial(t) idle.register("idle") time.Sleep(idleTimeout) active.sendAndExpect("PING :still here", "PONG") // The idle cleanup in internal/handlers removes exactly // the users this returns. stale, err := env.database.GetStaleOrphanSessions( t.Context(), time.Now().Add(-idleTimeout), ) if err != nil { t.Fatalf("get stale sessions: %v", err) } if len(stale) != 1 || stale[0].Nick != "idle" { t.Errorf("cleanup removes %v, want only idle", stale) } } func TestJoinChannel(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("joiner") client.send("JOIN #test") lines := client.readUntil(func(line string) bool { return strings.Contains(line, " 366 ") }) assertContains(t, lines, "JOIN", "JOIN echo") assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES") } // TestJoinInvalidChannelName checks that JOIN refuses a name // that is not # followed by 1 to 63 letters, digits, _ or - // with ERR_NOSUCHCHANNEL, and creates no such channel. func TestJoinInvalidChannelName(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("joiner") for _, channel := range []string{ "#bad.name", "#", "#" + strings.Repeat("a", 64), } { lines := client.sendAndExpect("JOIN "+channel, " 403 ") assertContains( t, lines, " 403 joiner "+channel+" :No such channel", channel, ) _, err := env.database.GetChannelByName(t.Context(), channel) if err == nil { t.Errorf("channel %q was created", channel) } } } func TestPrivmsgBetweenClients(t *testing.T) { t.Parallel() env := newTestEnv(t) alice := env.dial(t) alice.register("alice_pm") bob := env.dial(t) bob.register("bob_pm") alice.joinAndDrain("#chat") bob.joinAndDrain("#chat") alice.send("PRIVMSG #chat :hello bob!") lines := bob.sendAndExpect("PING :sync", "hello bob!") assertContains(t, lines, "hello bob!", "channel PRIVMSG") } func TestNickChange(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("oldnick") lines := client.sendAndExpect("NICK newnick", "newnick") assertContains(t, lines, "NICK", "NICK change echo") } func TestDuplicateNick(t *testing.T) { t.Parallel() env := newTestEnv(t) first := env.dial(t) first.register("taken") second := env.dial(t) second.send("NICK taken") second.send("USER taken 0 * :Test") lines := second.readUntil(func(line string) bool { return strings.Contains(line, " 433 ") }) assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE") } // TestNickRefused checks that NICK, before and after // registration, answers a nick the HTTP API refuses with // ERR_ERRONEUSNICKNAME and keeps the nick as it was, and // that another member of the channel sees no line from it. func TestNickRefused(t *testing.T) { t.Parallel() // Each is what follows "NICK " on the wire. refused := []string{ ":bob PRIVMSG #inj :hi", // contains spaces ":", // empty strings.Repeat("a", 33), // one character too long "bob!x", "bob@x", "#bob", } env := newTestEnv(t) // Before registration: the 432 is addressed to the nick // sent first, and USER registers with that nick. early := env.dial(t) early.send("NICK early") for _, param := range refused { lines := early.sendAndExpect("NICK "+param, " 432 ") assertContains(t, lines, " 432 early ", "before: "+param) } welcome := early.sendAndExpect("USER early 0 * :Test", " 001 ") assertContains(t, welcome, " 001 early ", "registered as early") // After registration. bob := env.dial(t) bob.register("bob") bob.joinAndDrain("#inj") mallory := env.dial(t) mallory.register("mallory") mallory.joinAndDrain("#inj") bob.readUntil(func(l string) bool { return strings.Contains(l, "JOIN") && strings.Contains(l, "mallory") }) for _, param := range refused { lines := mallory.sendAndExpect("NICK "+param, " 432 ") assertContains(t, lines, " 432 mallory ", "after: "+param) } mallory.send("PRIVMSG #inj :still mallory") bobLines := bob.readUntil(func(l string) bool { return strings.Contains(l, "still mallory") }) assertNotContains(t, bobLines, " NICK ", "bob sees no NICK") assertContains( t, bobLines, ":mallory!", "bob sees mallory's message", ) } func TestListChannels(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("lister") client.joinAndDrain("#listtest") lines := client.sendAndExpect("LIST", " 323 ") assertContains(t, lines, " 323 ", "RPL_LISTEND") //nolint:misspell // IRC term } func TestWhois(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("whoistest") lines := client.sendAndExpect( "WHOIS whoistest", " 318 ", ) assertContains(t, lines, " 311 ", "RPL_WHOISUSER") assertContains(t, lines, " 318 ", "RPL_ENDOFWHOIS") } func TestQuit(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("quitter") lines := client.sendAndExpect( "QUIT :goodbye", "ERROR", ) assertContains(t, lines, "goodbye", "QUIT reason") } // TestStopEndsSessions checks that stopping the server ends // a registered connection's session before Stop returns, as // closing the connection does: the session, its client and // its channel membership are gone from the database, and a // channel peer has been sent the QUIT. func TestStopEndsSessions(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("shutirc") client.joinAndDrain("#shut") ctx := t.Context() // A peer on the HTTP API, whose session outlives the stop. peerID, peerClientID, _, err := env.database.CreateSession( ctx, "peer", "peer", "", "", ) if err != nil { t.Fatalf("create peer session: %v", err) } channelID, err := env.database.GetChannelByName(ctx, "#shut") if err != nil { t.Fatalf("get channel: %v", err) } err = env.database.JoinChannel(ctx, channelID, peerID) if err != nil { t.Fatalf("join peer: %v", err) } env.srv.Stop() _, err = env.database.GetSessionByNick(ctx, "shutirc") if !errors.Is(err, sql.ErrNoRows) { t.Errorf("look up shutirc after stop: %v, want no rows", err) } clients, err := env.database.GetClientCount(ctx) if err != nil { t.Fatalf("count clients: %v", err) } if clients != 1 { t.Errorf("%d clients after stop, want only the peer's", clients) } members, err := env.database.GetChannelMemberIDs(ctx, channelID) if err != nil { t.Fatalf("get members: %v", err) } if len(members) != 1 || members[0] != peerID { t.Errorf( "members of #shut after stop: %v, want only the peer %d", members, peerID, ) } msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0) if err != nil { t.Fatalf("poll peer: %v", err) } for _, msg := range msgs { if msg.Command == "QUIT" && msg.From == "shutirc" { return } } t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs) } // TestStopReturnsWhileCleanupIsStuck checks that Stop stops // waiting for a connection's cleanup after StopTimeout, so a // cleanup that cannot finish does not hold up stopping. func TestStopReturnsWhileCleanupIsStuck(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("stuck") // The test database has one connection. While the test // holds it, the cleanup's first query waits for it. held, err := env.database.GetDB().Conn(t.Context()) if err != nil { t.Fatalf("take the database connection: %v", err) } stopped := make(chan struct{}) go func() { env.srv.Stop() close(stopped) }() select { case <-stopped: case <-time.After(ircserver.StopTimeout + testTimeout): t.Error("Stop did not return while a cleanup was stuck") } // Lets the cleanup finish before the database is closed. err = held.Close() if err != nil { t.Errorf("release the database connection: %v", err) } } func TestTopicSetAndGet(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("topicuser") client.joinAndDrain("#topictest") lines := client.sendAndExpect( "TOPIC #topictest :New topic here", "New topic here", ) assertContains( t, lines, "New topic here", "TOPIC echo", ) } func TestUnknownCommand(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("unknowncmd") lines := client.sendAndExpect("FOOBAR", " 421 ") assertContains(t, lines, " 421 ", "ERR_UNKNOWNCOMMAND") } func TestDirectMessage(t *testing.T) { t.Parallel() env := newTestEnv(t) sender := env.dial(t) sender.register("dmsender") receiver := env.dial(t) receiver.register("dmreceiver") // Give relay goroutines time to start. time.Sleep(100 * time.Millisecond) sender.send("PRIVMSG dmreceiver :hello privately") lines := receiver.readUntil(func(line string) bool { return strings.Contains(line, "hello privately") }) assertContains( t, lines, "hello privately", "direct PRIVMSG", ) } func TestCAPNegotiation(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.send("CAP LS 302") line := client.readLine() if !strings.Contains(line, "CAP") { t.Errorf("expected CAP response, got: %s", line) } client.send("CAP END") lines := client.register("capuser") assertContains(t, lines, " 001 ", "registration after CAP") } func TestPartChannel(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("parter") client.joinAndDrain("#parttest") lines := client.sendAndExpect( "PART #parttest :leaving", "PART", ) assertContains(t, lines, "#parttest", "PART echo") } func TestModeQuery(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("modeuser") client.joinAndDrain("#modetest") lines := client.sendAndExpect( "MODE #modetest", " 324 ", ) assertContains( t, lines, " 324 ", "RPL_CHANNELMODEIS", ) } func TestWhoChannel(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("whouser") client.joinAndDrain("#whotest") lines := client.sendAndExpect("WHO #whotest", " 315 ") assertContains(t, lines, " 352 ", "RPL_WHOREPLY") } func TestLusers(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("luseruser") lines := client.sendAndExpect("LUSERS", " 255 ") assertContains(t, lines, " 251 ", "RPL_LUSERCLIENT") } func TestMotd(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("motduser") lines := client.sendAndExpect("MOTD", " 376 ") assertContains(t, lines, " 376 ", "RPL_ENDOFMOTD") } func TestAwaySetAndClear(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("awayuser") setLines := client.sendAndExpect( "AWAY :brb lunch", " 306 ", ) assertContains(t, setLines, " 306 ", "RPL_NOWAWAY") clearLines := client.sendAndExpect("AWAY", " 305 ") assertContains(t, clearLines, " 305 ", "RPL_UNAWAY") } func TestHandlePassPostRegistration(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("passuser") lines := client.sendAndExpect( "PASS :mypassword123", "Password set", ) assertContains( t, lines, "Password set", "password confirmation", ) } func TestPreRegistrationNotRegistered(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.send("PRIVMSG #test :hello") line := client.readLine() if !strings.Contains(line, " 451 ") { t.Errorf( "expected ERR_NOTREGISTERED (451), got: %s", line, ) } } func TestNamesNonExistentChannel(t *testing.T) { t.Parallel() env := newTestEnv(t) client := env.dial(t) client.register("namesuser") lines := client.sendAndExpect( "NAMES #doesnotexist", " 366 ", ) assertContains( t, lines, " 366 ", "RPL_ENDOFNAMES for non-existent channel", ) } // TestRelayStopsWhenConnectionCloses checks that closing a // registered client's connection stops the goroutine that // relays its messages. // //nolint:paralleltest // counts every goroutine in the process func TestRelayStopsWhenConnectionCloses(t *testing.T) { env := newTestEnv(t) client := env.dial(t) client.register("relaystop") waitForRelayGoroutines(t, 1) err := client.conn.Close() if err != nil { t.Fatalf("close: %v", err) } waitForRelayGoroutines(t, 0) } const ( stackDumpSize = 1 << 20 relayCheckStep = 10 * time.Millisecond ) // waitForRelayGoroutines waits until exactly want goroutines // are running the relay loop, and fails the test if that does // not happen within testTimeout. func waitForRelayGoroutines(t *testing.T, want int) { t.Helper() deadline := time.Now().Add(testTimeout) stacks := make([]byte, stackDumpSize) for { n := runtime.Stack(stacks, true) got := strings.Count(string(stacks[:n]), "(*Conn).relayMessages(") if got == want { return } if time.Now().After(deadline) { t.Fatalf("relay goroutines: got %d, want %d", got, want) } time.Sleep(relayCheckStep) } } func BenchmarkParseMessage(b *testing.B) { line := ":nick!user@host PRIVMSG #channel :Hello, world!" b.ResetTimer() for range b.N { _ = ircserver.ParseMessage(line) } } func BenchmarkFormatMessage(b *testing.B) { b.ResetTimer() for range b.N { _ = ircserver.FormatMessage( "nick!user@host", "PRIVMSG", "#channel", "Hello, world!", ) } }