package handlers_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "path/filepath" "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" ) const cmdPrivmsg = "PRIVMSG" var viperMu sync.Mutex //nolint:gochecknoglobals // serializes viper access in parallel tests // testServer wraps a test HTTP server with helper methods. type testServer struct { srv *httptest.Server t *testing.T fxApp *fxtest.App } func testGlobals() *globals.Globals { return &globals.Globals{ Appname: "chat-test", Version: "test", } } func testConfigFactory( dbURL string, ) func(fx.Lifecycle, *globals.Globals, *logger.Logger) (*config.Config, error) { return func( lc fx.Lifecycle, g *globals.Globals, l *logger.Logger, ) (*config.Config, error) { viperMu.Lock() c, err := config.New(lc, config.Params{ Globals: g, Logger: l, }) viperMu.Unlock() if err != nil { return nil, err } c.DBURL = dbURL return c, nil } } func testDB( lc fx.Lifecycle, l *logger.Logger, c *config.Config, ) (*db.Database, error) { return db.New(lc, db.Params{ Logger: l, Config: c, }) } func testHealthcheck( 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 testMiddleware( 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 testHandlers( 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 testServer2( 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, }) } func newTestServer(t *testing.T) *testServer { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") dbURL := "file:" + dbPath + "?_journal_mode=WAL&_busy_timeout=5000" var s *server.Server app := fxtest.New(t, fx.Provide( testGlobals, logger.New, testConfigFactory(dbURL), testDB, testHealthcheck, testMiddleware, testHandlers, testServer2, ), fx.Populate(&s), ) app.RequireStart() 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 newReqWithCtx( method, url string, body io.Reader, ) (*http.Request, error) { return http.NewRequestWithContext( context.Background(), method, url, body, ) } func (ts *testServer) doReq( method, url string, body io.Reader, ) (*http.Response, error) { ts.t.Helper() req, err := newReqWithCtx(method, url, body) if err != nil { return nil, fmt.Errorf("new request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } return http.DefaultClient.Do(req) //nolint:gosec // test helper with test server URL } func (ts *testServer) doReqAuth( method, url, token string, body io.Reader, ) (*http.Response, error) { ts.t.Helper() req, err := newReqWithCtx(method, url, body) if err != nil { return nil, fmt.Errorf("new request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return http.DefaultClient.Do(req) //nolint:gosec // test helper with test server URL } func (ts *testServer) createSession(nick string) string { ts.t.Helper() body, err := json.Marshal(map[string]string{"nick": nick}) if err != nil { ts.t.Fatalf("marshal session: %v", err) } resp, err := ts.doReq( http.MethodPost, ts.url("/api/v1/session"), bytes.NewReader(body), ) if err != nil { ts.t.Fatalf("create session: %v", err) } defer func() { _ = 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"` } err = json.NewDecoder(resp.Body).Decode(&result) if err != nil { ts.t.Fatalf("decode session: %v", err) } return result.Token } func (ts *testServer) sendCommand( token string, cmd map[string]any, ) (int, map[string]any) { ts.t.Helper() body, err := json.Marshal(cmd) if err != nil { ts.t.Fatalf("marshal command: %v", err) } resp, err := ts.doReqAuth( http.MethodPost, ts.url("/api/v1/messages"), token, bytes.NewReader(body), ) if err != nil { ts.t.Fatalf("send command: %v", err) } defer func() { _ = resp.Body.Close() }() var result map[string]any _ = json.NewDecoder(resp.Body).Decode(&result) return resp.StatusCode, result } func (ts *testServer) getJSON( token, path string, //nolint:unparam ) (int, map[string]any) { ts.t.Helper() resp, err := ts.doReqAuth(http.MethodGet, ts.url(path), token, nil) if err != nil { ts.t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() var result map[string]any _ = json.NewDecoder(resp.Body).Decode(&result) return resp.StatusCode, result } func (ts *testServer) pollMessages( token string, afterID int64, ) ([]map[string]any, int64) { ts.t.Helper() url := fmt.Sprintf( "%s/api/v1/messages?timeout=0&after=%d", ts.srv.URL, afterID, ) resp, err := ts.doReqAuth(http.MethodGet, url, token, nil) if err != nil { ts.t.Fatalf("poll: %v", err) } defer func() { _ = resp.Body.Close() }() var result struct { Messages []map[string]any `json:"messages"` LastID json.Number `json:"last_id"` //nolint:tagliatelle } err = json.NewDecoder(resp.Body).Decode(&result) if err != nil { ts.t.Fatalf("decode poll: %v", err) } lastID, _ := result.LastID.Int64() return result.Messages, lastID } func postSessionExpect( t *testing.T, ts *testServer, nick string, wantStatus int, ) { t.Helper() body, err := json.Marshal(map[string]string{"nick": nick}) if err != nil { t.Fatalf("marshal: %v", err) } resp, err := ts.doReq( http.MethodPost, ts.url("/api/v1/session"), bytes.NewReader(body), ) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != wantStatus { t.Fatalf("expected %d, got %d", wantStatus, resp.StatusCode) } } // --- Tests --- func TestCreateSession(t *testing.T) { t.Parallel() ts := newTestServer(t) t.Run("valid nick", func(t *testing.T) { t.Parallel() token := ts.createSession("alice") if token == "" { t.Fatal("expected token") } }) t.Run("duplicate nick", func(t *testing.T) { t.Parallel() ts2 := newTestServer(t) ts2.createSession("dupnick") postSessionExpect(t, ts2, "dupnick", http.StatusConflict) }) t.Run("empty nick", func(t *testing.T) { t.Parallel() postSessionExpect(t, ts, "", http.StatusBadRequest) }) t.Run("invalid nick chars", func(t *testing.T) { t.Parallel() postSessionExpect(t, ts, "hello world", http.StatusBadRequest) }) t.Run("nick starting with number", func(t *testing.T) { t.Parallel() postSessionExpect(t, ts, "123abc", http.StatusBadRequest) }) t.Run("malformed json", func(t *testing.T) { t.Parallel() resp, err := ts.doReq( http.MethodPost, ts.url("/api/v1/session"), strings.NewReader("{bad"), ) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestAuth(t *testing.T) { t.Parallel() ts := newTestServer(t) t.Run("no auth header", func(t *testing.T) { t.Parallel() status, _ := ts.getJSON("", "/api/v1/state") if status != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", status) } }) t.Run("bad token", func(t *testing.T) { t.Parallel() status, _ := ts.getJSON("invalid-token-12345", "/api/v1/state") if status != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", status) } }) t.Run("valid token", func(t *testing.T) { t.Parallel() token := ts.createSession("authtest") status, result := ts.getJSON(token, "/api/v1/state") if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) } if result["nick"] != "authtest" { t.Fatalf("expected nick authtest, got %v", result["nick"]) } }) } func TestJoinAndPart(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("bob") t.Run("join channel", func(t *testing.T) { t.Parallel() status, result := ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#test"}) if status != http.StatusOK { t.Fatalf("expected 200, got %d: %v", status, result) } if result["channel"] != "#test" { t.Fatalf("expected #test, got %v", result["channel"]) } }) t.Run("join without hash", func(t *testing.T) { t.Parallel() status, result := ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "other"}) if status != http.StatusOK { t.Fatalf("expected 200, got %d: %v", status, result) } if result["channel"] != "#other" { t.Fatalf("expected #other, got %v", result["channel"]) } }) t.Run("part channel", func(t *testing.T) { t.Parallel() ts2 := newTestServer(t) tok := ts2.createSession("partuser") ts2.sendCommand(tok, map[string]any{"command": "JOIN", "to": "#partchan"}) status, result := ts2.sendCommand(tok, map[string]any{"command": "PART", "to": "#partchan"}) if status != http.StatusOK { t.Fatalf("expected 200, got %d: %v", status, result) } if result["channel"] != "#partchan" { t.Fatalf("expected #partchan, got %v", result["channel"]) } }) t.Run("join missing to", func(t *testing.T) { t.Parallel() status, _ := ts.sendCommand(token, map[string]any{"command": "JOIN"}) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } }) } func TestPrivmsgChannel(t *testing.T) { t.Parallel() ts := newTestServer(t) aliceToken := ts.createSession("alice_msg") bobToken := ts.createSession("bob_msg") ts.sendCommand(aliceToken, map[string]any{"command": "JOIN", "to": "#chat"}) ts.sendCommand(bobToken, map[string]any{"command": "JOIN", "to": "#chat"}) _, _ = ts.pollMessages(aliceToken, 0) _, bobLastID := ts.pollMessages(bobToken, 0) status, result := ts.sendCommand(aliceToken, map[string]any{ "command": cmdPrivmsg, "to": "#chat", "body": []string{"hello world"}, }) if status != http.StatusCreated { t.Fatalf("expected 201, got %d: %v", status, result) } if result["id"] == nil || result["id"] == "" { t.Fatal("expected message id") } msgs, _ := ts.pollMessages(bobToken, bobLastID) found := false for _, m := range msgs { if m["command"] == cmdPrivmsg && m["from"] == "alice_msg" { found = true break } } if !found { t.Fatalf("bob didn't receive alice's message: %v", msgs) } } func TestPrivmsgErrors(t *testing.T) { t.Parallel() ts := newTestServer(t) aliceToken := ts.createSession("alice_msg2") ts.sendCommand(aliceToken, map[string]any{"command": "JOIN", "to": "#chat2"}) t.Run("missing body", func(t *testing.T) { t.Parallel() status, _ := ts.sendCommand(aliceToken, map[string]any{ "command": cmdPrivmsg, "to": "#chat2", }) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } }) t.Run("missing to", func(t *testing.T) { t.Parallel() status, _ := ts.sendCommand(aliceToken, map[string]any{ "command": cmdPrivmsg, "body": []string{"hello"}, }) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } }) } func TestDMSend(t *testing.T) { t.Parallel() ts := newTestServer(t) aliceToken := ts.createSession("alice_dm") bobToken := ts.createSession("bob_dm") status, result := ts.sendCommand(aliceToken, map[string]any{ "command": cmdPrivmsg, "to": "bob_dm", "body": []string{"hey bob"}, }) if status != http.StatusCreated { t.Fatalf("expected 201, got %d: %v", status, result) } msgs, _ := ts.pollMessages(bobToken, 0) found := false for _, m := range msgs { if m["command"] == cmdPrivmsg && m["from"] == "alice_dm" { found = true } } if !found { t.Fatal("bob didn't receive DM") } } func TestDMEcho(t *testing.T) { t.Parallel() ts := newTestServer(t) aliceToken := ts.createSession("alice_echo") ts.createSession("bob_echo") ts.sendCommand(aliceToken, map[string]any{ "command": cmdPrivmsg, "to": "bob_echo", "body": []string{"hey bob"}, }) msgs, _ := ts.pollMessages(aliceToken, 0) found := false for _, m := range msgs { if m["command"] == cmdPrivmsg && m["from"] == "alice_echo" && m["to"] == "bob_echo" { found = true } } if !found { t.Fatal("alice didn't get DM echo") } } func TestDMNonexistent(t *testing.T) { t.Parallel() ts := newTestServer(t) aliceToken := ts.createSession("alice_noone") status, _ := ts.sendCommand(aliceToken, map[string]any{ "command": cmdPrivmsg, "to": "nobody", "body": []string{"hello?"}, }) if status != http.StatusNotFound { t.Fatalf("expected 404, got %d", status) } } func TestNickChange(t *testing.T) { t.Parallel() ts := newTestServer(t) tok := ts.createSession("nick_change") status, result := ts.sendCommand(tok, map[string]any{ "command": "NICK", "body": []string{"newnick"}, }) if status != http.StatusOK { t.Fatalf("expected 200, got %d: %v", status, result) } if result["nick"] != "newnick" { t.Fatalf("expected newnick, got %v", result["nick"]) } } func TestNickSameAsCurrent(t *testing.T) { t.Parallel() ts := newTestServer(t) tok := ts.createSession("samenick") status, _ := ts.sendCommand(tok, map[string]any{ "command": "NICK", "body": []string{"samenick"}, }) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) } } func TestNickErrors(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("nick_test") t.Run("collision", func(t *testing.T) { t.Parallel() ts.createSession("taken_nick") status, _ := ts.sendCommand(token, map[string]any{ "command": "NICK", "body": []string{"taken_nick"}, }) if status != http.StatusConflict { t.Fatalf("expected 409, got %d", status) } }) t.Run("invalid", func(t *testing.T) { t.Parallel() status, _ := ts.sendCommand(token, map[string]any{ "command": "NICK", "body": []string{"bad nick!"}, }) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } }) t.Run("empty body", func(t *testing.T) { t.Parallel() status, _ := ts.sendCommand(token, map[string]any{ "command": "NICK", }) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } }) } func TestTopic(t *testing.T) { t.Parallel() 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) { t.Parallel() status, result := ts.sendCommand(token, map[string]any{ "command": "TOPIC", "to": "#topictest", "body": []string{"Hello World Topic"}, }) if status != http.StatusOK { t.Fatalf("expected 200, got %d: %v", status, result) } if result["topic"] != "Hello World Topic" { t.Fatalf("expected topic, got %v", result["topic"]) } }) t.Run("missing to", func(t *testing.T) { t.Parallel() status, _ := ts.sendCommand(token, map[string]any{ "command": "TOPIC", "body": []string{"topic"}, }) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } }) t.Run("missing body", func(t *testing.T) { t.Parallel() status, _ := ts.sendCommand(token, map[string]any{ "command": "TOPIC", "to": "#topictest", }) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } }) } func TestPing(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("ping_user") status, result := ts.sendCommand(token, map[string]any{"command": "PING"}) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) } if result["command"] != "PONG" { t.Fatalf("expected PONG, got %v", result["command"]) } } func TestQuit(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("quitter") observerToken := ts.createSession("observer") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#quitchan"}) ts.sendCommand(observerToken, map[string]any{"command": "JOIN", "to": "#quitchan"}) _, lastID := ts.pollMessages(observerToken, 0) status, result := ts.sendCommand(token, map[string]any{"command": "QUIT"}) if status != http.StatusOK { t.Fatalf("expected 200, got %d: %v", status, result) } msgs, _ := ts.pollMessages(observerToken, lastID) 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) } status2, _ := ts.getJSON(token, "/api/v1/state") if status2 != http.StatusUnauthorized { t.Fatalf("expected 401 after quit, got %d", status2) } } func TestUnknownCommand(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("cmdtest") status, result := ts.sendCommand(token, map[string]any{"command": "BOGUS"}) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %v", status, result) } } func TestEmptyCommand(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("emptycmd") status, _ := ts.sendCommand(token, map[string]any{"command": ""}) if status != http.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } } func TestHistory(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("historian") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#history"}) for range 5 { ts.sendCommand(token, map[string]any{ "command": cmdPrivmsg, "to": "#history", "body": []string{"test message"}, }) } resp, err := ts.doReqAuth( http.MethodGet, ts.url("/api/v1/history?target=%23history&limit=3"), token, nil, ) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var msgs []map[string]any err = json.NewDecoder(resp.Body).Decode(&msgs) if err != nil { t.Fatalf("decode history: %v", err) } if len(msgs) != 3 { t.Fatalf("expected 3 messages, got %d", len(msgs)) } } func TestChannelList(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("lister") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#listchan"}) resp, err := ts.doReqAuth( http.MethodGet, ts.url("/api/v1/channels"), token, nil, ) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var channels []map[string]any err = json.NewDecoder(resp.Body).Decode(&channels) if err != nil { t.Fatalf("decode channels: %v", err) } 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) { t.Parallel() ts := newTestServer(t) token := ts.createSession("membertest") ts.sendCommand(token, map[string]any{"command": "JOIN", "to": "#members"}) resp, err := ts.doReqAuth( http.MethodGet, ts.url("/api/v1/channels/members/members"), token, nil, ) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func asyncPoll( ts *testServer, token string, afterID int64, ) <-chan []map[string]any { ch := make(chan []map[string]any, 1) go func() { url := fmt.Sprintf( "%s/api/v1/messages?timeout=5&after=%d", ts.srv.URL, afterID, ) resp, err := ts.doReqAuth( http.MethodGet, url, token, nil, ) if err != nil { ch <- nil return } defer func() { _ = resp.Body.Close() }() var result struct { Messages []map[string]any `json:"messages"` } _ = json.NewDecoder(resp.Body).Decode(&result) ch <- result.Messages }() return ch } func TestLongPoll(t *testing.T) { t.Parallel() 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"}) _, lastID := ts.pollMessages(bobToken, 0) pollMsgs := asyncPoll(ts, bobToken, lastID) time.Sleep(200 * time.Millisecond) ts.sendCommand(aliceToken, map[string]any{ "command": cmdPrivmsg, "to": "#longpoll", "body": []string{"wake up!"}, }) msgs := <-pollMsgs found := false for _, m := range msgs { if m["command"] == cmdPrivmsg && m["from"] == "lp_alice" { found = true } } if !found { t.Fatalf("long-poll didn't receive message: %v", msgs) } } func TestLongPollTimeout(t *testing.T) { t.Parallel() ts := newTestServer(t) token := ts.createSession("lp_timeout") start := time.Now() resp, err := ts.doReqAuth( http.MethodGet, ts.url("/api/v1/messages?timeout=1"), token, nil, ) if err != nil { t.Fatal(err) } defer func() { _ = 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) { t.Parallel() 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"}) resp, err := ts.doReqAuth( http.MethodGet, ts.url("/api/v1/channels"), token, nil, ) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() var channels []map[string]any err = json.NewDecoder(resp.Body).Decode(&channels) if err != nil { t.Fatalf("decode channels: %v", err) } for _, ch := range channels { if ch["name"] == "#ephemeral" { t.Fatal("ephemeral channel should have been cleaned up") } } } func TestConcurrentSessions(t *testing.T) { t.Parallel() ts := newTestServer(t) var wg sync.WaitGroup errs := make(chan error, 20) for i := range 20 { wg.Add(1) go func(idx int) { defer wg.Done() nick := fmt.Sprintf("concurrent_%d", idx) body, err := json.Marshal(map[string]string{"nick": nick}) if err != nil { errs <- fmt.Errorf("marshal: %w", err) return } resp, err := ts.doReq( http.MethodPost, ts.url("/api/v1/session"), bytes.NewReader(body), ) if err != nil { errs <- err return } _ = resp.Body.Close() if resp.StatusCode != http.StatusCreated { errs <- fmt.Errorf( //nolint:err113 "status %d for %s", resp.StatusCode, nick, ) } }(i) } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("concurrent session creation error: %v", err) } } } func TestServerInfo(t *testing.T) { t.Parallel() ts := newTestServer(t) resp, err := ts.doReq(http.MethodGet, ts.url("/api/v1/server"), nil) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestHealthcheck(t *testing.T) { t.Parallel() ts := newTestServer(t) resp, err := ts.doReq(http.MethodGet, ts.url("/.well-known/healthcheck.json"), nil) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var result map[string]any err = json.NewDecoder(resp.Body).Decode(&result) if err != nil { t.Fatalf("decode healthcheck: %v", err) } if result["status"] != "ok" { t.Fatalf("expected ok status, got %v", result["status"]) } } func TestNickBroadcastToChannels(t *testing.T) { t.Parallel() 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"}) _, lastID := ts.pollMessages(bobToken, 0) ts.sendCommand(aliceToken, map[string]any{ "command": "NICK", "body": []string{"nick_a_new"}, }) msgs, _ := ts.pollMessages(bobToken, lastID) 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) } }