From 071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 16 Feb 2026 20:05:39 +0900 Subject: test(backend): add unit tests for auth, config, ratelimit, game, and api Cover previously untested logic: session ID generation/hashing, password authentication, IP rate limiting, game state helpers, handler endpoints, task enqueue/result processing, and config loading. Co-Authored-By: Claude Opus 4.6 --- backend/api/handler_test.go | 245 ++++++++++++++++++++++++++++++++++- backend/auth/auth_test.go | 125 ++++++++++++++++++ backend/auth/session_test.go | 47 +++++++ backend/config/config_test.go | 139 ++++++++++++++++++++ backend/game/hub_test.go | 249 +++++++++++++++++++++++++++++++++++- backend/justfile | 2 + backend/ratelimit/ratelimit_test.go | 123 ++++++++++++++++++ 7 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 backend/auth/auth_test.go create mode 100644 backend/auth/session_test.go create mode 100644 backend/config/config_test.go create mode 100644 backend/ratelimit/ratelimit_test.go (limited to 'backend') diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index 47ad92c..a54f995 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -15,7 +16,8 @@ import ( // mockQuerier implements db.Querier for testing. type mockQuerier struct { db.Querier - getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { @@ -25,6 +27,13 @@ func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGame return db.GetGameByIDRow{}, pgx.ErrNoRows } +func (m *mockQuerier) ListMainPlayers(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) { + if m.listMainPlayersFunc != nil { + return m.listMainPlayersFunc(ctx, gameIDs) + } + return nil, nil +} + // mockTxManager implements db.TxManager for testing. type mockTxManager struct{} @@ -110,6 +119,240 @@ func TestPostGamePlaySubmit_GameNotRunning(t *testing.T) { } } +func TestIsGameRunning(t *testing.T) { + now := time.Now() + tests := []struct { + name string + game db.GetGameByIDRow + want bool + }{ + { + name: "not started", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Valid: false}, + DurationSeconds: 300, + }, + want: false, + }, + { + name: "running", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: true, + }, + { + name: "finished", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isGameRunning(tt.game) + if got != tt.want { + t.Errorf("isGameRunning() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsGameFinished(t *testing.T) { + now := time.Now() + tests := []struct { + name string + game db.GetGameByIDRow + want bool + }{ + { + name: "not started", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Valid: false}, + DurationSeconds: 300, + }, + want: false, + }, + { + name: "still running", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: false, + }, + { + name: "finished", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isGameFinished(tt.game) + if got != tt.want { + t.Errorf("isGameFinished() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToNullable(t *testing.T) { + t.Run("nil value", func(t *testing.T) { + result := toNullable[string](nil) + if !result.IsNull() { + t.Error("expected null for nil input") + } + }) + t.Run("non-nil value", func(t *testing.T) { + s := "hello" + result := toNullable(&s) + if result.IsNull() { + t.Error("expected non-null for non-nil input") + } + v, err := result.Get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "hello" { + t.Errorf("expected 'hello', got %q", v) + } + }) +} + +func TestGetMe(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{ + UserID: 1, + Username: "testuser", + DisplayName: "Test User", + IsAdmin: false, + } + resp, err := h.GetMe(context.Background(), GetMeRequestObject{}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetMe200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + if okResp.User.UserID != 1 { + t.Errorf("expected user ID 1, got %d", okResp.User.UserID) + } + if okResp.User.Username != "testuser" { + t.Errorf("expected username 'testuser', got %q", okResp.User.Username) + } + if okResp.User.IsAdmin { + t.Error("expected non-admin user") + } +} + +func TestGetGame_NotFound(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 999}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(GetGame404JSONResponse); !ok { + t.Errorf("expected 404 response, got %T", resp) + } +} + +func TestGetGame_NonPublicAsNonAdmin(t *testing.T) { + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + IsPublic: false, + Language: "php", + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1, IsAdmin: false} + resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 1}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(GetGame404JSONResponse); !ok { + t.Errorf("expected 404 for non-public game as non-admin, got %T", resp) + } +} + +func TestGetGame_PublicGameSuccess(t *testing.T) { + now := time.Now() + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + IsPublic: true, + Language: "php", + DisplayName: "Test Game", + DurationSeconds: 300, + StartedAt: pgtype.Timestamp{Time: now, Valid: true}, + GameType: "golf", + ProblemID: 10, + Title: "Test Problem", + Description: "desc", + SampleCode: "