package api import ( "context" "errors" "testing" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "albatross-2026-backend/config" "albatross-2026-backend/db" ) // mockQuerier implements db.Querier for testing. type mockQuerier struct { db.Querier getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) listPublicGamesFunc func(ctx context.Context) ([]db.ListPublicGamesRow, error) deleteSessionFunc func(ctx context.Context, sessionID string) error getLatestStateFunc func(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) updateCodeFunc func(ctx context.Context, arg db.UpdateCodeParams) error getRankingFunc func(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) getLatestStatesFunc func(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error) listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { if m.getGameByIDFunc != nil { return m.getGameByIDFunc(ctx, gameID) } 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 } func (m *mockQuerier) ListPublicGames(ctx context.Context) ([]db.ListPublicGamesRow, error) { if m.listPublicGamesFunc != nil { return m.listPublicGamesFunc(ctx) } return nil, nil } func (m *mockQuerier) DeleteSession(ctx context.Context, sessionID string) error { if m.deleteSessionFunc != nil { return m.deleteSessionFunc(ctx, sessionID) } return nil } func (m *mockQuerier) GetLatestState(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) { if m.getLatestStateFunc != nil { return m.getLatestStateFunc(ctx, arg) } return db.GetLatestStateRow{}, pgx.ErrNoRows } func (m *mockQuerier) UpdateCode(ctx context.Context, arg db.UpdateCodeParams) error { if m.updateCodeFunc != nil { return m.updateCodeFunc(ctx, arg) } return nil } func (m *mockQuerier) GetRanking(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) { if m.getRankingFunc != nil { return m.getRankingFunc(ctx, gameID) } return nil, nil } func (m *mockQuerier) GetLatestStatesOfMainPlayers(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) { if m.getLatestStatesFunc != nil { return m.getLatestStatesFunc(ctx, gameID) } return nil, nil } func (m *mockQuerier) GetTournamentByID(ctx context.Context, tournamentID int32) (db.Tournament, error) { if m.getTournamentByIDFunc != nil { return m.getTournamentByIDFunc(ctx, tournamentID) } return db.Tournament{}, pgx.ErrNoRows } func (m *mockQuerier) ListTournamentEntries(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) { if m.listTournamentEntriesFunc != nil { return m.listTournamentEntriesFunc(ctx, tournamentID) } return nil, nil } func (m *mockQuerier) ListTournamentMatches(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) { if m.listTournamentMatchesFunc != nil { return m.listTournamentMatchesFunc(ctx, tournamentID) } return nil, nil } // mockTxManager implements db.TxManager for testing. type mockTxManager struct{} func (m *mockTxManager) RunInTx(_ context.Context, fn func(q db.Querier) error) error { return fn(&mockQuerier{}) } // mockGameHub implements GameHubInterface for testing. type mockGameHub struct { calcCodeSizeResult int enqueueErr error } func (m *mockGameHub) CalcCodeSize(_ string, _ string) int { return m.calcCodeSizeResult } func (m *mockGameHub) EnqueueTestTasks(_ context.Context, _, _, _ int, _, _ string) error { return m.enqueueErr } // mockAuthenticator implements AuthenticatorInterface for testing. type mockAuthenticator struct { loginResult int loginErr error } func (m *mockAuthenticator) Login(_ context.Context, _, _ string) (int, error) { return m.loginResult, m.loginErr } func TestPostGamePlaySubmit_GameNotFound(t *testing.T) { h := Handler{ q: &mockQuerier{}, txm: &mockTxManager{}, hub: &mockGameHub{}, auth: &mockAuthenticator{}, conf: &config.Config{}, } user := &db.User{UserID: 1} resp, err := h.PostGamePlaySubmit(context.Background(), PostGamePlaySubmitRequestObject{ GameID: 999, Body: &PostGamePlaySubmitJSONRequestBody{Code: "test"}, }, user) if err != nil { t.Fatalf("unexpected error: %v", err) } if _, ok := resp.(PostGamePlaySubmit404JSONResponse); !ok { t.Errorf("expected 404 response, got %T", resp) } } func TestPostGamePlaySubmit_GameNotRunning(t *testing.T) { h := Handler{ q: &mockQuerier{ getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { return db.GetGameByIDRow{ GameID: 1, Language: "php", StartedAt: pgtype.Timestamp{ Valid: false, }, }, nil }, }, txm: &mockTxManager{}, hub: &mockGameHub{calcCodeSizeResult: 10}, auth: &mockAuthenticator{}, conf: &config.Config{}, } user := &db.User{UserID: 1} resp, err := h.PostGamePlaySubmit(context.Background(), PostGamePlaySubmitRequestObject{ GameID: 1, Body: &PostGamePlaySubmitJSONRequestBody{Code: "= 4 { t.Errorf("Seed 1 should be in first half, but at position %d", seed1Pos) } if seed2Pos < 4 { t.Errorf("Seed 2 should be in second half, but at position %d", seed2Pos) } } func TestStandardBracketSeeds_AllSeedsPresent(t *testing.T) { for _, size := range []int{2, 4, 8, 16} { seeds := standardBracketSeeds(size) seen := make(map[int]bool) for _, s := range seeds { if s < 1 || s > size { t.Errorf("bracket_size=%d: seed %d out of range", size, s) } if seen[s] { t.Errorf("bracket_size=%d: duplicate seed %d", size, s) } seen[s] = true } if len(seen) != size { t.Errorf("bracket_size=%d: expected %d unique seeds, got %d", size, size, len(seen)) } } } func TestFindSeedByUserID(t *testing.T) { entries := []TournamentEntry{ {User: User{UserID: 10}, Seed: 1}, {User: User{UserID: 20}, Seed: 2}, {User: User{UserID: 30}, Seed: 3}, } if got := findSeedByUserID(entries, 10); got != 1 { t.Errorf("expected seed 1 for user 10, got %d", got) } if got := findSeedByUserID(entries, 20); got != 2 { t.Errorf("expected seed 2 for user 20, got %d", got) } if got := findSeedByUserID(entries, 999); got != 0 { t.Errorf("expected seed 0 for unknown user, got %d", got) } } func TestGetTournament_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.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 999}, user) if err != nil { t.Fatalf("unexpected error: %v", err) } if _, ok := resp.(GetTournament404JSONResponse); !ok { t.Errorf("expected 404 response, got %T", resp) } } func TestGetTournament_Success_NoEntries(t *testing.T) { h := Handler{ q: &mockQuerier{ getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { return db.Tournament{ TournamentID: 1, DisplayName: "Test Tournament", BracketSize: 4, NumRounds: 2, }, nil }, }, txm: &mockTxManager{}, hub: &mockGameHub{}, auth: &mockAuthenticator{}, conf: &config.Config{}, } user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) if err != nil { t.Fatalf("unexpected error: %v", err) } okResp, ok := resp.(GetTournament200JSONResponse) if !ok { t.Fatalf("expected 200 response, got %T", resp) } if okResp.Tournament.TournamentID != 1 { t.Errorf("expected tournament ID 1, got %d", okResp.Tournament.TournamentID) } if okResp.Tournament.DisplayName != "Test Tournament" { t.Errorf("expected display name 'Test Tournament', got %q", okResp.Tournament.DisplayName) } if okResp.Tournament.BracketSize != 4 { t.Errorf("expected bracket size 4, got %d", okResp.Tournament.BracketSize) } if len(okResp.Tournament.Entries) != 0 { t.Errorf("expected 0 entries, got %d", len(okResp.Tournament.Entries)) } } func TestGetTournament_WithEntriesAndMatches(t *testing.T) { gameID := int32(10) h := Handler{ q: &mockQuerier{ getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { return db.Tournament{ TournamentID: 1, DisplayName: "Test", BracketSize: 4, NumRounds: 2, }, nil }, listTournamentEntriesFunc: func(_ context.Context, _ int32) ([]db.ListTournamentEntriesRow, error) { return []db.ListTournamentEntriesRow{ {Seed: 1, UserID: 100, Username: "alice", DisplayName: "Alice", IsAdmin: false}, {Seed: 2, UserID: 200, Username: "bob", DisplayName: "Bob", IsAdmin: false}, {Seed: 3, UserID: 300, Username: "carol", DisplayName: "Carol", IsAdmin: false}, }, nil }, listTournamentMatchesFunc: func(_ context.Context, _ int32) ([]db.TournamentMatch, error) { return []db.TournamentMatch{ {TournamentMatchID: 1, TournamentID: 1, Round: 0, Position: 0, GameID: &gameID}, {TournamentMatchID: 2, TournamentID: 1, Round: 0, Position: 1, GameID: nil}, {TournamentMatchID: 3, TournamentID: 1, Round: 1, Position: 0, GameID: nil}, }, nil }, getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { return db.GetGameByIDRow{ GameID: 10, StartedAt: pgtype.Timestamp{Valid: false}, }, nil }, }, txm: &mockTxManager{}, hub: &mockGameHub{}, auth: &mockAuthenticator{}, conf: &config.Config{}, } user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) if err != nil { t.Fatalf("unexpected error: %v", err) } okResp, ok := resp.(GetTournament200JSONResponse) if !ok { t.Fatalf("expected 200 response, got %T", resp) } // Check entries if len(okResp.Tournament.Entries) != 3 { t.Fatalf("expected 3 entries, got %d", len(okResp.Tournament.Entries)) } // Check matches: bracket_size=4, num_rounds=2 → round 0: 2 matches, round 1: 1 match if len(okResp.Tournament.Matches) != 3 { t.Fatalf("expected 3 matches, got %d", len(okResp.Tournament.Matches)) } // Round 0, Position 0: Seed 1 (Alice) vs Seed 4 (bye) m0 := okResp.Tournament.Matches[0] if m0.Round != 0 || m0.Position != 0 { t.Errorf("match 0: expected round=0, pos=0, got round=%d, pos=%d", m0.Round, m0.Position) } if m0.Player1 == nil || m0.Player1.Username != "alice" { t.Errorf("match 0 player1: expected alice") } if m0.Player2 != nil { t.Errorf("match 0 player2: expected nil (bye), got %v", m0.Player2) } if !m0.IsBye { t.Error("match 0: expected is_bye=true") } if m0.WinnerUserID == nil || *m0.WinnerUserID != 100 { t.Error("match 0: expected winner to be Alice (user_id=100)") } // Round 0, Position 1: Seed 2 (Bob) vs Seed 3 (Carol) m1 := okResp.Tournament.Matches[1] if m1.Round != 0 || m1.Position != 1 { t.Errorf("match 1: expected round=0, pos=1, got round=%d, pos=%d", m1.Round, m1.Position) } if m1.Player1 == nil || m1.Player1.Username != "bob" { t.Errorf("match 1 player1: expected bob, got %v", m1.Player1) } if m1.Player2 == nil || m1.Player2.Username != "carol" { t.Errorf("match 1 player2: expected carol, got %v", m1.Player2) } if m1.IsBye { t.Error("match 1: expected is_bye=false") } } func TestGetTournament_ByeAutoWinner(t *testing.T) { // 3 players in bracket_size=4: seed 4 is empty → round 0, pos 0 is a bye // The bye winner should propagate to round 1 h := Handler{ q: &mockQuerier{ getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { return db.Tournament{ TournamentID: 1, DisplayName: "Bye Test", BracketSize: 4, NumRounds: 2, }, nil }, listTournamentEntriesFunc: func(_ context.Context, _ int32) ([]db.ListTournamentEntriesRow, error) { return []db.ListTournamentEntriesRow{ {Seed: 1, UserID: 100, Username: "alice", DisplayName: "Alice"}, {Seed: 2, UserID: 200, Username: "bob", DisplayName: "Bob"}, {Seed: 3, UserID: 300, Username: "carol", DisplayName: "Carol"}, }, nil }, listTournamentMatchesFunc: func(_ context.Context, _ int32) ([]db.TournamentMatch, error) { return []db.TournamentMatch{ {TournamentMatchID: 1, Round: 0, Position: 0}, {TournamentMatchID: 2, Round: 0, Position: 1}, {TournamentMatchID: 3, Round: 1, Position: 0}, }, nil }, }, txm: &mockTxManager{}, hub: &mockGameHub{}, auth: &mockAuthenticator{}, conf: &config.Config{}, } user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) if err != nil { t.Fatalf("unexpected error: %v", err) } okResp := resp.(GetTournament200JSONResponse) // Round 1, Position 0 (final): player1 should be Alice (bye winner from round 0 pos 0) final := okResp.Tournament.Matches[2] if final.Round != 1 || final.Position != 0 { t.Fatalf("expected final at round=1, pos=0") } if final.Player1 == nil || final.Player1.UserID != 100 { t.Error("final player1: expected Alice (bye winner)") } }