Files
chat/internal/handlers/api_test.go
clawbot f8f0b6afbb
All checks were successful
check / check (push) Successful in 58s
refactor: replace HTTP error codes with IRC numeric replies (#56)
## Summary

Refactors all IRC command handlers to respond with proper IRC numeric replies via the message queue instead of HTTP status codes.

HTTP error codes are now reserved exclusively for transport-level concerns:
- **401** — missing/invalid auth token
- **400** — malformed JSON, empty command
- **500** — server errors

## IRC Numerics Implemented

### Success replies (delivered via message queue on success):
- **001 RPL_WELCOME** — sent on session creation and login
- **331 RPL_NOTOPIC** — channel has no topic (on JOIN)
- **332 RPL_TOPIC** — channel topic (on JOIN, TOPIC set)
- **353 RPL_NAMREPLY** — channel member list (on JOIN)
- **366 RPL_ENDOFNAMES** — end of NAMES list (on JOIN)
- **375/372/376** — MOTD (already existed)

### Error replies (delivered via message queue instead of HTTP 4xx):
- **401 ERR_NOSUCHNICK** — DM target not found (was HTTP 404)
- **403 ERR_NOSUCHCHANNEL** — channel not found / invalid name (was HTTP 404)
- **421 ERR_UNKNOWNCOMMAND** — unrecognized command (was HTTP 400)
- **432 ERR_ERRONEUSNICKNAME** — invalid nick format (was HTTP 400)
- **433 ERR_NICKNAMEINUSE** — nick taken (was HTTP 409)
- **442 ERR_NOTONCHANNEL** — not a member of channel (was HTTP 403)
- **461 ERR_NEEDMOREPARAMS** — missing required fields (was HTTP 400)

## Database Changes
- Added `params` column to messages table for IRC-style parameters
- Added `Params` field to `IRCMessage` struct
- Updated `InsertMessage` to accept params

## Test Updates
- All existing tests updated to expect HTTP 200 + IRC numerics
- New tests: `TestWelcomeNumeric`, `TestJoinNumerics`

## Client Impact
- CLI and SPA already handle unknown numerics via default event handlers
- PRIVMSG/NOTICE success changed from HTTP 201 to HTTP 200

closes #54

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #56
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-09 22:21:30 +01:00

1985 lines
37 KiB
Go

// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package handlers_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
const (
commandKey = "command"
bodyKey = "body"
toKey = "to"
statusKey = "status"
privmsgCmd = "PRIVMSG"
joinCmd = "JOIN"
apiMessages = "/api/v1/messages"
apiSession = "/api/v1/session"
apiState = "/api/v1/state"
)
// testServer wraps a test HTTP server with helpers.
type testServer struct {
httpServer *httptest.Server
t *testing.T
fxApp *fxtest.App
}
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 srv *server.Server
app := fxtest.New(t,
fx.Provide(
newTestGlobals,
logger.New,
func(
lifecycle fx.Lifecycle,
globs *globals.Globals,
log *logger.Logger,
) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
}
cfg.DBURL = dbURL
cfg.Port = 0
return cfg, nil
},
newTestDB,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
newTestServerFx,
),
fx.Populate(&srv),
)
app.RequireStart()
time.Sleep(100 * time.Millisecond)
httpSrv := httptest.NewServer(srv)
t.Cleanup(func() {
httpSrv.Close()
app.RequireStop()
})
return &testServer{
httpServer: httpSrv,
t: t,
fxApp: app,
}
}
func newTestGlobals() *globals.Globals {
return &globals.Globals{
Appname: "neoirc-test",
Version: "test",
}
}
func newTestDB(
lifecycle fx.Lifecycle,
log *logger.Logger,
cfg *config.Config,
) (*db.Database, error) {
database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct
Logger: log, Config: cfg,
})
if err != nil {
return nil, fmt.Errorf("test db: %w", err)
}
return database, nil
}
func newTestHealthcheck(
lifecycle fx.Lifecycle,
globs *globals.Globals,
cfg *config.Config,
log *logger.Logger,
database *db.Database,
) (*healthcheck.Healthcheck, error) {
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
Globals: globs,
Config: cfg,
Logger: log,
Database: database,
})
if err != nil {
return nil, fmt.Errorf("test healthcheck: %w", err)
}
return hcheck, nil
}
func newTestMiddleware(
lifecycle fx.Lifecycle,
log *logger.Logger,
globs *globals.Globals,
cfg *config.Config,
) (*middleware.Middleware, error) {
mware, err := middleware.New(lifecycle, middleware.Params{ //nolint:exhaustruct
Logger: log,
Globals: globs,
Config: cfg,
})
if err != nil {
return nil, fmt.Errorf("test middleware: %w", err)
}
return mware, nil
}
func newTestHandlers(
lifecycle fx.Lifecycle,
log *logger.Logger,
globs *globals.Globals,
cfg *config.Config,
database *db.Database,
hcheck *healthcheck.Healthcheck,
) (*handlers.Handlers, error) {
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
Logger: log,
Globals: globs,
Config: cfg,
Database: database,
Healthcheck: hcheck,
})
if err != nil {
return nil, fmt.Errorf("test handlers: %w", err)
}
return hdlr, nil
}
func newTestServerFx(
lifecycle fx.Lifecycle,
log *logger.Logger,
globs *globals.Globals,
cfg *config.Config,
mware *middleware.Middleware,
hdlr *handlers.Handlers,
) (*server.Server, error) {
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
Logger: log,
Globals: globs,
Config: cfg,
Middleware: mware,
Handlers: hdlr,
})
if err != nil {
return nil, fmt.Errorf("test server: %w", err)
}
return srv, nil
}
func (tserver *testServer) url(path string) string {
return tserver.httpServer.URL + path
}
func doRequest(
t *testing.T,
method, url string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), method, url, body,
)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
if body != nil {
request.Header.Set(
"Content-Type", "application/json",
)
}
resp, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
return resp, nil
}
func doRequestAuth(
t *testing.T,
method, url, token string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), method, url, body,
)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
if body != nil {
request.Header.Set(
"Content-Type", "application/json",
)
}
if token != "" {
request.Header.Set(
"Authorization", "Bearer "+token,
)
}
resp, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
return resp, nil
}
func (tserver *testServer) createSession(
nick string,
) string {
tserver.t.Helper()
body, err := json.Marshal(
map[string]string{"nick": nick},
)
if err != nil {
tserver.t.Fatalf("marshal session: %v", err)
}
resp, err := doRequest(
tserver.t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("create session: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
tserver.t.Fatalf(
"create session: status %d: %s",
resp.StatusCode, respBody,
)
}
var result struct {
ID int64 `json:"id"`
Token string `json:"token"`
}
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
tserver.t.Fatalf("decode session: %v", decErr)
}
return result.Token
}
func (tserver *testServer) sendCommand(
token string, cmd map[string]any,
) (int, map[string]any) {
tserver.t.Helper()
body, err := json.Marshal(cmd)
if err != nil {
tserver.t.Fatalf("marshal command: %v", err)
}
resp, err := doRequestAuth(
tserver.t,
http.MethodPost,
tserver.url(apiMessages),
token,
bytes.NewReader(body),
)
if err != nil {
tserver.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 (tserver *testServer) getState(
token string,
) (int, map[string]any) {
tserver.t.Helper()
resp, err := doRequestAuth(
tserver.t,
http.MethodGet,
tserver.url(apiState),
token,
nil,
)
if err != nil {
tserver.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 (tserver *testServer) pollMessages(
token string, afterID int64,
) ([]map[string]any, int64) {
tserver.t.Helper()
pollURL := fmt.Sprintf(
"%s"+apiMessages+"?timeout=0&after=%d",
tserver.httpServer.URL, afterID,
)
resp, err := doRequestAuth(
tserver.t,
http.MethodGet,
pollURL,
token,
nil,
)
if err != nil {
tserver.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
}
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
tserver.t.Fatalf("decode poll: %v", decErr)
}
lastID, _ := result.LastID.Int64()
return result.Messages, lastID
}
func postSession(
t *testing.T,
tserver *testServer,
nick string,
) *http.Response {
t.Helper()
body, err := json.Marshal(
map[string]string{"nick": nick},
)
if err != nil {
t.Fatalf("marshal: %v", err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
return resp
}
func findMessage(
msgs []map[string]any,
command, from string,
) bool {
for _, msg := range msgs {
if msg[commandKey] == command &&
msg["from"] == from {
return true
}
}
return false
}
func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
for _, msg := range msgs {
if msg[commandKey] == numeric {
return true
}
}
return false
}
// --- Tests ---
func TestCreateSessionValid(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
if token == "" {
t.Fatal("expected token")
}
}
func TestWelcomeNumeric(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("welcomer")
msgs, _ := tserver.pollMessages(token, 0)
if !findNumeric(msgs, "001") {
t.Fatalf(
"expected RPL_WELCOME (001), got %v",
msgs,
)
}
}
func TestJoinNumerics(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("jnumtest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#numtest",
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "353") {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
if !findNumeric(msgs, "366") {
t.Fatalf(
"expected RPL_ENDOFNAMES (366), got %v",
msgs,
)
}
}
func TestCreateSessionDuplicate(t *testing.T) {
tserver := newTestServer(t)
tserver.createSession("alice")
resp := postSession(t, tserver, "alice")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp.StatusCode)
}
}
func TestCreateSessionEmpty(t *testing.T) {
tserver := newTestServer(t)
resp := postSession(t, tserver, "")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(
"expected 400, got %d", resp.StatusCode,
)
}
}
func TestCreateSessionInvalidChars(t *testing.T) {
tserver := newTestServer(t)
resp := postSession(t, tserver, "hello world")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(
"expected 400, got %d", resp.StatusCode,
)
}
}
func TestCreateSessionNumericStart(t *testing.T) {
tserver := newTestServer(t)
resp := postSession(t, tserver, "123abc")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(
"expected 400, got %d", resp.StatusCode,
)
}
}
func TestCreateSessionMalformed(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
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 TestAuthNoHeader(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState("")
if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status)
}
}
func TestAuthBadToken(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState(
"invalid-token-12345",
)
if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status)
}
}
func TestAuthValidToken(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("authtest")
status, result := tserver.getState(token)
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 TestJoinChannel(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner")
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: "#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"],
)
}
}
func TestJoinWithoutHash(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner2")
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: "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"],
)
}
}
func TestPartChannel(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("parter")
tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: "#test",
},
)
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "PART", toKey: "#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"],
)
}
}
func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner3")
// Drain initial MOTD/welcome numerics.
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: joinCmd},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestChannelMessage(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_msg")
bobToken := tserver.createSession("bob_msg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#test",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#test",
})
_, _ = tserver.pollMessages(aliceToken, 0)
_, bobLastID := tserver.pollMessages(bobToken, 0)
status, result := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "#test",
bodyKey: []string{"hello world"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id")
}
msgs, _ := tserver.pollMessages(
bobToken, bobLastID,
)
if !findMessage(msgs, privmsgCmd, "alice_msg") {
t.Fatalf(
"bob didn't receive alice's message: %v",
msgs,
)
}
}
func TestMessageMissingBody(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#test",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: "#test",
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("noto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
bodyKey: []string{"hello"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestNonMemberCannotSend(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_nosend")
bobToken := tserver.createSession("bob_nosend")
// Only bob joins the channel.
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#private",
})
_, lastID := tserver.pollMessages(aliceToken, 0)
// Alice tries to send without joining.
status, _ := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "#private",
bodyKey: []string{"sneaky"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
}
}
func TestDirectMessage(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_dm")
bobToken := tserver.createSession("bob_dm")
status, result := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "bob_dm",
bodyKey: []string{"hey bob"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
msgs, _ := tserver.pollMessages(bobToken, 0)
if !findMessage(msgs, privmsgCmd, "alice_dm") {
t.Fatal("bob didn't receive DM")
}
aliceMsgs, _ := tserver.pollMessages(aliceToken, 0)
found := false
for _, msg := range aliceMsgs {
if msg[commandKey] == privmsgCmd &&
msg["from"] == "alice_dm" &&
msg[toKey] == "bob_dm" {
found = true
}
}
if !found {
t.Fatal("alice didn't get DM echo")
}
}
func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dmsender")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "nobody",
bodyKey: []string{"hello?"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
}
}
func TestNickChange(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nick_test")
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "NICK",
bodyKey: []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) {
tserver := newTestServer(t)
token := tserver.createSession("same_nick")
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
bodyKey: []string{"same_nick"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
}
func TestNickCollision(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nickuser")
tserver.createSession("taken_nick")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
bodyKey: []string{"taken_nick"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "433") {
t.Fatalf(
"expected ERR_NICKNAMEINUSE (433), got %v",
msgs,
)
}
}
func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nickval")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
bodyKey: []string{"bad nick!"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "432") {
t.Fatalf(
"expected ERR_ERRONEUSNICKNAME (432), got %v",
msgs,
)
}
}
func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nicknobody")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "NICK"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestTopic(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("topic_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#topictest",
})
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "TOPIC",
toKey: "#topictest",
bodyKey: []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"],
)
}
}
func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("topicnoto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC",
bodyKey: []string{"topic"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestTopicMissingBody(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("topicnobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#topictest",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", toKey: "#topictest",
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestPing(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("ping_user")
status, result := tserver.sendCommand(
token, map[string]any{commandKey: "PING"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if result[commandKey] != "PONG" {
t.Fatalf(
"expected PONG, got %v",
result[commandKey],
)
}
}
func TestQuit(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("quitter")
observerToken := tserver.createSession("observer")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#quitchan",
})
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd, toKey: "#quitchan",
})
_, lastID := tserver.pollMessages(observerToken, 0)
status, result := tserver.sendCommand(
token, map[string]any{commandKey: "QUIT"},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
msgs, _ := tserver.pollMessages(
observerToken, lastID,
)
if !findMessage(msgs, "QUIT", "quitter") {
t.Fatalf(
"observer didn't get QUIT: %v", msgs,
)
}
afterStatus, _ := tserver.getState(token)
if afterStatus != http.StatusUnauthorized {
t.Fatalf(
"expected 401 after quit, got %d",
afterStatus,
)
}
}
func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("cmdtest")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "BOGUS"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "421") {
t.Fatalf(
"expected ERR_UNKNOWNCOMMAND (421), got %v",
msgs,
)
}
}
func TestEmptyCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("emptycmd")
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: ""},
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
func TestHistory(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("historian")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#history",
})
for range 5 {
tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "#history",
bodyKey: []string{"test message"},
})
}
histURL := tserver.url(
"/api/v1/history?target=%23history&limit=3",
)
resp, err := doRequestAuth(
t, http.MethodGet, histURL, 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
decErr := json.NewDecoder(resp.Body).Decode(&msgs)
if decErr != nil {
t.Fatalf("decode history: %v", decErr)
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
}
func TestHistoryNonMember(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_hist")
bobToken := tserver.createSession("bob_hist")
// Alice creates and joins a channel.
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#secret",
})
tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd,
toKey: "#secret",
bodyKey: []string{"secret stuff"},
})
// Bob tries to read history without joining.
histURL := tserver.url(
"/api/v1/history?target=%23secret",
)
resp, err := doRequestAuth(
t, http.MethodGet, histURL, bobToken, nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf(
"expected 403, got %d", resp.StatusCode,
)
}
}
func TestChannelList(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("lister")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#listchan",
})
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.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
decErr := json.NewDecoder(resp.Body).Decode(
&channels,
)
if decErr != nil {
t.Fatalf("decode channels: %v", decErr)
}
found := false
for _, channel := range channels {
if channel["name"] == "#listchan" {
found = true
}
}
if !found {
t.Fatal("channel not in list")
}
}
func TestChannelMembers(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("membertest")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#members",
})
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.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 TestLongPoll(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("lp_alice")
bobToken := tserver.createSession("lp_bob")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#longpoll",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#longpoll",
})
_, lastID := tserver.pollMessages(bobToken, 0)
var waitGroup sync.WaitGroup
var pollMsgs []map[string]any
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
pollURL := fmt.Sprintf(
"%s"+apiMessages+"?timeout=5&after=%d",
tserver.httpServer.URL, lastID,
)
resp, err := doRequestAuth(
t, http.MethodGet,
pollURL, bobToken, nil,
)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
var result struct {
Messages []map[string]any `json:"messages"`
}
_ = json.NewDecoder(resp.Body).Decode(&result)
pollMsgs = result.Messages
}()
time.Sleep(200 * time.Millisecond)
tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd,
toKey: "#longpoll",
bodyKey: []string{"wake up!"},
})
waitGroup.Wait()
if !findMessage(pollMsgs, privmsgCmd, "lp_alice") {
t.Fatalf(
"long-poll didn't receive message: %v",
pollMsgs,
)
}
}
func TestLongPollTimeout(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("lp_timeout")
// Drain initial welcome/MOTD numerics.
_, lastID := tserver.pollMessages(token, 0)
start := time.Now()
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
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) {
tserver := newTestServer(t)
token := tserver.createSession("ephemeral")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#ephemeral",
})
tserver.sendCommand(token, map[string]any{
commandKey: "PART", toKey: "#ephemeral",
})
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url("/api/v1/channels"),
token,
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var channels []map[string]any
decErr := json.NewDecoder(resp.Body).Decode(
&channels,
)
if decErr != nil {
t.Fatalf("decode channels: %v", decErr)
}
for _, channel := range channels {
if channel["name"] == "#ephemeral" {
t.Fatal(
"ephemeral channel should be cleaned up",
)
}
}
}
func TestConcurrentSessions(t *testing.T) {
tserver := newTestServer(t)
var waitGroup sync.WaitGroup
const concurrency = 20
errs := make(chan error, concurrency)
for idx := range concurrency {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
nick := fmt.Sprintf("conc_%d", index)
body, err := json.Marshal(
map[string]string{"nick": nick},
)
if err != nil {
errs <- fmt.Errorf(
"marshal: %w", err,
)
return
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
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,
)
}
}(idx)
}
waitGroup.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent error: %v", err)
}
}
}
func TestServerInfo(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.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) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.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
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
if result[statusKey] != "ok" {
t.Fatalf(
"expected ok status, got %v",
result[statusKey],
)
}
}
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "reguser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 201, got %d: %s",
resp.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
if result["nick"] != "reguser" {
t.Fatalf(
"expected reguser, got %v", result["nick"],
)
}
}
func TestRegisterDuplicate(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "dupuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp2.StatusCode)
}
}
func postJSONExpectStatus(
t *testing.T,
tserver *testServer,
path string,
payload map[string]string,
expectedStatus int,
) {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(path),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != expectedStatus {
t.Fatalf(
"expected %d, got %d",
expectedStatus, resp.StatusCode,
)
}
}
func TestRegisterShortPassword(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "shortpw", "password": "short",
},
http.StatusBadRequest,
)
}
func TestRegisterInvalidNick(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "bad nick!",
"password": "password123",
},
http.StatusBadRequest,
)
}
func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Register first.
regBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// Login.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp2.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp2.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
// Verify token works.
token, ok := result["token"].(string)
if !ok {
t.Fatal("token not a string")
}
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state["nick"] != "loginuser" {
t.Fatalf(
"expected loginuser, got %v",
state["nick"],
)
}
}
func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
regBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "correctpass1",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
loginBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "wrongpass12",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf(
"expected 401, got %d", resp2.StatusCode,
)
}
}
func TestLoginNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "ghostuser",
"password": "password123",
},
http.StatusUnauthorized,
)
}
func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t)
// Verify anonymous session creation still works.
token := tserver.createSession("anon_user")
if token == "" {
t.Fatal("expected token for anonymous session")
}
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state["nick"] != "anon_user" {
t.Fatalf(
"expected anon_user, got %v",
state["nick"],
)
}
}
func TestNickBroadcastToChannels(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("nick_a")
bobToken := tserver.createSession("nick_b")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#nicktest",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#nicktest",
})
_, lastID := tserver.pollMessages(bobToken, 0)
tserver.sendCommand(aliceToken, map[string]any{
commandKey: "NICK",
bodyKey: []string{"nick_a_new"},
})
msgs, _ := tserver.pollMessages(bobToken, lastID)
if !findMessage(msgs, "NICK", "nick_a") {
t.Fatalf(
"bob didn't get nick change: %v", msgs,
)
}
}