package handlers_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/chat/internal/handlers" "git.eeqj.de/sneak/chat/internal/healthcheck" "git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/chat/internal/middleware" "git.eeqj.de/sneak/chat/internal/server" "go.uber.org/fx" "go.uber.org/fx/fxtest" ) // testServer wraps a test HTTP server with helper methods. type testServer struct { srv *httptest.Server t *testing.T fxApp *fxtest.App } func newTestServer(t *testing.T) *testServer { t.Helper() var s *server.Server app := fxtest.New(t, fx.Provide( func() *globals.Globals { return &globals.Globals{Appname: "chat-test", Version: "test"} }, logger.New, func(lc fx.Lifecycle, g *globals.Globals, l *logger.Logger) (*config.Config, error) { return config.New(lc, config.Params{Globals: g, Logger: l}) }, func(lc fx.Lifecycle, l *logger.Logger, c *config.Config) (*db.Database, error) { return db.New(lc, db.Params{Logger: l, Config: c}) }, func(lc fx.Lifecycle, g *globals.Globals, c *config.Config, l *logger.Logger, d *db.Database) (*healthcheck.Healthcheck, error) { return healthcheck.New(lc, healthcheck.Params{Globals: g, Config: c, Logger: l, Database: d}) }, func(lc fx.Lifecycle, l *logger.Logger, g *globals.Globals, c *config.Config) (*middleware.Middleware, error) { return middleware.New(lc, middleware.Params{Logger: l, Globals: g, Config: c}) }, func(lc fx.Lifecycle, l *logger.Logger, g *globals.Globals, c *config.Config, d *db.Database, hc *healthcheck.Healthcheck) (*handlers.Handlers, error) { return handlers.New(lc, handlers.Params{Logger: l, Globals: g, Config: c, Database: d, Healthcheck: hc}) }, func(lc fx.Lifecycle, l *logger.Logger, g *globals.Globals, c *config.Config, mw *middleware.Middleware, h *handlers.Handlers) (*server.Server, error) { return server.New(lc, server.Params{Logger: l, Globals: g, Config: c, Middleware: mw, Handlers: h}) }, ), fx.Populate(&s), ) app.RequireStart() // Give the server a moment to set up routes. time.Sleep(100 * time.Millisecond) ts := httptest.NewServer(s) t.Cleanup(func() { ts.Close() app.RequireStop() }) return &testServer{srv: ts, t: t, fxApp: app} } func (ts *testServer) url(path string) string { return ts.srv.URL + path } func (ts *testServer) createSession(nick string) (int64, string) { ts.t.Helper() body, _ := json.Marshal(map[string]string{"nick": nick}) resp, err := http.Post(ts.url("/api/v1/session"), "application/json", bytes.NewReader(body)) if err != nil { ts.t.Fatalf("create session: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { b, _ := io.ReadAll(resp.Body) ts.t.Fatalf("create session: status %d: %s", resp.StatusCode, b) } var result struct { ID int64 `json:"id"` Token string `json:"token"` } json.NewDecoder(resp.Body).Decode(&result) return result.ID, result.Token } func (ts *testServer) sendCommand(token string, cmd map[string]any) (*http.Response, map[string]any) { ts.t.Helper() body, _ := json.Marshal(cmd) req, _ := http.NewRequest("POST", ts.url("/api/v1/messages"), bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { ts.t.Fatalf("send command: %v", err) } defer resp.Body.Close() var result map[string]any json.NewDecoder(resp.Body).Decode(&result) return resp, result } func (ts *testServer) getJSON(token, path string) (*http.Response, map[string]any) { ts.t.Helper() req, _ := http.NewRequest("GET", ts.url(path), nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { ts.t.Fatalf("get: %v", err) } defer resp.Body.Close() var result map[string]any json.NewDecoder(resp.Body).Decode(&result) return resp, result } func (ts *testServer) pollMessages(token string, afterID int64, timeout int) ([]map[string]any, int64) { ts.t.Helper() url := fmt.Sprintf("%s/api/v1/messages?timeout=%d&after=%d", ts.srv.URL, timeout, afterID) req, _ := http.NewRequestWithContext(context.Background(), "GET", url, nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { ts.t.Fatalf("poll: %v", err) } defer resp.Body.Close() var result struct { Messages []map[string]any `json:"messages"` LastID json.Number `json:"last_id"` } json.NewDecoder(resp.Body).Decode(&result) lastID, _ := result.LastID.Int64() return result.Messages, lastID } // --- Tests --- func TestCreateSession(t *testing.T) { ts := newTestServer(t) t.Run("valid nick", func(t *testing.T) { _, token := ts.createSession("alice") if token == "" { t.Fatal("expected token") } }) t.Run("duplicate nick", func(t *testing.T) { body, _ := json.Marshal(map[string]string{"nick": "alice"}) resp, err := http.Post(ts.url("/api/v1/session"), "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } }) t.Run("empty nick", func(t *testing.T) { body, _ := json.Marshal(map[string]string{"nick": ""}) resp, err := http.Post(ts.url("/api/v1/session"), "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("invalid nick chars", func(t *testing.T) { body, _ := json.Marshal(map[string]string{"nick": "hello world"}) resp, err := http.Post(ts.url("/api/v1/session"), "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("nick starting with number", func(t *testing.T) { body, _ := json.Marshal(map[string]string{"nick": "123abc"}) resp, err := http.Post(ts.url("/api/v1/session"), "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("malformed json", func(t *testing.T) { resp, err := http.Post(ts.url("/api/v1/session"), "application/json", strings.NewReader("{bad")) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestAuth(t *testing.T) { ts := newTestServer(t) t.Run("no auth header", func(t *testing.T) { resp, _ := ts.getJSON("", "/api/v1/state") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("bad token", func(t *testing.T) { resp, _ := ts.getJSON("invalid-token-12345", "/api/v1/state") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("valid token", func(t *testing.T) { _, token := ts.createSession("authtest") resp, result := ts.getJSON(token, "/api/v1/state") if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if result["nick"] != "authtest" { t.Fatalf("expected nick authtest, got %v", result["nick"]) } }) } func TestJoinAndPart(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("bob") t.Run("join channel", func(t *testing.T) { resp, result := ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#test"}) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %v", resp.StatusCode, result) } if result["channel"] != "#test" { t.Fatalf("expected #test, got %v", result["channel"]) } }) t.Run("join without hash", func(t *testing.T) { resp, result := ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "other"}) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %v", resp.StatusCode, result) } if result["channel"] != "#other" { t.Fatalf("expected #other, got %v", result["channel"]) } }) t.Run("part channel", func(t *testing.T) { resp, result := ts.sendCommand(token, map[string]any{"command": "PART", "to": "#test"}) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %v", resp.StatusCode, result) } if result["channel"] != "#test" { t.Fatalf("expected #test, got %v", result["channel"]) } }) t.Run("join missing to", func(t *testing.T) { resp, _ := ts.sendCommand(token, map[string]any{"command": "JOIN"}) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestPrivmsg(t *testing.T) { ts := newTestServer(t) _, aliceToken := ts.createSession("alice_msg") _, bobToken := ts.createSession("bob_msg") // Both join #chat ts.sendCommand(aliceToken, map[string]any{"command": "JOIN", "to": "#chat"}) ts.sendCommand(bobToken, map[string]any{"command": "JOIN", "to": "#chat"}) // Drain existing messages (JOINs) _, _ = ts.pollMessages(aliceToken, 0, 0) _, bobLastID := ts.pollMessages(bobToken, 0, 0) t.Run("send channel message", func(t *testing.T) { resp, result := ts.sendCommand(aliceToken, map[string]any{ "command": "PRIVMSG", "to": "#chat", "body": []string{"hello world"}, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d: %v", resp.StatusCode, result) } if result["id"] == nil || result["id"] == "" { t.Fatal("expected message id") } }) t.Run("bob receives message", func(t *testing.T) { msgs, _ := ts.pollMessages(bobToken, bobLastID, 0) found := false for _, m := range msgs { if m["command"] == "PRIVMSG" && m["from"] == "alice_msg" { found = true break } } if !found { t.Fatalf("bob didn't receive alice's message: %v", msgs) } }) t.Run("missing body", func(t *testing.T) { resp, _ := ts.sendCommand(aliceToken, map[string]any{ "command": "PRIVMSG", "to": "#chat", }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("missing to", func(t *testing.T) { resp, _ := ts.sendCommand(aliceToken, map[string]any{ "command": "PRIVMSG", "body": []string{"hello"}, }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestDM(t *testing.T) { ts := newTestServer(t) _, aliceToken := ts.createSession("alice_dm") _, bobToken := ts.createSession("bob_dm") t.Run("send DM", func(t *testing.T) { resp, result := ts.sendCommand(aliceToken, map[string]any{ "command": "PRIVMSG", "to": "bob_dm", "body": []string{"hey bob"}, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d: %v", resp.StatusCode, result) } }) t.Run("bob receives DM", func(t *testing.T) { msgs, _ := ts.pollMessages(bobToken, 0, 0) found := false for _, m := range msgs { if m["command"] == "PRIVMSG" && m["from"] == "alice_dm" { found = true } } if !found { t.Fatal("bob didn't receive DM") } }) t.Run("alice gets echo", func(t *testing.T) { msgs, _ := ts.pollMessages(aliceToken, 0, 0) found := false for _, m := range msgs { if m["command"] == "PRIVMSG" && m["from"] == "alice_dm" && m["to"] == "bob_dm" { found = true } } if !found { t.Fatal("alice didn't get DM echo") } }) t.Run("DM to nonexistent user", func(t *testing.T) { resp, _ := ts.sendCommand(aliceToken, map[string]any{ "command": "PRIVMSG", "to": "nobody", "body": []string{"hello?"}, }) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } }) } func TestNick(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("nick_test") t.Run("change nick", func(t *testing.T) { resp, result := ts.sendCommand(token, map[string]any{ "command": "NICK", "body": []string{"newnick"}, }) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %v", resp.StatusCode, result) } if result["nick"] != "newnick" { t.Fatalf("expected newnick, got %v", result["nick"]) } }) t.Run("nick same as current", func(t *testing.T) { resp, _ := ts.sendCommand(token, map[string]any{ "command": "NICK", "body": []string{"newnick"}, }) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) t.Run("nick collision", func(t *testing.T) { ts.createSession("taken_nick") resp, _ := ts.sendCommand(token, map[string]any{ "command": "NICK", "body": []string{"taken_nick"}, }) if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } }) t.Run("invalid nick", func(t *testing.T) { resp, _ := ts.sendCommand(token, map[string]any{ "command": "NICK", "body": []string{"bad nick!"}, }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("empty body", func(t *testing.T) { resp, _ := ts.sendCommand(token, map[string]any{ "command": "NICK", }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestTopic(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("topic_user") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#topictest"}) t.Run("set topic", func(t *testing.T) { resp, result := ts.sendCommand(token, map[string]any{ "command": "TOPIC", "to": "#topictest", "body": []string{"Hello World Topic"}, }) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %v", resp.StatusCode, result) } if result["topic"] != "Hello World Topic" { t.Fatalf("expected topic, got %v", result["topic"]) } }) t.Run("missing to", func(t *testing.T) { resp, _ := ts.sendCommand(token, map[string]any{ "command": "TOPIC", "body": []string{"topic"}, }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("missing body", func(t *testing.T) { resp, _ := ts.sendCommand(token, map[string]any{ "command": "TOPIC", "to": "#topictest", }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestPing(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("ping_user") resp, result := ts.sendCommand(token, map[string]any{"command": "PING"}) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if result["command"] != "PONG" { t.Fatalf("expected PONG, got %v", result["command"]) } } func TestQuit(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("quitter") _, observerToken := ts.createSession("observer") // Both join a channel ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#quitchan"}) ts.sendCommand(observerToken, map[string]any{"command": "JOIN", "to": "#quitchan"}) // Drain messages _, lastID := ts.pollMessages(observerToken, 0, 0) // Quit resp, result := ts.sendCommand(token, map[string]any{"command": "QUIT"}) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d: %v", resp.StatusCode, result) } // Observer should get QUIT message msgs, _ := ts.pollMessages(observerToken, lastID, 0) found := false for _, m := range msgs { if m["command"] == "QUIT" && m["from"] == "quitter" { found = true } } if !found { t.Fatalf("observer didn't get QUIT: %v", msgs) } // Token should be invalid now resp2, _ := ts.getJSON(token, "/api/v1/state") if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401 after quit, got %d", resp2.StatusCode) } } func TestUnknownCommand(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("cmdtest") resp, result := ts.sendCommand(token, map[string]any{"command": "BOGUS"}) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %v", resp.StatusCode, result) } } func TestEmptyCommand(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("emptycmd") resp, _ := ts.sendCommand(token, map[string]any{"command": ""}) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestHistory(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("historian") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#history"}) // Send some messages for i := 0; i < 5; i++ { ts.sendCommand(token, map[string]any{ "command": "PRIVMSG", "to": "#history", "body": []string{"msg " + string(rune('A'+i))}, }) } req, _ := http.NewRequest("GET", ts.url("/api/v1/history?target=%23history&limit=3"), nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var msgs []map[string]any json.NewDecoder(resp.Body).Decode(&msgs) if len(msgs) != 3 { t.Fatalf("expected 3 messages, got %d", len(msgs)) } } func TestChannelList(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("lister") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#listchan"}) req, _ := http.NewRequest("GET", ts.url("/api/v1/channels"), nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var channels []map[string]any json.NewDecoder(resp.Body).Decode(&channels) found := false for _, ch := range channels { if ch["name"] == "#listchan" { found = true } } if !found { t.Fatal("channel not in list") } } func TestChannelMembers(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("membertest") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#members"}) req, _ := http.NewRequest("GET", ts.url("/api/v1/channels/members/members"), nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestLongPoll(t *testing.T) { ts := newTestServer(t) _, aliceToken := ts.createSession("lp_alice") _, bobToken := ts.createSession("lp_bob") ts.sendCommand(aliceToken, map[string]any{"command": "JOIN", "to": "#longpoll"}) ts.sendCommand(bobToken, map[string]any{"command": "JOIN", "to": "#longpoll"}) // Drain existing messages _, lastID := ts.pollMessages(bobToken, 0, 0) // Start long-poll in goroutine var wg sync.WaitGroup var pollMsgs []map[string]any wg.Add(1) go func() { defer wg.Done() url := fmt.Sprintf("%s/api/v1/messages?timeout=5&after=%d", ts.srv.URL, lastID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+bobToken) resp, err := http.DefaultClient.Do(req) if err != nil { return } defer resp.Body.Close() var result struct { Messages []map[string]any `json:"messages"` } json.NewDecoder(resp.Body).Decode(&result) pollMsgs = result.Messages }() // Give the long-poll a moment to start time.Sleep(200 * time.Millisecond) // Send a message ts.sendCommand(aliceToken, map[string]any{ "command": "PRIVMSG", "to": "#longpoll", "body": []string{"wake up!"}, }) wg.Wait() found := false for _, m := range pollMsgs { if m["command"] == "PRIVMSG" && m["from"] == "lp_alice" { found = true } } if !found { t.Fatalf("long-poll didn't receive message: %v", pollMsgs) } } func TestLongPollTimeout(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("lp_timeout") start := time.Now() req, _ := http.NewRequest("GET", ts.url("/api/v1/messages?timeout=1"), nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() elapsed := time.Since(start) if elapsed < 900*time.Millisecond { t.Fatalf("long-poll returned too fast: %v", elapsed) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestEphemeralChannelCleanup(t *testing.T) { ts := newTestServer(t) _, token := ts.createSession("ephemeral") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#ephemeral"}) ts.sendCommand(token, map[string]any{"command": "PART", "to": "#ephemeral"}) // Channel should be gone req, _ := http.NewRequest("GET", ts.url("/api/v1/channels"), nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var channels []map[string]any json.NewDecoder(resp.Body).Decode(&channels) for _, ch := range channels { if ch["name"] == "#ephemeral" { t.Fatal("ephemeral channel should have been cleaned up") } } } func TestConcurrentSessions(t *testing.T) { ts := newTestServer(t) var wg sync.WaitGroup errors := make(chan error, 20) for i := 0; i < 20; i++ { wg.Add(1) go func(i int) { defer wg.Done() nick := "concurrent_" + string(rune('a'+i)) body, _ := json.Marshal(map[string]string{"nick": nick}) resp, err := http.Post(ts.url("/api/v1/session"), "application/json", bytes.NewReader(body)) if err != nil { errors <- err return } resp.Body.Close() if resp.StatusCode != http.StatusCreated { errors <- err } }(i) } wg.Wait() close(errors) for err := range errors { if err != nil { t.Fatalf("concurrent session creation error: %v", err) } } } func TestServerInfo(t *testing.T) { ts := newTestServer(t) resp, err := http.Get(ts.url("/api/v1/server")) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestHealthcheck(t *testing.T) { ts := newTestServer(t) resp, err := http.Get(ts.url("/.well-known/healthcheck.json")) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var result map[string]any json.NewDecoder(resp.Body).Decode(&result) if result["status"] != "ok" { t.Fatalf("expected ok status, got %v", result["status"]) } } func TestNickBroadcastToChannels(t *testing.T) { ts := newTestServer(t) _, aliceToken := ts.createSession("nick_a") _, bobToken := ts.createSession("nick_b") ts.sendCommand(aliceToken, map[string]any{"command": "JOIN", "to": "#nicktest"}) ts.sendCommand(bobToken, map[string]any{"command": "JOIN", "to": "#nicktest"}) // Drain _, lastID := ts.pollMessages(bobToken, 0, 0) // Alice changes nick ts.sendCommand(aliceToken, map[string]any{"command": "NICK", "body": []string{"nick_a_new"}}) // Bob should see it msgs, _ := ts.pollMessages(bobToken, lastID, 0) found := false for _, m := range msgs { if m["command"] == "NICK" && m["from"] == "nick_a" { found = true } } if !found { t.Fatalf("bob didn't get nick change: %v", msgs) } } // Broker tests are in internal/broker/broker_test.go