aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-15 22:13:50 +0900
committernsfisis <nsfisis@gmail.com>2026-02-15 22:16:22 +0900
commit5ed369a6c70707543fd5ec9a13c79851fdfc5d6c (patch)
treee5678d6d88fab3ac0ae8c05b85236f3e7d5eddfd /backend
parent87e9f5ed48af3a8dca5f6373ae900336f285eef5 (diff)
downloadphperkaigi-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>
Diffstat (limited to 'backend')
-rw-r--r--backend/account/icon.go2
-rw-r--r--backend/admin/handler.go68
-rw-r--r--backend/api/auth_middleware.go2
-rw-r--r--backend/api/handler.go57
-rw-r--r--backend/api/handler_test.go130
-rw-r--r--backend/api/handler_wrapper.go7
-rw-r--r--backend/auth/auth.go76
-rw-r--r--backend/db/querier.go63
-rw-r--r--backend/db/txmanager.go40
-rw-r--r--backend/game/hub.go70
-rw-r--r--backend/game/hub_test.go100
-rw-r--r--backend/gen/api/handler_wrapper_gen.go7
-rw-r--r--backend/gen/sqlc.yaml1
-rw-r--r--backend/justfile3
-rw-r--r--backend/main.go9
15 files changed, 464 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)