diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-15 22:13:50 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-15 22:16:22 +0900 |
| commit | 5ed369a6c70707543fd5ec9a13c79851fdfc5d6c (patch) | |
| tree | e5678d6d88fab3ac0ae8c05b85236f3e7d5eddfd | |
| parent | 87e9f5ed48af3a8dca5f6373ae900336f285eef5 (diff) | |
| download | phperkaigi-2026-albatross-5ed369a6c70707543fd5ec9a13c79851fdfc5d6c.tar.gz phperkaigi-2026-albatross-5ed369a6c70707543fd5ec9a13c79851fdfc5d6c.tar.zst phperkaigi-2026-albatross-5ed369a6c70707543fd5ec9a13c79851fdfc5d6c.zip | |
refactor(backend): introduce DI interfaces for testability
Replace concrete *db.Queries and *pgxpool.Pool dependencies with
db.Querier and db.TxManager interfaces across all handlers, game hub,
and auth. This enables unit testing with mocks.
- Enable sqlc emit_interface to generate Querier interface
- Add TxManager abstraction to encapsulate transactions
- Convert auth package-level functions to Authenticator struct
- Add TaskQueueInterface/TaskWorkerInterface for game.Hub
- Add initial unit tests for game logic and API handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | backend/account/icon.go | 2 | ||||
| -rw-r--r-- | backend/admin/handler.go | 68 | ||||
| -rw-r--r-- | backend/api/auth_middleware.go | 2 | ||||
| -rw-r--r-- | backend/api/handler.go | 57 | ||||
| -rw-r--r-- | backend/api/handler_test.go | 130 | ||||
| -rw-r--r-- | backend/api/handler_wrapper.go | 7 | ||||
| -rw-r--r-- | backend/auth/auth.go | 76 | ||||
| -rw-r--r-- | backend/db/querier.go | 63 | ||||
| -rw-r--r-- | backend/db/txmanager.go | 40 | ||||
| -rw-r--r-- | backend/game/hub.go | 70 | ||||
| -rw-r--r-- | backend/game/hub_test.go | 100 | ||||
| -rw-r--r-- | backend/gen/api/handler_wrapper_gen.go | 7 | ||||
| -rw-r--r-- | backend/gen/sqlc.yaml | 1 | ||||
| -rw-r--r-- | backend/justfile | 3 | ||||
| -rw-r--r-- | backend/main.go | 9 | ||||
| -rw-r--r-- | justfile | 3 |
16 files changed, 467 insertions, 171 deletions
diff --git a/backend/account/icon.go b/backend/account/icon.go index 8a7ecd0..a4b7c5f 100644 --- a/backend/account/icon.go +++ b/backend/account/icon.go @@ -17,7 +17,7 @@ import ( func FetchIcon( ctx context.Context, - q *db.Queries, + q db.Querier, userID int, ) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) diff --git a/backend/admin/handler.go b/backend/admin/handler.go index e8a7921..6ca0a3f 100644 --- a/backend/admin/handler.go +++ b/backend/admin/handler.go @@ -10,7 +10,6 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" "albatross-2026-backend/account" @@ -22,13 +21,13 @@ import ( var jst = time.FixedZone("Asia/Tokyo", 9*60*60) type Handler struct { - q *db.Queries - pool *pgxpool.Pool + q db.Querier + txm db.TxManager conf *config.Config } -func NewHandler(q *db.Queries, pool *pgxpool.Pool, conf *config.Config) *Handler { - return &Handler{q: q, pool: pool, conf: conf} +func NewHandler(q db.Querier, txm db.TxManager, conf *config.Config) *Handler { + return &Handler{q: q, txm: txm, conf: conf} } func (h *Handler) newAdminMiddleware() echo.MiddlewareFunc { @@ -506,48 +505,35 @@ func (h *Handler) postGameEdit(c echo.Context) error { } ctx := c.Request().Context() - tx, err := h.pool.Begin(ctx) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - defer func() { - if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { - slog.Error("failed to rollback transaction", "error", err) + err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.UpdateGame(ctx, db.UpdateGameParams{ + GameID: int32(gameID), + GameType: gameType, + IsPublic: isPublic, + DisplayName: displayName, + DurationSeconds: int32(durationSeconds), + StartedAt: changedStartedAt, + ProblemID: int32(problemID), + }); err != nil { + return err } - }() - - qtx := h.q.WithTx(tx) - err = qtx.UpdateGame(ctx, db.UpdateGameParams{ - GameID: int32(gameID), - GameType: gameType, - IsPublic: isPublic, - DisplayName: displayName, - DurationSeconds: int32(durationSeconds), - StartedAt: changedStartedAt, - ProblemID: int32(problemID), + if err := qtx.RemoveAllMainPlayers(ctx, int32(gameID)); err != nil { + return err + } + for _, userID := range mainPlayers { + if err := qtx.AddMainPlayer(ctx, db.AddMainPlayerParams{ + GameID: int32(gameID), + UserID: int32(userID), + }); err != nil { + return err + } + } + return nil }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - err = qtx.RemoveAllMainPlayers(ctx, int32(gameID)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - for _, userID := range mainPlayers { - err = qtx.AddMainPlayer(ctx, db.AddMainPlayerParams{ - GameID: int32(gameID), - UserID: int32(userID), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - } - - if err := tx.Commit(ctx); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/games") } diff --git a/backend/api/auth_middleware.go b/backend/api/auth_middleware.go index d721f1d..0b0dfc8 100644 --- a/backend/api/auth_middleware.go +++ b/backend/api/auth_middleware.go @@ -12,7 +12,7 @@ import ( type sessionIDContextKey struct{} type userContextKey struct{} -func SessionCookieMiddleware(q *db.Queries) echo.MiddlewareFunc { +func SessionCookieMiddleware(q db.Querier) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { cookie, err := c.Cookie("albatross_session") diff --git a/backend/api/handler.go b/backend/api/handler.go index 3fe7e3c..7efacf3 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -11,7 +11,6 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" @@ -20,10 +19,15 @@ import ( "albatross-2026-backend/db" ) +type AuthenticatorInterface interface { + Login(ctx context.Context, username, password string) (int, error) +} + type Handler struct { - q *db.Queries - pool *pgxpool.Pool + q db.Querier + txm db.TxManager hub GameHubInterface + auth AuthenticatorInterface conf *config.Config } @@ -47,7 +51,7 @@ func (r postLoginCookieResponse) VisitPostLoginResponse(w http.ResponseWriter) e func (h *Handler) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) { username := request.Body.Username password := request.Body.Password - userID, err := auth.Login(ctx, h.q, h.pool, username, password) + userID, err := h.auth.Login(ctx, username, password) if err != nil { slog.Error("login failed", "error", err) var msg string @@ -419,40 +423,29 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu }, nil } - tx, err := h.pool.Begin(ctx) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - defer func() { - if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { - slog.Error("failed to rollback transaction", "error", err) + var submissionID int32 + err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{ + GameID: int32(gameID), + UserID: user.UserID, + Code: code, + Status: "running", + }); err != nil { + return err } - }() - - qtx := h.q.WithTx(tx) - err = qtx.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{ - GameID: int32(gameID), - UserID: user.UserID, - Code: code, - Status: "running", - }) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - submissionID, err := qtx.CreateSubmission(ctx, db.CreateSubmissionParams{ - GameID: int32(gameID), - UserID: user.UserID, - Code: code, - CodeSize: int32(codeSize), + var err error + submissionID, err = qtx.CreateSubmission(ctx, db.CreateSubmissionParams{ + GameID: int32(gameID), + UserID: user.UserID, + Code: code, + CodeSize: int32(codeSize), + }) + return err }) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - if err := tx.Commit(ctx); err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - err = h.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, int(user.UserID), language, code) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go new file mode 100644 index 0000000..47ad92c --- /dev/null +++ b/backend/api/handler_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + "errors" + "testing" + + "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) +} + +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 +} + +// mockTxManager implements db.TxManager for testing. +type mockTxManager struct{} + +func (m *mockTxManager) RunInTx(ctx 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: "<?php echo 1;"}, + }, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r, ok := resp.(PostGamePlaySubmit403JSONResponse); !ok { + t.Errorf("expected 403 response, got %T", resp) + } else if r.Message != "Game is not running" { + t.Errorf("unexpected message: %s", r.Message) + } +} + +func TestPostLogin_AuthFailure(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{loginErr: errors.New("invalid credentials")}, + conf: &config.Config{}, + } + resp, err := h.PostLogin(context.Background(), PostLoginRequestObject{ + Body: &PostLoginJSONRequestBody{Username: "user", Password: "wrong"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(PostLogin401JSONResponse); !ok { + t.Errorf("expected 401 response, got %T", resp) + } +} diff --git a/backend/api/handler_wrapper.go b/backend/api/handler_wrapper.go index 7448d13..28b89e4 100644 --- a/backend/api/handler_wrapper.go +++ b/backend/api/handler_wrapper.go @@ -5,8 +5,6 @@ package api import ( "context" - "github.com/jackc/pgx/v5/pgxpool" - "albatross-2026-backend/config" "albatross-2026-backend/db" ) @@ -17,12 +15,13 @@ type HandlerWrapper struct { impl Handler } -func NewHandler(queries *db.Queries, pool *pgxpool.Pool, hub GameHubInterface, conf *config.Config) *HandlerWrapper { +func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth AuthenticatorInterface, conf *config.Config) *HandlerWrapper { return &HandlerWrapper{ impl: Handler{ q: queries, - pool: pool, + txm: txm, hub: hub, + auth: auth, conf: conf, }, } diff --git a/backend/auth/auth.go b/backend/auth/auth.go index 7d9a4c2..a1fcb64 100644 --- a/backend/auth/auth.go +++ b/backend/auth/auth.go @@ -7,7 +7,6 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "albatross-2026-backend/account" @@ -15,28 +14,32 @@ import ( "albatross-2026-backend/fortee" ) -var ( - ErrForteeLoginTimeout = errors.New("fortee login timeout") -) +var ErrForteeLoginTimeout = errors.New("fortee login timeout") const ( forteeAPITimeout = 3 * time.Second ) -func Login( +type Authenticator struct { + q db.Querier + txm db.TxManager +} + +func NewAuthenticator(q db.Querier, txm db.TxManager) *Authenticator { + return &Authenticator{q: q, txm: txm} +} + +func (a *Authenticator) Login( ctx context.Context, - queries *db.Queries, - pool *pgxpool.Pool, username string, password string, ) (int, error) { - userAuth, err := queries.GetUserAuthByUsername(ctx, username) + userAuth, err := a.q.GetUserAuthByUsername(ctx, username) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return 0, err } if userAuth.AuthType == "password" { - // Authenticate with password. passwordHash := userAuth.PasswordHash if passwordHash == nil { return 0, errors.New("inconsistent data: password auth type but no password hash") @@ -48,14 +51,11 @@ func Login( return int(userAuth.UserID), nil } - // Authenticate with fortee. - return verifyForteeAccountOrSignup(ctx, queries, pool, username, password) + return a.verifyForteeAccountOrSignup(ctx, username, password) } -func verifyForteeAccountOrSignup( +func (a *Authenticator) verifyForteeAccountOrSignup( ctx context.Context, - queries *db.Queries, - pool *pgxpool.Pool, username string, password string, ) (int, error) { @@ -63,58 +63,40 @@ func verifyForteeAccountOrSignup( if err != nil { return 0, err } - userID, err := queries.GetUserIDByUsername(ctx, canonicalizedUsername) + userID, err := a.q.GetUserIDByUsername(ctx, canonicalizedUsername) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return signup( - ctx, - queries, - pool, - canonicalizedUsername, - ) + return a.signup(ctx, canonicalizedUsername) } return 0, err } return int(userID), nil } -func signup( +func (a *Authenticator) signup( ctx context.Context, - queries *db.Queries, - pool *pgxpool.Pool, username string, ) (int, error) { - tx, err := pool.Begin(ctx) - if err != nil { - return 0, err - } - defer func() { - if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { - slog.Error("failed to rollback transaction", "error", err) + var userID int32 + err := a.txm.RunInTx(ctx, func(qtx db.Querier) error { + var err error + userID, err = qtx.CreateUser(ctx, username) + if err != nil { + return err } - }() - - qtx := queries.WithTx(tx) - userID, err := qtx.CreateUser(ctx, username) + return qtx.CreateUserAuth(ctx, db.CreateUserAuthParams{ + UserID: userID, + AuthType: "fortee", + }) + }) if err != nil { return 0, err } - if err := qtx.CreateUserAuth(ctx, db.CreateUserAuthParams{ - UserID: userID, - AuthType: "fortee", - }); err != nil { - return 0, err - } - - if err := tx.Commit(ctx); err != nil { - return 0, err - } go func() { - err := account.FetchIcon(context.Background(), queries, int(userID)) + err := account.FetchIcon(context.Background(), a.q, int(userID)) if err != nil { slog.Error("failed to fetch icon", "error", err) - // The failure is intentionally ignored. Retry manually if needed. } }() return int(userID), nil diff --git a/backend/db/querier.go b/backend/db/querier.go new file mode 100644 index 0000000..89d4b55 --- /dev/null +++ b/backend/db/querier.go @@ -0,0 +1,63 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" +) + +type Querier interface { + AddMainPlayer(ctx context.Context, arg AddMainPlayerParams) error + AggregateTestcaseResults(ctx context.Context, submissionID int32) (string, error) + CreateGame(ctx context.Context, arg CreateGameParams) (int32, error) + CreateProblem(ctx context.Context, arg CreateProblemParams) (int32, error) + CreateSession(ctx context.Context, arg CreateSessionParams) error + CreateSubmission(ctx context.Context, arg CreateSubmissionParams) (int32, error) + CreateTestcase(ctx context.Context, arg CreateTestcaseParams) (int32, error) + CreateTestcaseResult(ctx context.Context, arg CreateTestcaseResultParams) error + CreateUser(ctx context.Context, username string) (int32, error) + CreateUserAuth(ctx context.Context, arg CreateUserAuthParams) error + DeleteExpiredSessions(ctx context.Context) error + DeleteSession(ctx context.Context, sessionID string) error + DeleteTestcase(ctx context.Context, testcaseID int32) error + GetGameByID(ctx context.Context, gameID int32) (GetGameByIDRow, error) + GetLatestState(ctx context.Context, arg GetLatestStateParams) (GetLatestStateRow, error) + GetLatestStatesOfMainPlayers(ctx context.Context, gameID int32) ([]GetLatestStatesOfMainPlayersRow, error) + GetProblemByID(ctx context.Context, problemID int32) (Problem, error) + GetQualifyingRanking(ctx context.Context, arg GetQualifyingRankingParams) ([]GetQualifyingRankingRow, error) + GetRanking(ctx context.Context, gameID int32) ([]GetRankingRow, error) + GetSubmissionByID(ctx context.Context, submissionID int32) (Submission, error) + GetSubmissionsByGameID(ctx context.Context, gameID int32) ([]Submission, error) + GetTestcaseByID(ctx context.Context, testcaseID int32) (Testcase, error) + GetTestcaseResultsBySubmissionID(ctx context.Context, submissionID int32) ([]TestcaseResult, error) + GetUserAuthByUsername(ctx context.Context, username string) (GetUserAuthByUsernameRow, error) + GetUserByID(ctx context.Context, userID int32) (User, error) + GetUserBySession(ctx context.Context, sessionID string) (User, error) + GetUserIDByUsername(ctx context.Context, username string) (int32, error) + ListAllGames(ctx context.Context) ([]Game, error) + ListGameStateIDs(ctx context.Context) ([]ListGameStateIDsRow, error) + ListMainPlayers(ctx context.Context, dollar_1 []int32) ([]ListMainPlayersRow, error) + ListProblems(ctx context.Context) ([]Problem, error) + ListPublicGames(ctx context.Context) ([]ListPublicGamesRow, error) + ListSubmissionIDs(ctx context.Context) ([]int32, error) + ListTestcases(ctx context.Context) ([]Testcase, error) + ListTestcasesByGameID(ctx context.Context, gameID int32) ([]Testcase, error) + ListTestcasesByProblemID(ctx context.Context, problemID int32) ([]Testcase, error) + ListUsers(ctx context.Context) ([]User, error) + RemoveAllMainPlayers(ctx context.Context, gameID int32) error + SyncGameStateBestScoreSubmission(ctx context.Context, arg SyncGameStateBestScoreSubmissionParams) error + UpdateCode(ctx context.Context, arg UpdateCodeParams) error + UpdateCodeAndStatus(ctx context.Context, arg UpdateCodeAndStatusParams) error + UpdateGame(ctx context.Context, arg UpdateGameParams) error + UpdateGameStartedAt(ctx context.Context, arg UpdateGameStartedAtParams) error + UpdateGameStateStatus(ctx context.Context, arg UpdateGameStateStatusParams) error + UpdateProblem(ctx context.Context, arg UpdateProblemParams) error + UpdateSubmissionStatus(ctx context.Context, arg UpdateSubmissionStatusParams) error + UpdateTestcase(ctx context.Context, arg UpdateTestcaseParams) error + UpdateUser(ctx context.Context, arg UpdateUserParams) error + UpdateUserIconPath(ctx context.Context, arg UpdateUserIconPathParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/backend/db/txmanager.go b/backend/db/txmanager.go new file mode 100644 index 0000000..9495288 --- /dev/null +++ b/backend/db/txmanager.go @@ -0,0 +1,40 @@ +package db + +import ( + "context" + "log/slog" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type TxManager interface { + RunInTx(ctx context.Context, fn func(q Querier) error) error +} + +type PgxTxManager struct { + pool *pgxpool.Pool + queries *Queries +} + +func NewTxManager(pool *pgxpool.Pool, queries *Queries) *PgxTxManager { + return &PgxTxManager{pool: pool, queries: queries} +} + +func (m *PgxTxManager) RunInTx(ctx context.Context, fn func(q Querier) error) error { + tx, err := m.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { + if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { + slog.Error("failed to rollback transaction", "error", err) + } + }() + + qtx := m.queries.WithTx(tx) + if err := fn(qtx); err != nil { + return err + } + return tx.Commit(ctx) +} diff --git a/backend/game/hub.go b/backend/game/hub.go index d918543..9c193f2 100644 --- a/backend/game/hub.go +++ b/backend/game/hub.go @@ -7,25 +7,31 @@ import ( "regexp" "strings" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "albatross-2026-backend/db" "albatross-2026-backend/taskqueue" ) +type TaskQueueInterface interface { + EnqueueTaskRunTestcase(gameID, userID, submissionID, testcaseID int, language, code, stdin, stdout string) error +} + +type TaskWorkerInterface interface { + Run() error + Results() chan taskqueue.TaskResult +} + type Hub struct { - q *db.Queries - pool *pgxpool.Pool + q db.Querier + txm db.TxManager ctx context.Context - taskQueue *taskqueue.Queue - taskWorker *taskqueue.WorkerServer + taskQueue TaskQueueInterface + taskWorker TaskWorkerInterface } -func NewGameHub(q *db.Queries, pool *pgxpool.Pool, taskQueue *taskqueue.Queue, taskWorker *taskqueue.WorkerServer) *Hub { +func NewGameHub(q db.Querier, txm db.TxManager, taskQueue TaskQueueInterface, taskWorker TaskWorkerInterface) *Hub { return &Hub{ q: q, - pool: pool, + txm: txm, ctx: context.Background(), taskQueue: taskQueue, taskWorker: taskWorker, @@ -104,40 +110,30 @@ func (hub *Hub) processTaskResults() { } func (hub *Hub) updateSubmissionAndGameState(taskResult *taskqueue.TaskResultRunTestcase, aggregatedStatus string) error { - tx, err := hub.pool.Begin(hub.ctx) - if err != nil { - return err - } - defer func() { - if err := tx.Rollback(hub.ctx); err != nil && err != pgx.ErrTxClosed { - slog.Error("failed to rollback transaction", "error", err) + return hub.txm.RunInTx(hub.ctx, func(qtx db.Querier) error { + if err := qtx.UpdateSubmissionStatus(hub.ctx, db.UpdateSubmissionStatusParams{ + SubmissionID: int32(taskResult.TaskPayload.SubmissionID), + Status: aggregatedStatus, + }); err != nil { + return err } - }() - - qtx := hub.q.WithTx(tx) - if err := qtx.UpdateSubmissionStatus(hub.ctx, db.UpdateSubmissionStatusParams{ - SubmissionID: int32(taskResult.TaskPayload.SubmissionID), - Status: aggregatedStatus, - }); err != nil { - return err - } - if err := qtx.UpdateGameStateStatus(hub.ctx, db.UpdateGameStateStatusParams{ - GameID: int32(taskResult.TaskPayload.GameID), - UserID: int32(taskResult.TaskPayload.UserID), - Status: aggregatedStatus, - }); err != nil { - return err - } - if aggregatedStatus == "success" { - if err := qtx.SyncGameStateBestScoreSubmission(hub.ctx, db.SyncGameStateBestScoreSubmissionParams{ + if err := qtx.UpdateGameStateStatus(hub.ctx, db.UpdateGameStateStatusParams{ GameID: int32(taskResult.TaskPayload.GameID), UserID: int32(taskResult.TaskPayload.UserID), + Status: aggregatedStatus, }); err != nil { return err } - } - - return tx.Commit(hub.ctx) + if aggregatedStatus == "success" { + if err := qtx.SyncGameStateBestScoreSubmission(hub.ctx, db.SyncGameStateBestScoreSubmissionParams{ + GameID: int32(taskResult.TaskPayload.GameID), + UserID: int32(taskResult.TaskPayload.UserID), + }); err != nil { + return err + } + } + return nil + }) } func (hub *Hub) processTaskResultRunTestcase( diff --git a/backend/game/hub_test.go b/backend/game/hub_test.go new file mode 100644 index 0000000..a8fad58 --- /dev/null +++ b/backend/game/hub_test.go @@ -0,0 +1,100 @@ +package game + +import "testing" + +func TestCalcCodeSize_PHP(t *testing.T) { + hub := &Hub{} + tests := []struct { + name string + code string + language string + want int + }{ + { + name: "simple php code", + code: "<?php echo 1;", + language: "php", + want: 6, // "echo1;" after stripping whitespace and "<?php" + }, + { + name: "php with short open tag", + code: "<? echo 1;", + language: "php", + want: 6, // "echo1;" after stripping whitespace and "<?" + }, + { + name: "php with closing tag", + code: "<?php echo 1; ?>", + language: "php", + want: 6, // "echo1;" after stripping whitespace, "<?php", and "?>" + }, + { + name: "php with whitespace", + code: "<?php echo 1 ; ?>", + language: "php", + want: 6, + }, + { + name: "non-php language", + code: "print(1)", + language: "swift", + want: 8, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hub.CalcCodeSize(tt.code, tt.language) + if got != tt.want { + t.Errorf("CalcCodeSize(%q, %q) = %d, want %d", tt.code, tt.language, got, tt.want) + } + }) + } +} + +func TestIsTestcaseResultCorrect(t *testing.T) { + tests := []struct { + name string + expected string + actual string + want bool + }{ + { + name: "exact match", + expected: "hello", + actual: "hello", + want: true, + }, + { + name: "trailing newline ignored", + expected: "hello\n", + actual: "hello", + want: true, + }, + { + name: "CRLF normalized", + expected: "hello\r\n", + actual: "hello\n", + want: true, + }, + { + name: "mismatch", + expected: "hello", + actual: "world", + want: false, + }, + { + name: "multiline match", + expected: "line1\nline2", + actual: "line1\nline2\n", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTestcaseResultCorrect(tt.expected, tt.actual) + if got != tt.want { + t.Errorf("isTestcaseResultCorrect(%q, %q) = %v, want %v", tt.expected, tt.actual, got, tt.want) + } + }) + } +} diff --git a/backend/gen/api/handler_wrapper_gen.go b/backend/gen/api/handler_wrapper_gen.go index 1a948a7..e3e56c1 100644 --- a/backend/gen/api/handler_wrapper_gen.go +++ b/backend/gen/api/handler_wrapper_gen.go @@ -105,8 +105,6 @@ package api import ( "context" - "github.com/jackc/pgx/v5/pgxpool" - "albatross-2026-backend/config" "albatross-2026-backend/db" ) @@ -117,12 +115,13 @@ type HandlerWrapper struct { impl Handler } -func NewHandler(queries *db.Queries, pool *pgxpool.Pool, hub GameHubInterface, conf *config.Config) *HandlerWrapper { +func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth AuthenticatorInterface, conf *config.Config) *HandlerWrapper { return &HandlerWrapper{ impl: Handler{ q: queries, - pool: pool, + txm: txm, hub: hub, + auth: auth, conf: conf, }, } diff --git a/backend/gen/sqlc.yaml b/backend/gen/sqlc.yaml index c56e44b..98dec89 100644 --- a/backend/gen/sqlc.yaml +++ b/backend/gen/sqlc.yaml @@ -9,3 +9,4 @@ sql: out: "../db" sql_package: "pgx/v5" emit_pointers_for_null_types: true + emit_interface: true diff --git a/backend/justfile b/backend/justfile index a74047f..1897cab 100644 --- a/backend/justfile +++ b/backend/justfile @@ -2,7 +2,8 @@ check: go build -o /dev/null ./... go tool golangci-lint run -ci: check +test: + go test ./... gen: go generate ./... diff --git a/backend/main.go b/backend/main.go index 01ed784..f936cf4 100644 --- a/backend/main.go +++ b/backend/main.go @@ -16,6 +16,7 @@ import ( "albatross-2026-backend/admin" "albatross-2026-backend/api" + "albatross-2026-backend/auth" "albatross-2026-backend/config" "albatross-2026-backend/db" "albatross-2026-backend/game" @@ -61,6 +62,8 @@ func main() { defer connPool.Close() queries := db.New(connPool) + txm := db.NewTxManager(connPool, queries) + authenticator := auth.NewAuthenticator(queries, txm) e := echo.New() e.Renderer = admin.NewRenderer() @@ -91,7 +94,7 @@ func main() { taskQueue := taskqueue.NewQueue("task-db:6379") workerServer := taskqueue.NewWorkerServer("task-db:6379") - gameHub := game.NewGameHub(queries, connPool, taskQueue, workerServer) + gameHub := game.NewGameHub(queries, txm, taskQueue, workerServer) loginRL := ratelimit.NewIPRateLimiter(rate.Every(time.Minute/5), 5) @@ -99,10 +102,10 @@ func main() { apiGroup.Use(ratelimit.LoginRateLimitMiddleware(loginRL)) apiGroup.Use(api.SessionCookieMiddleware(queries)) apiGroup.Use(oapimiddleware.OapiRequestValidator(openAPISpec)) - apiHandler := api.NewHandler(queries, connPool, gameHub, conf) + apiHandler := api.NewHandler(queries, txm, gameHub, authenticator, conf) api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil)) - adminHandler := admin.NewHandler(queries, connPool, conf) + adminHandler := admin.NewHandler(queries, txm, conf) adminGroup := e.Group(conf.BasePath + "admin") adminGroup.Use(api.SessionCookieMiddleware(queries)) adminHandler.RegisterHandlers(adminGroup) @@ -56,3 +56,6 @@ check: cd backend; just check cd worker/swift; just check npm -w frontend run check + +test: + cd backend; just test |
