From e8db174d3e464a5764a9f4bfd82172261bd50519 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 21 Feb 2026 10:29:21 +0900 Subject: refactor(api): separate business logic into game, tournament, session packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract business logic from api/handler.go into dedicated service packages: - session: context helpers (resolves admin → api import dependency) - game: game state, code submission, ranking, watch logic - tournament: bracket construction and seed ordering - api/convert.go: domain → API type conversion functions api/handler.go is now a thin adapter that delegates to services and maps domain errors to HTTP status codes. Co-Authored-By: Claude Opus 4.6 --- backend/api/handler_test.go | 829 +++++++++++++++----------------------------- 1 file changed, 271 insertions(+), 558 deletions(-) (limited to 'backend/api/handler_test.go') diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index a28d605..2b8c06a 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -11,6 +11,9 @@ import ( "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/game" + "albatross-2026-backend/session" + "albatross-2026-backend/tournament" ) // mockQuerier implements db.Querier for testing. @@ -28,6 +31,7 @@ type mockQuerier struct { listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) getSubmissionsByGameIDAndUserIDFunc func(ctx context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) + getUserByIDFunc func(ctx context.Context, userID int32) (db.User, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { @@ -114,6 +118,13 @@ func (m *mockQuerier) ListTournamentMatches(ctx context.Context, tournamentID in return nil, nil } +func (m *mockQuerier) GetUserByID(ctx context.Context, userID int32) (db.User, error) { + if m.getUserByIDFunc != nil { + return m.getUserByIDFunc(ctx, userID) + } + return db.User{}, pgx.ErrNoRows +} + // mockTxManager implements db.TxManager for testing. type mockTxManager struct{} @@ -121,7 +132,7 @@ func (m *mockTxManager) RunInTx(_ context.Context, fn func(q db.Querier) error) return fn(&mockQuerier{}) } -// mockGameHub implements GameHubInterface for testing. +// mockGameHub implements game.GameHubInterface for testing. type mockGameHub struct { calcCodeSizeResult int enqueueErr error @@ -145,14 +156,29 @@ func (m *mockAuthenticator) Login(_ context.Context, _, _ string) (int, error) { return m.loginResult, m.loginErr } -func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { - h := Handler{ - q: &mockQuerier{}, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, +func newTestHandler(q *mockQuerier) Handler { + hub := &mockGameHub{} + return Handler{ + gameSvc: game.NewService(q, &mockTxManager{}, hub), + tournamentSvc: tournament.NewService(q), + auth: &mockAuthenticator{}, + conf: &config.Config{}, + q: q, } +} + +func newTestHandlerWithHub(q *mockQuerier, hub *mockGameHub) Handler { + return Handler{ + gameSvc: game.NewService(q, &mockTxManager{}, hub), + tournamentSvc: tournament.NewService(q), + auth: &mockAuthenticator{}, + conf: &config.Config{}, + q: q, + } +} + +func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) user := &db.User{UserID: 1} resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ GameID: 999, @@ -166,20 +192,14 @@ func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { } func TestGetGamePlaySubmissions_Empty(t *testing.T) { - h := Handler{ - q: &mockQuerier{ - getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { - return db.GetGameByIDRow{ - GameID: 1, - Language: "php", - }, nil - }, + h := newTestHandler(&mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + Language: "php", + }, nil }, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + }) user := &db.User{UserID: 1} resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ GameID: 1, @@ -198,45 +218,39 @@ func TestGetGamePlaySubmissions_Empty(t *testing.T) { func TestGetGamePlaySubmissions_WithSubmissions(t *testing.T) { now := time.Now() - h := Handler{ - q: &mockQuerier{ - getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { - return db.GetGameByIDRow{ - GameID: 1, - Language: "php", - }, nil - }, - getSubmissionsByGameIDAndUserIDFunc: func(_ context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) { - if arg.GameID != 1 || arg.UserID != 42 { - t.Errorf("unexpected query params: game_id=%d, user_id=%d", arg.GameID, arg.UserID) - } - return []db.Submission{ - { - SubmissionID: 10, - GameID: 1, - UserID: 42, - 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 + v, err := result.Get() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if len(seen) != size { - t.Errorf("bracket_size=%d: expected %d unique seeds, got %d", size, size, len(seen)) + if v != "hello" { + t.Errorf("expected 'hello', got %q", v) } - } + }) } -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) - } -} +// --- Tournament tests --- func TestGetTournament_NotFound(t *testing.T) { - h := Handler{ - q: &mockQuerier{}, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + h := newTestHandler(&mockQuerier{}) user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 999}, user) if err != nil { @@ -1010,22 +748,16 @@ func TestGetTournament_NotFound(t *testing.T) { } 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 - }, + h := newTestHandler(&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 { @@ -1051,42 +783,36 @@ func TestGetTournament_Success_NoEntries(t *testing.T) { 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 - }, + h := newTestHandler(&mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Test", + BracketSize: 4, + NumRounds: 2, + }, nil }, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + 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 + }, + }) user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) if err != nil { @@ -1097,17 +823,14 @@ func TestGetTournament_WithEntriesAndMatches(t *testing.T) { 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) @@ -1125,7 +848,6 @@ func TestGetTournament_WithEntriesAndMatches(t *testing.T) { 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) @@ -1142,38 +864,30 @@ func TestGetTournament_WithEntriesAndMatches(t *testing.T) { } 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 - }, + h := newTestHandler(&mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Bye Test", + BracketSize: 4, + NumRounds: 2, + }, nil }, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + 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 + }, + }) user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) if err != nil { @@ -1181,7 +895,6 @@ func TestGetTournament_ByeAutoWinner(t *testing.T) { } 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") -- cgit v1.3.1