aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 10:29:21 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 10:29:21 +0900
commite8db174d3e464a5764a9f4bfd82172261bd50519 (patch)
tree68cb8f0713fcc1f960a650d879232cb4c20ca6cd /backend
parent1be106ac53caa019a8912af932a43570fa8c052d (diff)
downloadphperkaigi-2026-albatross-e8db174d3e464a5764a9f4bfd82172261bd50519.tar.gz
phperkaigi-2026-albatross-e8db174d3e464a5764a9f4bfd82172261bd50519.tar.zst
phperkaigi-2026-albatross-e8db174d3e464a5764a9f4bfd82172261bd50519.zip
refactor(api): separate business logic into game, tournament, session packages
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 <noreply@anthropic.com>
Diffstat (limited to 'backend')
-rw-r--r--backend/admin/handler.go4
-rw-r--r--backend/admin/handler_test.go4
-rw-r--r--backend/api/auth_middleware.go34
-rw-r--r--backend/api/auth_middleware_test.go63
-rw-r--r--backend/api/convert.go160
-rw-r--r--backend/api/handler.go636
-rw-r--r--backend/api/handler_test.go829
-rw-r--r--backend/api/handler_wrapper.go37
-rw-r--r--backend/game/errors.go9
-rw-r--r--backend/game/service.go406
-rw-r--r--backend/game/service_test.go82
-rw-r--r--backend/gen/api/handler_wrapper_gen.go19
-rw-r--r--backend/main.go5
-rw-r--r--backend/session/context.go39
-rw-r--r--backend/tournament/service.go303
-rw-r--r--backend/tournament/service_test.go97
16 files changed, 1478 insertions, 1249 deletions
diff --git a/backend/admin/handler.go b/backend/admin/handler.go
index 8eac425..09303ac 100644
--- a/backend/admin/handler.go
+++ b/backend/admin/handler.go
@@ -14,9 +14,9 @@ import (
"github.com/labstack/echo/v4"
"albatross-2026-backend/account"
- "albatross-2026-backend/api"
"albatross-2026-backend/config"
"albatross-2026-backend/db"
+ "albatross-2026-backend/session"
)
var jst = time.FixedZone("Asia/Tokyo", 9*60*60)
@@ -39,7 +39,7 @@ func NewHandler(q db.Querier, txm db.TxManager, hub GameHub, conf *config.Config
func (h *Handler) newAdminMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
- user, ok := api.GetUserFromContext(c.Request().Context())
+ user, ok := session.GetUserFromContext(c.Request().Context())
if !ok {
return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"login")
}
diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go
index 9de9a0b..20f5775 100644
--- a/backend/admin/handler_test.go
+++ b/backend/admin/handler_test.go
@@ -14,9 +14,9 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
- "albatross-2026-backend/api"
"albatross-2026-backend/config"
"albatross-2026-backend/db"
+ "albatross-2026-backend/session"
)
// mockQuerier implements db.Querier for admin handler testing.
@@ -395,7 +395,7 @@ func newEchoContextWithForm(path string, params map[string]string, form url.Valu
// --- Admin middleware tests ---
func setUserInContext(c echo.Context, user *db.User) {
- ctx := api.SetUserInContext(c.Request().Context(), user)
+ ctx := session.SetUserInContext(c.Request().Context(), user)
c.SetRequest(c.Request().WithContext(ctx))
}
diff --git a/backend/api/auth_middleware.go b/backend/api/auth_middleware.go
index f2a3987..a588185 100644
--- a/backend/api/auth_middleware.go
+++ b/backend/api/auth_middleware.go
@@ -1,17 +1,13 @@
package api
import (
- "context"
-
"github.com/labstack/echo/v4"
"albatross-2026-backend/auth"
"albatross-2026-backend/db"
+ "albatross-2026-backend/session"
)
-type sessionIDContextKey struct{}
-type userContextKey struct{}
-
func SessionCookieMiddleware(q db.Querier) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -25,31 +21,14 @@ func SessionCookieMiddleware(q db.Querier) echo.MiddlewareFunc {
return next(c)
}
ctx := c.Request().Context()
- ctx = context.WithValue(ctx, sessionIDContextKey{}, hashedID)
- ctx = context.WithValue(ctx, userContextKey{}, &user)
+ ctx = session.SetSessionIDInContext(ctx, hashedID)
+ ctx = session.SetUserInContext(ctx, &user)
c.SetRequest(c.Request().WithContext(ctx))
return next(c)
}
}
}
-func GetSessionIDFromContext(ctx context.Context) (string, bool) {
- sessionID, ok := ctx.Value(sessionIDContextKey{}).(string)
- return sessionID, ok
-}
-
-func GetUserFromContext(ctx context.Context) (*db.User, bool) {
- user, ok := ctx.Value(userContextKey{}).(*db.User)
- return user, ok
-}
-
-// SetUserInContext sets a user in the context. Intended for testing.
-func SetUserInContext(ctx context.Context, user *db.User) context.Context {
- return context.WithValue(ctx, userContextKey{}, user)
-}
-
-type clientIPContextKey struct{}
-
// ClientIPMiddleware extracts the client IP from echo.Context.RealIP()
// and stores it in the request's context.Context so that handlers
// receiving only context.Context (via generated code) can access it.
@@ -57,14 +36,9 @@ func ClientIPMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ip := c.RealIP()
- ctx := context.WithValue(c.Request().Context(), clientIPContextKey{}, ip)
+ ctx := session.SetClientIPInContext(c.Request().Context(), ip)
c.SetRequest(c.Request().WithContext(ctx))
return next(c)
}
}
}
-
-func GetClientIPFromContext(ctx context.Context) string {
- ip, _ := ctx.Value(clientIPContextKey{}).(string)
- return ip
-}
diff --git a/backend/api/auth_middleware_test.go b/backend/api/auth_middleware_test.go
index d84eef7..9fc20af 100644
--- a/backend/api/auth_middleware_test.go
+++ b/backend/api/auth_middleware_test.go
@@ -9,62 +9,9 @@ import (
"github.com/labstack/echo/v4"
"albatross-2026-backend/db"
+ "albatross-2026-backend/session"
)
-func TestGetSessionIDFromContext_NotSet(t *testing.T) {
- ctx := context.Background()
- _, ok := GetSessionIDFromContext(ctx)
- if ok {
- t.Error("expected ok=false when session ID is not set")
- }
-}
-
-func TestGetSessionIDFromContext_Set(t *testing.T) {
- ctx := context.WithValue(context.Background(), sessionIDContextKey{}, "abc123")
- id, ok := GetSessionIDFromContext(ctx)
- if !ok {
- t.Fatal("expected ok=true when session ID is set")
- }
- if id != "abc123" {
- t.Errorf("expected session ID 'abc123', got %q", id)
- }
-}
-
-func TestGetUserFromContext_NotSet(t *testing.T) {
- ctx := context.Background()
- _, ok := GetUserFromContext(ctx)
- if ok {
- t.Error("expected ok=false when user is not set")
- }
-}
-
-func TestGetUserFromContext_Set(t *testing.T) {
- user := &db.User{UserID: 42, Username: "testuser"}
- ctx := context.WithValue(context.Background(), userContextKey{}, user)
- u, ok := GetUserFromContext(ctx)
- if !ok {
- t.Fatal("expected ok=true when user is set")
- }
- if u.UserID != 42 {
- t.Errorf("expected user ID 42, got %d", u.UserID)
- }
- if u.Username != "testuser" {
- t.Errorf("expected username 'testuser', got %q", u.Username)
- }
-}
-
-func TestSetUserInContext(t *testing.T) {
- user := &db.User{UserID: 7, Username: "admin"}
- ctx := SetUserInContext(context.Background(), user)
- u, ok := GetUserFromContext(ctx)
- if !ok {
- t.Fatal("expected ok=true after SetUserInContext")
- }
- if u.UserID != 7 {
- t.Errorf("expected user ID 7, got %d", u.UserID)
- }
-}
-
// mockSessionQuerier implements the subset of db.Querier needed by SessionCookieMiddleware.
type mockSessionQuerier struct {
db.Querier
@@ -89,7 +36,7 @@ func TestSessionCookieMiddleware_NoCookie(t *testing.T) {
handler := mw(func(c echo.Context) error {
called = true
// User should not be set
- _, ok := GetUserFromContext(c.Request().Context())
+ _, ok := session.GetUserFromContext(c.Request().Context())
if ok {
t.Error("expected no user in context when no cookie is present")
}
@@ -122,14 +69,14 @@ func TestSessionCookieMiddleware_ValidSession(t *testing.T) {
var called bool
handler := mw(func(c echo.Context) error {
called = true
- user, ok := GetUserFromContext(c.Request().Context())
+ user, ok := session.GetUserFromContext(c.Request().Context())
if !ok {
t.Fatal("expected user in context")
}
if user.UserID != 10 {
t.Errorf("expected user ID 10, got %d", user.UserID)
}
- sid, ok := GetSessionIDFromContext(c.Request().Context())
+ sid, ok := session.GetSessionIDFromContext(c.Request().Context())
if !ok {
t.Fatal("expected session ID in context")
}
@@ -164,7 +111,7 @@ func TestSessionCookieMiddleware_InvalidSession(t *testing.T) {
var called bool
handler := mw(func(c echo.Context) error {
called = true
- _, ok := GetUserFromContext(c.Request().Context())
+ _, ok := session.GetUserFromContext(c.Request().Context())
if ok {
t.Error("expected no user in context for invalid session")
}
diff --git a/backend/api/convert.go b/backend/api/convert.go
new file mode 100644
index 0000000..f05f3bf
--- /dev/null
+++ b/backend/api/convert.go
@@ -0,0 +1,160 @@
+package api
+
+import (
+ "github.com/oapi-codegen/nullable"
+
+ "albatross-2026-backend/game"
+ "albatross-2026-backend/tournament"
+)
+
+func toAPIUser(p game.Player) User {
+ return User{
+ UserID: p.UserID,
+ Username: p.Username,
+ DisplayName: p.DisplayName,
+ IconPath: p.IconPath,
+ IsAdmin: p.IsAdmin,
+ Label: toNullable(p.Label),
+ }
+}
+
+func toAPIGame(g game.GameDetail) Game {
+ var startedAt *int64
+ if g.StartedAt != nil {
+ ts := g.StartedAt.Unix()
+ startedAt = &ts
+ }
+ mainPlayers := make([]User, len(g.MainPlayers))
+ for i, p := range g.MainPlayers {
+ mainPlayers[i] = toAPIUser(p)
+ }
+ return Game{
+ GameID: g.GameID,
+ GameType: GameType(g.GameType),
+ IsPublic: g.IsPublic,
+ DisplayName: g.DisplayName,
+ DurationSeconds: g.DurationSeconds,
+ StartedAt: startedAt,
+ Problem: Problem{
+ ProblemID: g.Problem.ProblemID,
+ Title: g.Problem.Title,
+ Description: g.Problem.Description,
+ Language: ProblemLanguage(g.Problem.Language),
+ SampleCode: g.Problem.SampleCode,
+ },
+ MainPlayers: mainPlayers,
+ }
+}
+
+func toAPILatestState(s game.LatestState) LatestGameState {
+ var score nullable.Nullable[int]
+ if s.Score != nil {
+ score = nullable.NewNullableWithValue(*s.Score)
+ } else {
+ score = nullable.NewNullNullable[int]()
+ }
+ var submittedAt nullable.Nullable[int64]
+ if s.BestScoreSubmittedAt != nil {
+ submittedAt = nullable.NewNullableWithValue(*s.BestScoreSubmittedAt)
+ } else {
+ submittedAt = nullable.NewNullNullable[int64]()
+ }
+ return LatestGameState{
+ Code: s.Code,
+ Score: score,
+ BestScoreSubmittedAt: submittedAt,
+ Status: ExecutionStatus(s.Status),
+ }
+}
+
+func toAPIRankingEntry(r game.RankingEntry) RankingEntry {
+ var code nullable.Nullable[string]
+ if r.Code != nil {
+ code = nullable.NewNullableWithValue(*r.Code)
+ } else {
+ code = nullable.NewNullNullable[string]()
+ }
+ return RankingEntry{
+ Player: toAPIUser(r.Player),
+ Score: r.Score,
+ SubmittedAt: r.SubmittedAt,
+ Code: code,
+ }
+}
+
+func toAPISubmission(s game.SubmissionDetail) Submission {
+ return Submission{
+ SubmissionID: s.SubmissionID,
+ GameID: s.GameID,
+ Code: s.Code,
+ CodeSize: s.CodeSize,
+ Status: ExecutionStatus(s.Status),
+ CreatedAt: s.CreatedAt,
+ }
+}
+
+func toAPITournamentUser(p tournament.Player) User {
+ return User{
+ UserID: p.UserID,
+ Username: p.Username,
+ DisplayName: p.DisplayName,
+ IconPath: p.IconPath,
+ IsAdmin: p.IsAdmin,
+ Label: toNullable(p.Label),
+ }
+}
+
+func toAPITournamentPlayerPtr(p *tournament.Player) *User {
+ if p == nil {
+ return nil
+ }
+ u := toAPITournamentUser(*p)
+ return &u
+}
+
+func toAPITournament(t tournament.TournamentBracket) Tournament {
+ entries := make([]TournamentEntry, len(t.Entries))
+ for i, e := range t.Entries {
+ entries[i] = TournamentEntry{
+ User: toAPITournamentUser(e.User),
+ Seed: e.Seed,
+ }
+ }
+ matches := make([]TournamentMatch, len(t.Matches))
+ for i, m := range t.Matches {
+ matches[i] = TournamentMatch{
+ TournamentMatchID: m.TournamentMatchID,
+ Round: m.Round,
+ Position: m.Position,
+ GameID: m.GameID,
+ Player1: toAPITournamentPlayerPtr(m.Player1),
+ Player2: toAPITournamentPlayerPtr(m.Player2),
+ Player1Score: m.Player1Score,
+ Player2Score: m.Player2Score,
+ WinnerUserID: m.WinnerUserID,
+ IsBye: m.IsBye,
+ }
+ }
+ return Tournament{
+ TournamentID: t.TournamentID,
+ DisplayName: t.DisplayName,
+ BracketSize: t.BracketSize,
+ NumRounds: t.NumRounds,
+ Entries: entries,
+ Matches: matches,
+ }
+}
+
+func toNullable[T any](p *T) nullable.Nullable[T] {
+ if p == nil {
+ return nullable.NewNullNullable[T]()
+ }
+ return nullable.NewNullableWithValue(*p)
+}
+
+func toNullableWith[T, U any](p *T, f func(T) U) nullable.Nullable[U] {
+ if p == nil {
+ return nullable.NewNullNullable[U]()
+ }
+ return nullable.NewNullableWithValue(f(*p))
+}
diff --git a/backend/api/handler.go b/backend/api/handler.go
index dcebfa1..4d729f1 100644
--- a/backend/api/handler.go
+++ b/backend/api/handler.go
@@ -9,14 +9,15 @@ import (
"strconv"
"time"
- "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
- "github.com/oapi-codegen/nullable"
"albatross-2026-backend/auth"
"albatross-2026-backend/config"
"albatross-2026-backend/db"
+ "albatross-2026-backend/game"
+ "albatross-2026-backend/session"
+ "albatross-2026-backend/tournament"
)
type AuthenticatorInterface interface {
@@ -24,16 +25,11 @@ type AuthenticatorInterface interface {
}
type Handler struct {
- q db.Querier
- txm db.TxManager
- hub GameHubInterface
- auth AuthenticatorInterface
- conf *config.Config
-}
-
-type GameHubInterface interface {
- CalcCodeSize(code string, language string) int
- EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error
+ gameSvc *game.Service
+ tournamentSvc *tournament.Service
+ auth AuthenticatorInterface
+ conf *config.Config
+ q db.Querier // for session management (login/logout)
}
type postLoginCookieResponse struct {
@@ -51,7 +47,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
- ip := GetClientIPFromContext(ctx)
+ ip := session.GetClientIPFromContext(ctx)
userID, err := h.auth.Login(ctx, username, password)
if err != nil {
@@ -138,7 +134,7 @@ func (r postLogoutCookieResponse) VisitPostLogoutResponse(w http.ResponseWriter)
}
func (h *Handler) PostLogout(ctx context.Context, _ PostLogoutRequestObject, _ *db.User) (PostLogoutResponseObject, error) {
- if sessionID, ok := GetSessionIDFromContext(ctx); ok {
+ if sessionID, ok := session.GetSessionIDFromContext(ctx); ok {
_ = h.q.DeleteSession(ctx, sessionID)
}
return postLogoutCookieResponse{
@@ -155,631 +151,125 @@ func (h *Handler) PostLogout(ctx context.Context, _ PostLogoutRequestObject, _ *
}
func (h *Handler) GetGames(ctx context.Context, _ GetGamesRequestObject, _ *db.User) (GetGamesResponseObject, error) {
- gameRows, err := h.q.ListPublicGames(ctx)
- if err != nil {
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
- }
- games := make([]Game, len(gameRows))
- gameIDs := make([]int32, len(gameRows))
- gameID2Index := make(map[int32]int, len(gameRows))
- for i, row := range gameRows {
- var startedAt *int64
- if row.StartedAt.Valid {
- startedAtTimestamp := row.StartedAt.Time.Unix()
- startedAt = &startedAtTimestamp
- }
- games[i] = Game{
- GameID: int(row.GameID),
- GameType: GameType(row.GameType),
- IsPublic: row.IsPublic,
- DisplayName: row.DisplayName,
- DurationSeconds: int(row.DurationSeconds),
- StartedAt: startedAt,
- Problem: Problem{
- ProblemID: int(row.ProblemID),
- Title: row.Title,
- Description: row.Description,
- Language: ProblemLanguage(row.Language),
- SampleCode: row.SampleCode,
- },
- }
- gameIDs[i] = row.GameID
- gameID2Index[row.GameID] = i
- }
- mainPlayerRows, err := h.q.ListMainPlayers(ctx, gameIDs)
+ games, err := h.gameSvc.ListPublicGames(ctx)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- for _, row := range mainPlayerRows {
- idx := gameID2Index[row.GameID]
- game := &games[idx]
- game.MainPlayers = append(game.MainPlayers, User{
- UserID: int(row.UserID),
- Username: row.Username,
- DisplayName: row.DisplayName,
- IconPath: row.IconPath,
- IsAdmin: row.IsAdmin,
- Label: toNullable(row.Label),
- })
+ apiGames := make([]Game, len(games))
+ for i, g := range games {
+ apiGames[i] = toAPIGame(g)
}
- return GetGames200JSONResponse{
- Games: games,
- }, nil
+ return GetGames200JSONResponse{Games: apiGames}, nil
}
func (h *Handler) GetGame(ctx context.Context, request GetGameRequestObject, user *db.User) (GetGameResponseObject, error) {
- gameID := request.GameID
- row, err := h.q.GetGameByID(ctx, int32(gameID))
+ isAdmin := user != nil && user.IsAdmin
+ g, err := h.gameSvc.GetGameByID(ctx, request.GameID, isAdmin)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return GetGame404JSONResponse{
- Message: "Game not found",
- }, nil
+ if errors.Is(err, game.ErrNotFound) {
+ return GetGame404JSONResponse{Message: "Game not found"}, nil
}
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- if !row.IsPublic && (user == nil || !user.IsAdmin) {
- return GetGame404JSONResponse{
- Message: "Game not found",
- }, nil
- }
- var startedAt *int64
- if row.StartedAt.Valid {
- startedAtTimestamp := row.StartedAt.Time.Unix()
- startedAt = &startedAtTimestamp
- }
- mainPlayerRows, err := h.q.ListMainPlayers(ctx, []int32{int32(gameID)})
- if err != nil {
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
- }
- mainPlayers := make([]User, len(mainPlayerRows))
- for i, playerRow := range mainPlayerRows {
- mainPlayers[i] = User{
- UserID: int(playerRow.UserID),
- Username: playerRow.Username,
- DisplayName: playerRow.DisplayName,
- IconPath: playerRow.IconPath,
- IsAdmin: playerRow.IsAdmin,
- Label: toNullable(playerRow.Label),
- }
- }
- game := Game{
- GameID: int(row.GameID),
- GameType: GameType(row.GameType),
- IsPublic: row.IsPublic,
- DisplayName: row.DisplayName,
- DurationSeconds: int(row.DurationSeconds),
- StartedAt: startedAt,
- Problem: Problem{
- ProblemID: int(row.ProblemID),
- Title: row.Title,
- Description: row.Description,
- Language: ProblemLanguage(row.Language),
- SampleCode: row.SampleCode,
- },
- MainPlayers: mainPlayers,
- }
- return GetGame200JSONResponse{
- Game: game,
- }, nil
+ return GetGame200JSONResponse{Game: toAPIGame(g)}, nil
}
func (h *Handler) GetGamePlayLatestState(ctx context.Context, request GetGamePlayLatestStateRequestObject, user *db.User) (GetGamePlayLatestStateResponseObject, error) {
- gameID := request.GameID
- row, err := h.q.GetLatestState(ctx, db.GetLatestStateParams{
- GameID: int32(gameID),
- UserID: user.UserID,
- })
+ state, err := h.gameSvc.GetLatestState(ctx, request.GameID, user.UserID)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return GetGamePlayLatestState200JSONResponse{
- State: LatestGameState{
- Code: "",
- Score: nullable.NewNullNullable[int](),
- BestScoreSubmittedAt: nullable.NewNullNullable[int64](),
- Status: None,
- },
- }, nil
- }
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- return GetGamePlayLatestState200JSONResponse{
- State: LatestGameState{
- Code: row.Code,
- Score: toNullableWith(row.CodeSize, func(x int32) int { return int(x) }),
- BestScoreSubmittedAt: nullable.NewNullableWithValue(row.CreatedAt.Time.Unix()),
- Status: ExecutionStatus(row.Status),
- },
- }, nil
+ return GetGamePlayLatestState200JSONResponse{State: toAPILatestState(state)}, nil
}
func (h *Handler) GetGameWatchLatestStates(ctx context.Context, request GetGameWatchLatestStatesRequestObject, user *db.User) (GetGameWatchLatestStatesResponseObject, error) {
- gameID := request.GameID
- rows, err := h.q.GetLatestStatesOfMainPlayers(ctx, int32(gameID))
- if err != nil {
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ var userID *int32
+ var isAdmin bool
+ if user != nil {
+ userID = &user.UserID
+ isAdmin = user.IsAdmin
}
- states := make(map[string]LatestGameState, len(rows))
- for _, row := range rows {
- var code string
- if row.Code != nil {
- code = *row.Code
- }
- var status ExecutionStatus
- if row.Status != nil {
- status = ExecutionStatus(*row.Status)
- } else {
- status = None
- }
- states[strconv.Itoa(int(row.UserID))] = LatestGameState{
- Code: code,
- Score: toNullableWith(row.CodeSize, func(x int32) int { return int(x) }),
- BestScoreSubmittedAt: nullable.NewNullableWithValue(row.CreatedAt.Time.Unix()),
- Status: status,
- }
-
- if user != nil && row.UserID == user.UserID && !user.IsAdmin {
+ stateMap, err := h.gameSvc.GetWatchLatestStates(ctx, request.GameID, userID, isAdmin)
+ if err != nil {
+ if errors.Is(err, game.ErrForbidden) {
return GetGameWatchLatestStates403JSONResponse{
Message: "You are one of the main players of this game",
}, nil
}
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- return GetGameWatchLatestStates200JSONResponse{
- States: states,
- }, nil
+ states := make(map[string]LatestGameState, len(stateMap))
+ for uid, s := range stateMap {
+ states[strconv.Itoa(uid)] = toAPILatestState(s)
+ }
+ return GetGameWatchLatestStates200JSONResponse{States: states}, nil
}
func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchRankingRequestObject, _ *db.User) (GetGameWatchRankingResponseObject, error) {
- gameID := request.GameID
-
- gameRow, err := h.q.GetGameByID(ctx, int32(gameID))
+ ranking, _, err := h.gameSvc.GetRanking(ctx, request.GameID)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return GetGameWatchRanking404JSONResponse{
- Message: "Game not found",
- }, nil
+ if errors.Is(err, game.ErrNotFound) {
+ return GetGameWatchRanking404JSONResponse{Message: "Game not found"}, nil
}
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- gameFinished := isGameFinished(gameRow)
-
- rows, err := h.q.GetRanking(ctx, int32(gameID))
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return GetGameWatchRanking200JSONResponse{}, nil
- }
- }
- ranking := make([]RankingEntry, len(rows))
- for i, row := range rows {
- var code nullable.Nullable[string]
- if gameFinished {
- code = nullable.NewNullableWithValue(row.Submission.Code)
- } else {
- code = nullable.NewNullNullable[string]()
- }
- ranking[i] = RankingEntry{
- Player: User{
- UserID: int(row.User.UserID),
- Username: row.User.Username,
- DisplayName: row.User.DisplayName,
- IconPath: row.User.IconPath,
- IsAdmin: row.User.IsAdmin,
- Label: toNullable(row.User.Label),
- },
- Score: int(row.Submission.CodeSize),
- SubmittedAt: row.Submission.CreatedAt.Time.Unix(),
- Code: code,
- }
+ apiRanking := make([]RankingEntry, len(ranking))
+ for i, r := range ranking {
+ apiRanking[i] = toAPIRankingEntry(r)
}
- return GetGameWatchRanking200JSONResponse{
- Ranking: ranking,
- }, nil
+ return GetGameWatchRanking200JSONResponse{Ranking: apiRanking}, nil
}
func (h *Handler) GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject, user *db.User) (GetGamePlaySubmissionsResponseObject, error) {
- gameID := request.GameID
-
- _, err := h.q.GetGameByID(ctx, int32(gameID))
+ submissions, err := h.gameSvc.GetSubmissions(ctx, request.GameID, user.UserID)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return GetGamePlaySubmissions404JSONResponse{
- Message: "Game not found",
- }, nil
+ if errors.Is(err, game.ErrNotFound) {
+ return GetGamePlaySubmissions404JSONResponse{Message: "Game not found"}, nil
}
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
-
- rows, err := h.q.GetSubmissionsByGameIDAndUserID(ctx, db.GetSubmissionsByGameIDAndUserIDParams{
- GameID: int32(gameID),
- UserID: user.UserID,
- })
- if err != nil {
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
- }
-
- submissions := make([]Submission, len(rows))
- for i, row := range rows {
- submissions[i] = Submission{
- SubmissionID: int(row.SubmissionID),
- GameID: int(row.GameID),
- Code: row.Code,
- CodeSize: int(row.CodeSize),
- Status: ExecutionStatus(row.Status),
- CreatedAt: row.CreatedAt.Time.Unix(),
- }
+ apiSubmissions := make([]Submission, len(submissions))
+ for i, s := range submissions {
+ apiSubmissions[i] = toAPISubmission(s)
}
-
- return GetGamePlaySubmissions200JSONResponse{
- Submissions: submissions,
- }, nil
+ return GetGamePlaySubmissions200JSONResponse{Submissions: apiSubmissions}, nil
}
func (h *Handler) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject, user *db.User) (PostGamePlayCodeResponseObject, error) {
- gameID := request.GameID
-
- gameRow, err := h.q.GetGameByID(ctx, int32(gameID))
+ err := h.gameSvc.SaveCode(ctx, request.GameID, user.UserID, request.Body.Code)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return PostGamePlayCode404JSONResponse{
- Message: "Game not found",
- }, nil
+ if errors.Is(err, game.ErrNotFound) {
+ return PostGamePlayCode404JSONResponse{Message: "Game not found"}, nil
+ }
+ if errors.Is(err, game.ErrGameNotRunning) {
+ return PostGamePlayCode403JSONResponse{Message: "Game is not running"}, nil
}
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
- }
- if !isGameRunning(gameRow) {
- return PostGamePlayCode403JSONResponse{
- Message: "Game is not running",
- }, nil
- }
-
- err = h.q.UpdateCode(ctx, db.UpdateCodeParams{
- GameID: int32(gameID),
- UserID: user.UserID,
- Code: request.Body.Code,
- Status: "none",
- })
- if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return PostGamePlayCode200Response{}, nil
}
func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySubmitRequestObject, user *db.User) (PostGamePlaySubmitResponseObject, error) {
- gameID := request.GameID
- code := request.Body.Code
-
- gameRow, err := h.q.GetGameByID(ctx, int32(gameID))
+ err := h.gameSvc.SubmitCode(ctx, request.GameID, user.UserID, request.Body.Code)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
+ if errors.Is(err, game.ErrNotFound) {
return PostGamePlaySubmit404JSONResponse{}, nil
}
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
- }
-
- language := gameRow.Language
- codeSize := h.hub.CalcCodeSize(code, language)
-
- if !isGameRunning(gameRow) {
- return PostGamePlaySubmit403JSONResponse{
- Message: "Game is not running",
- }, nil
- }
-
- 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
+ if errors.Is(err, game.ErrGameNotRunning) {
+ return PostGamePlaySubmit403JSONResponse{Message: "Game is not running"}, nil
}
- 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())
- }
-
- err = h.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, int(user.UserID), language, code)
- if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return PostGamePlaySubmit200Response{}, nil
}
func (h *Handler) GetTournament(ctx context.Context, request GetTournamentRequestObject, _ *db.User) (GetTournamentResponseObject, error) {
- tournamentID := int32(request.TournamentID)
-
- tournament, err := h.q.GetTournamentByID(ctx, tournamentID)
+ t, err := h.tournamentSvc.GetTournament(ctx, request.TournamentID)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
+ if errors.Is(err, game.ErrNotFound) {
return GetTournament404JSONResponse{Message: "Tournament not found"}, nil
}
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
-
- entryRows, err := h.q.ListTournamentEntries(ctx, tournamentID)
- if err != nil {
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
- }
-
- seedToUser := make(map[int]User)
- entries := make([]TournamentEntry, len(entryRows))
- for i, e := range entryRows {
- u := User{
- UserID: int(e.UserID),
- Username: e.Username,
- DisplayName: e.DisplayName,
- IconPath: e.IconPath,
- IsAdmin: e.IsAdmin,
- Label: toNullable(e.Label),
- }
- seedToUser[int(e.Seed)] = u
- entries[i] = TournamentEntry{
- User: u,
- Seed: int(e.Seed),
- }
- }
-
- matchRows, err := h.q.ListTournamentMatches(ctx, tournamentID)
- if err != nil {
- return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
- }
-
- bracketSize := int(tournament.BracketSize)
- numRounds := int(tournament.NumRounds)
- bracketSeeds := standardBracketSeeds(bracketSize)
-
- // Index matches by (round, position)
- type matchKey struct{ round, position int }
- matchByKey := make(map[matchKey]db.TournamentMatch)
- for _, m := range matchRows {
- matchByKey[matchKey{int(m.Round), int(m.Position)}] = m
- }
-
- // Collect game IDs for batch fetching
- gameIDs := make(map[int32]bool)
- for _, m := range matchRows {
- if m.GameID != nil {
- gameIDs[*m.GameID] = true
- }
- }
-
- // Fetch rankings for all games that have started
- type rankingResult struct {
- scores map[int]int // userID -> score
- winnerID int
- }
- gameRankings := make(map[int32]*rankingResult)
- for gid := range gameIDs {
- gameRow, err := h.q.GetGameByID(ctx, gid)
- if err != nil {
- continue
- }
- if !gameRow.StartedAt.Valid {
- continue
- }
- rankingRows, err := h.q.GetRanking(ctx, gid)
- if err != nil || len(rankingRows) == 0 {
- continue
- }
- rr := &rankingResult{scores: make(map[int]int)}
- for i, r := range rankingRows {
- rr.scores[int(r.User.UserID)] = int(r.Submission.CodeSize)
- if i == 0 {
- rr.winnerID = int(r.User.UserID)
- }
- }
- gameRankings[gid] = rr
- }
-
- // Build match results bottom-up
- type matchResult struct {
- player1 *User
- player2 *User
- p1Score *int
- p2Score *int
- winnerUID *int
- isBye bool
- }
- resultByKey := make(map[matchKey]*matchResult)
-
- for round := range numRounds {
- numPositions := bracketSize / (1 << (round + 1))
- for pos := range numPositions {
- m, exists := matchByKey[matchKey{round, pos}]
- mr := &matchResult{}
-
- if round == 0 {
- // First round: resolve players from bracket seeds
- slot1 := pos * 2
- slot2 := pos*2 + 1
- seed1 := bracketSeeds[slot1]
- seed2 := bracketSeeds[slot2]
-
- if u, ok := seedToUser[seed1]; ok {
- mr.player1 = &u
- }
- if u, ok := seedToUser[seed2]; ok {
- mr.player2 = &u
- }
- } else {
- // Later rounds: resolve from child match winners
- child1 := resultByKey[matchKey{round - 1, pos * 2}]
- child2 := resultByKey[matchKey{round - 1, pos*2 + 1}]
-
- if child1 != nil && child1.winnerUID != nil {
- if u, ok := seedToUser[findSeedByUserID(entries, *child1.winnerUID)]; ok {
- mr.player1 = &u
- }
- }
- if child2 != nil && child2.winnerUID != nil {
- if u, ok := seedToUser[findSeedByUserID(entries, *child2.winnerUID)]; ok {
- mr.player2 = &u
- }
- }
- }
-
- // Check for bye
- if mr.player1 == nil && mr.player2 != nil {
- mr.isBye = true
- uid := mr.player2.UserID
- mr.winnerUID = &uid
- } else if mr.player1 != nil && mr.player2 == nil {
- mr.isBye = true
- uid := mr.player1.UserID
- mr.winnerUID = &uid
- }
-
- // Resolve scores from game
- if exists && m.GameID != nil && !mr.isBye {
- if rr, ok := gameRankings[*m.GameID]; ok {
- if mr.player1 != nil {
- if s, ok := rr.scores[mr.player1.UserID]; ok {
- score := s
- mr.p1Score = &score
- }
- }
- if mr.player2 != nil {
- if s, ok := rr.scores[mr.player2.UserID]; ok {
- score := s
- mr.p2Score = &score
- }
- }
- // Winner is the one with the best (lowest) score in the ranking
- if mr.player1 != nil && mr.player2 != nil {
- if rr.winnerID == mr.player1.UserID || rr.winnerID == mr.player2.UserID {
- w := rr.winnerID
- mr.winnerUID = &w
- } else {
- // Both players have scores; pick the one with lower score
- if mr.p1Score != nil && mr.p2Score != nil {
- if *mr.p1Score <= *mr.p2Score {
- w := mr.player1.UserID
- mr.winnerUID = &w
- } else {
- w := mr.player2.UserID
- mr.winnerUID = &w
- }
- }
- }
- }
- }
- }
-
- resultByKey[matchKey{round, pos}] = mr
- }
- }
-
- // Build API response matches
- apiMatches := make([]TournamentMatch, 0, len(matchRows))
- for round := 0; round < numRounds; round++ {
- numPositions := bracketSize / (1 << (round + 1))
- for pos := 0; pos < numPositions; pos++ {
- m, exists := matchByKey[matchKey{round, pos}]
- mr := resultByKey[matchKey{round, pos}]
-
- matchID := 0
- var gameID *int
- if exists {
- matchID = int(m.TournamentMatchID)
- if m.GameID != nil {
- gid := int(*m.GameID)
- gameID = &gid
- }
- }
-
- apiMatches = append(apiMatches, TournamentMatch{
- TournamentMatchID: matchID,
- Round: round,
- Position: pos,
- GameID: gameID,
- Player1: mr.player1,
- Player2: mr.player2,
- Player1Score: mr.p1Score,
- Player2Score: mr.p2Score,
- WinnerUserID: mr.winnerUID,
- IsBye: mr.isBye,
- })
- }
- }
-
- return GetTournament200JSONResponse{
- Tournament: Tournament{
- TournamentID: int(tournament.TournamentID),
- DisplayName: tournament.DisplayName,
- BracketSize: bracketSize,
- NumRounds: numRounds,
- Entries: entries,
- Matches: apiMatches,
- },
- }, nil
-}
-
-func findSeedByUserID(entries []TournamentEntry, userID int) int {
- for _, e := range entries {
- if e.User.UserID == userID {
- return e.Seed
- }
- }
- return 0
-}
-
-// standardBracketSeeds returns the seed assignments for each slot in a standard
-// single-elimination bracket. For bracket_size=8:
-// Position: [0]=1, [1]=8, [2]=5, [3]=4, [4]=3, [5]=6, [6]=7, [7]=2
-// This ensures Seed 1 vs Seed 2 are on opposite sides, and higher seeds face lower seeds.
-func standardBracketSeeds(bracketSize int) []int {
- seeds := make([]int, bracketSize)
- seeds[0] = 1
- // Build the bracket by repeatedly splitting
- for size := 2; size <= bracketSize; size *= 2 {
- // For each pair in the current level, the new opponent for seed[i]
- // is (size + 1 - seed[i])
- temp := make([]int, size)
- for i := 0; i < size/2; i++ {
- temp[i*2] = seeds[i]
- temp[i*2+1] = size + 1 - seeds[i]
- }
- copy(seeds, temp)
- }
- return seeds
-}
-
-func isGameRunning(game db.GetGameByIDRow) bool {
- if !game.StartedAt.Valid {
- return false
- }
- endTime := game.StartedAt.Time.Add(time.Duration(game.DurationSeconds) * time.Second)
- return time.Now().Before(endTime)
-}
-
-func isGameFinished(game db.GetGameByIDRow) bool {
- if !game.StartedAt.Valid {
- return false
- }
- endTime := game.StartedAt.Time.Add(time.Duration(game.DurationSeconds) * time.Second)
- return !time.Now().Before(endTime)
-}
-
-func toNullable[T any](p *T) nullable.Nullable[T] {
- if p == nil {
- return nullable.NewNullNullable[T]()
- }
- return nullable.NewNullableWithValue(*p)
-}
-
-func toNullableWith[T, U any](p *T, f func(T) U) nullable.Nullable[U] {
- if p == nil {
- return nullable.NewNullNullable[U]()
- }
- return nullable.NewNullableWithValue(f(*p))
+ return GetTournament200JSONResponse{Tournament: toAPITournament(t)}, nil
}
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: "<?php echo 1;",
- CodeSize: 14,
- Status: "success",
- CreatedAt: pgtype.Timestamp{Time: now, Valid: true},
- },
- {
- SubmissionID: 9,
- GameID: 1,
- UserID: 42,
- Code: "<?php echo 'hello';",
- CodeSize: 20,
- Status: "wrong_answer",
- CreatedAt: pgtype.Timestamp{Time: now.Add(-5 * time.Minute), Valid: true},
- },
- }, 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{},
- }
+ 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: "<?php echo 1;",
+ CodeSize: 14,
+ Status: "success",
+ CreatedAt: pgtype.Timestamp{Time: now, Valid: true},
+ },
+ {
+ SubmissionID: 9,
+ GameID: 1,
+ UserID: 42,
+ Code: "<?php echo 'hello';",
+ CodeSize: 20,
+ Status: "wrong_answer",
+ CreatedAt: pgtype.Timestamp{Time: now.Add(-5 * time.Minute), Valid: true},
+ },
+ }, nil
+ },
+ })
user := &db.User{UserID: 42}
resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{
GameID: 1,
@@ -282,13 +296,7 @@ func TestGetGamePlaySubmissions_WithSubmissions(t *testing.T) {
}
func TestPostGamePlaySubmit_GameNotFound(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.PostGamePlaySubmit(context.Background(), PostGamePlaySubmitRequestObject{
GameID: 999,
@@ -303,23 +311,17 @@ func TestPostGamePlaySubmit_GameNotFound(t *testing.T) {
}
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
- },
+ h := newTestHandlerWithHub(&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{},
- }
+ }, &mockGameHub{calcCodeSizeResult: 10})
user := &db.User{UserID: 1}
resp, err := h.PostGamePlaySubmit(context.Background(), PostGamePlaySubmitRequestObject{
GameID: 1,
@@ -335,121 +337,8 @@ 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{},
- }
+ h := newTestHandler(&mockQuerier{})
user := &db.User{
UserID: 1,
Username: "testuser",
@@ -476,13 +365,7 @@ func TestGetMe(t *testing.T) {
}
func TestGetGame_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.GetGame(context.Background(), GetGameRequestObject{GameID: 999}, user)
if err != nil {
@@ -494,21 +377,15 @@ func TestGetGame_NotFound(t *testing.T) {
}
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
- },
+ h := newTestHandler(&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 {
@@ -521,34 +398,28 @@ func TestGetGame_NonPublicAsNonAdmin(t *testing.T) {
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: "<?php",
- }, nil
- },
- listMainPlayersFunc: func(_ context.Context, _ []int32) ([]db.ListMainPlayersRow, error) {
- return []db.ListMainPlayersRow{
- {UserID: 1, Username: "player1", DisplayName: "Player 1", IsAdmin: false},
- }, nil
- },
+ h := newTestHandler(&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: "<?php",
+ }, nil
},
- txm: &mockTxManager{},
- hub: &mockGameHub{},
- auth: &mockAuthenticator{},
- conf: &config.Config{},
- }
+ listMainPlayersFunc: func(_ context.Context, _ []int32) ([]db.ListMainPlayersRow, error) {
+ return []db.ListMainPlayersRow{
+ {UserID: 1, Username: "player1", DisplayName: "Player 1", IsAdmin: false},
+ }, nil
+ },
+ })
user := &db.User{UserID: 1, IsAdmin: false}
resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 1}, user)
if err != nil {
@@ -571,11 +442,11 @@ func TestGetGame_PublicGameSuccess(t *testing.T) {
func TestPostLogin_AuthFailure(t *testing.T) {
h := Handler{
- q: &mockQuerier{},
- txm: &mockTxManager{},
- hub: &mockGameHub{},
- auth: &mockAuthenticator{loginErr: errors.New("invalid credentials")},
- conf: &config.Config{},
+ gameSvc: game.NewService(&mockQuerier{}, &mockTxManager{}, &mockGameHub{}),
+ tournamentSvc: tournament.NewService(&mockQuerier{}),
+ auth: &mockAuthenticator{loginErr: errors.New("invalid credentials")},
+ conf: &config.Config{},
+ q: &mockQuerier{},
}
resp, err := h.PostLogin(context.Background(), PostLoginRequestObject{
Body: &PostLoginJSONRequestBody{Username: "user", Password: "wrong"},
@@ -590,15 +461,14 @@ func TestPostLogin_AuthFailure(t *testing.T) {
func TestPostLogout(t *testing.T) {
h := Handler{
- q: &mockQuerier{},
- txm: &mockTxManager{},
- hub: &mockGameHub{},
- auth: &mockAuthenticator{},
- conf: &config.Config{BasePath: "/"},
+ gameSvc: game.NewService(&mockQuerier{}, &mockTxManager{}, &mockGameHub{}),
+ tournamentSvc: tournament.NewService(&mockQuerier{}),
+ auth: &mockAuthenticator{},
+ conf: &config.Config{BasePath: "/"},
+ q: &mockQuerier{},
}
user := &db.User{UserID: 1}
- // Set session ID in context
- ctx := context.WithValue(context.Background(), sessionIDContextKey{}, "hashed-session")
+ ctx := session.SetSessionIDInContext(context.Background(), "hashed-session")
resp, err := h.PostLogout(ctx, PostLogoutRequestObject{}, user)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -609,13 +479,7 @@ func TestPostLogout(t *testing.T) {
}
func TestGetGames_Empty(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.GetGames(context.Background(), GetGamesRequestObject{}, user)
if err != nil {
@@ -632,34 +496,28 @@ func TestGetGames_Empty(t *testing.T) {
func TestGetGames_WithGames(t *testing.T) {
now := time.Now()
- h := Handler{
- q: &mockQuerier{
- listPublicGamesFunc: func(_ context.Context) ([]db.ListPublicGamesRow, error) {
- return []db.ListPublicGamesRow{
- {
- GameID: 1,
- GameType: "golf",
- IsPublic: true,
- DisplayName: "Game 1",
- DurationSeconds: 300,
- StartedAt: pgtype.Timestamp{Time: now, Valid: true},
- ProblemID: 10,
- Title: "Problem 1",
- Description: "desc",
- Language: "php",
- SampleCode: "<?php",
- },
- }, nil
- },
- listMainPlayersFunc: func(_ context.Context, _ []int32) ([]db.ListMainPlayersRow, error) {
- return nil, nil
- },
+ h := newTestHandler(&mockQuerier{
+ listPublicGamesFunc: func(_ context.Context) ([]db.ListPublicGamesRow, error) {
+ return []db.ListPublicGamesRow{
+ {
+ GameID: 1,
+ GameType: "golf",
+ IsPublic: true,
+ DisplayName: "Game 1",
+ DurationSeconds: 300,
+ StartedAt: pgtype.Timestamp{Time: now, Valid: true},
+ ProblemID: 10,
+ Title: "Problem 1",
+ Description: "desc",
+ Language: "php",
+ SampleCode: "<?php",
+ },
+ }, nil
},
- txm: &mockTxManager{},
- hub: &mockGameHub{},
- auth: &mockAuthenticator{},
- conf: &config.Config{},
- }
+ listMainPlayersFunc: func(_ context.Context, _ []int32) ([]db.ListMainPlayersRow, error) {
+ return nil, nil
+ },
+ })
user := &db.User{UserID: 1}
resp, err := h.GetGames(context.Background(), GetGamesRequestObject{}, user)
if err != nil {
@@ -681,13 +539,7 @@ func TestGetGames_WithGames(t *testing.T) {
}
func TestGetGamePlayLatestState_NoState(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.GetGamePlayLatestState(context.Background(), GetGamePlayLatestStateRequestObject{GameID: 1}, user)
if err != nil {
@@ -706,13 +558,7 @@ func TestGetGamePlayLatestState_NoState(t *testing.T) {
}
func TestPostGamePlayCode_GameNotFound(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.PostGamePlayCode(context.Background(), PostGamePlayCodeRequestObject{
GameID: 999,
@@ -727,23 +573,17 @@ func TestPostGamePlayCode_GameNotFound(t *testing.T) {
}
func TestPostGamePlayCode_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
- },
+ h := newTestHandler(&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{},
- auth: &mockAuthenticator{},
- conf: &config.Config{},
- }
+ })
user := &db.User{UserID: 1}
resp, err := h.PostGamePlayCode(context.Background(), PostGamePlayCodeRequestObject{
GameID: 1,
@@ -760,26 +600,20 @@ func TestPostGamePlayCode_GameNotRunning(t *testing.T) {
func TestPostGamePlayCode_Success(t *testing.T) {
now := time.Now()
var updatedCode string
- h := Handler{
- q: &mockQuerier{
- getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) {
- return db.GetGameByIDRow{
- GameID: 1,
- Language: "php",
- StartedAt: pgtype.Timestamp{Time: now, Valid: true},
- DurationSeconds: 600,
- }, nil
- },
- updateCodeFunc: func(_ context.Context, arg db.UpdateCodeParams) error {
- updatedCode = arg.Code
- return nil
- },
+ h := newTestHandler(&mockQuerier{
+ getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) {
+ return db.GetGameByIDRow{
+ GameID: 1,
+ Language: "php",
+ StartedAt: pgtype.Timestamp{Time: now, Valid: true},
+ DurationSeconds: 600,
+ }, nil
},
- txm: &mockTxManager{},
- hub: &mockGameHub{},
- auth: &mockAuthenticator{},
- conf: &config.Config{},
- }
+ updateCodeFunc: func(_ context.Context, arg db.UpdateCodeParams) error {
+ updatedCode = arg.Code
+ return nil
+ },
+ })
user := &db.User{UserID: 1}
resp, err := h.PostGamePlayCode(context.Background(), PostGamePlayCodeRequestObject{
GameID: 1,
@@ -797,13 +631,7 @@ func TestPostGamePlayCode_Success(t *testing.T) {
}
func TestGetGameWatchRanking_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.GetGameWatchRanking(context.Background(), GetGameWatchRankingRequestObject{GameID: 999}, user)
if err != nil {
@@ -816,25 +644,19 @@ func TestGetGameWatchRanking_NotFound(t *testing.T) {
func TestGetGameWatchRanking_EmptyRanking(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",
- StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true},
- DurationSeconds: 300,
- }, nil
- },
- getRankingFunc: func(_ context.Context, _ int32) ([]db.GetRankingRow, error) {
- return nil, nil
- },
+ h := newTestHandler(&mockQuerier{
+ getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) {
+ return db.GetGameByIDRow{
+ GameID: 1,
+ Language: "php",
+ StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true},
+ DurationSeconds: 300,
+ }, nil
},
- txm: &mockTxManager{},
- hub: &mockGameHub{},
- auth: &mockAuthenticator{},
- conf: &config.Config{},
- }
+ getRankingFunc: func(_ context.Context, _ int32) ([]db.GetRankingRow, error) {
+ return nil, nil
+ },
+ })
user := &db.User{UserID: 1}
resp, err := h.GetGameWatchRanking(context.Background(), GetGameWatchRankingRequestObject{GameID: 1}, user)
if err != nil {
@@ -850,13 +672,7 @@ func TestGetGameWatchRanking_EmptyRanking(t *testing.T) {
}
func TestGetGameWatchLatestStates_Empty(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.GetGameWatchLatestStates(context.Background(), GetGameWatchLatestStatesRequestObject{GameID: 1}, user)
if err != nil {
@@ -871,16 +687,16 @@ func TestGetGameWatchLatestStates_Empty(t *testing.T) {
}
}
-func TestToNullableWith(t *testing.T) {
+func TestToNullable(t *testing.T) {
t.Run("nil value", func(t *testing.T) {
- result := toNullableWith(nil, func(_ int) string { return "x" })
+ result := toNullable[string](nil)
if !result.IsNull() {
t.Error("expected null for nil input")
}
})
t.Run("non-nil value", func(t *testing.T) {
- x := 42
- result := toNullableWith(&x, func(_ int) string { return "hello" })
+ s := "hello"
+ result := toNullable(&s)
if result.IsNull() {
t.Error("expected non-null for non-nil input")
}
@@ -894,111 +710,33 @@ func TestToNullableWith(t *testing.T) {
})
}
-// --- Tournament tests ---
-
-func TestStandardBracketSeeds(t *testing.T) {
- tests := []struct {
- name string
- bracketSize int
- expected []int
- }{
- {
- name: "bracket_size=2",
- bracketSize: 2,
- expected: []int{1, 2},
- },
- {
- name: "bracket_size=4",
- bracketSize: 4,
- expected: []int{1, 4, 2, 3},
- },
- {
- name: "bracket_size=8",
- bracketSize: 8,
- expected: []int{1, 8, 4, 5, 2, 7, 3, 6},
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := standardBracketSeeds(tt.bracketSize)
- if len(got) != len(tt.expected) {
- t.Fatalf("expected length %d, got %d", len(tt.expected), len(got))
- }
- for i, v := range tt.expected {
- if got[i] != v {
- t.Errorf("position %d: expected seed %d, got %d", i, v, got[i])
- }
- }
- })
- }
-}
-
-func TestStandardBracketSeeds_Seed1And2OppositeSides(t *testing.T) {
- seeds := standardBracketSeeds(8)
- // Seed 1 should be in the first half, Seed 2 in the second half
- seed1Pos := -1
- seed2Pos := -1
- for i, s := range seeds {
- if s == 1 {
- seed1Pos = i
+func TestToNullableWith(t *testing.T) {
+ t.Run("nil value", func(t *testing.T) {
+ result := toNullableWith(nil, func(_ int) string { return "x" })
+ if !result.IsNull() {
+ t.Error("expected null for nil input")
}
- if s == 2 {
- seed2Pos = i
+ })
+ t.Run("non-nil value", func(t *testing.T) {
+ x := 42
+ result := toNullableWith(&x, func(_ int) string { return "hello" })
+ if result.IsNull() {
+ t.Error("expected non-null for non-nil input")
}
- }
- if seed1Pos >= 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")
diff --git a/backend/api/handler_wrapper.go b/backend/api/handler_wrapper.go
index 1c8bc83..6a6c724 100644
--- a/backend/api/handler_wrapper.go
+++ b/backend/api/handler_wrapper.go
@@ -7,6 +7,9 @@ import (
"albatross-2026-backend/config"
"albatross-2026-backend/db"
+ "albatross-2026-backend/game"
+ "albatross-2026-backend/session"
+ "albatross-2026-backend/tournament"
)
var _ StrictServerInterface = (*HandlerWrapper)(nil)
@@ -15,25 +18,25 @@ type HandlerWrapper struct {
impl Handler
}
-func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth AuthenticatorInterface, conf *config.Config) *HandlerWrapper {
+func NewHandler(gameSvc *game.Service, tournamentSvc *tournament.Service, auth AuthenticatorInterface, queries db.Querier, conf *config.Config) *HandlerWrapper {
return &HandlerWrapper{
impl: Handler{
- q: queries,
- txm: txm,
- hub: hub,
- auth: auth,
- conf: conf,
+ gameSvc: gameSvc,
+ tournamentSvc: tournamentSvc,
+ auth: auth,
+ conf: conf,
+ q: queries,
},
}
}
func (h *HandlerWrapper) GetGame(ctx context.Context, request GetGameRequestObject) (GetGameResponseObject, error) {
- user, _ := GetUserFromContext(ctx)
+ user, _ := session.GetUserFromContext(ctx)
return h.impl.GetGame(ctx, request, user)
}
func (h *HandlerWrapper) GetGamePlayLatestState(ctx context.Context, request GetGamePlayLatestStateRequestObject) (GetGamePlayLatestStateResponseObject, error) {
- user, ok := GetUserFromContext(ctx)
+ user, ok := session.GetUserFromContext(ctx)
if !ok {
return GetGamePlayLatestState401JSONResponse{
Message: "Unauthorized",
@@ -43,7 +46,7 @@ func (h *HandlerWrapper) GetGamePlayLatestState(ctx context.Context, request Get
}
func (h *HandlerWrapper) GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject) (GetGamePlaySubmissionsResponseObject, error) {
- user, ok := GetUserFromContext(ctx)
+ user, ok := session.GetUserFromContext(ctx)
if !ok {
return GetGamePlaySubmissions401JSONResponse{
Message: "Unauthorized",
@@ -53,22 +56,22 @@ func (h *HandlerWrapper) GetGamePlaySubmissions(ctx context.Context, request Get
}
func (h *HandlerWrapper) GetGameWatchLatestStates(ctx context.Context, request GetGameWatchLatestStatesRequestObject) (GetGameWatchLatestStatesResponseObject, error) {
- user, _ := GetUserFromContext(ctx)
+ user, _ := session.GetUserFromContext(ctx)
return h.impl.GetGameWatchLatestStates(ctx, request, user)
}
func (h *HandlerWrapper) GetGameWatchRanking(ctx context.Context, request GetGameWatchRankingRequestObject) (GetGameWatchRankingResponseObject, error) {
- user, _ := GetUserFromContext(ctx)
+ user, _ := session.GetUserFromContext(ctx)
return h.impl.GetGameWatchRanking(ctx, request, user)
}
func (h *HandlerWrapper) GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error) {
- user, _ := GetUserFromContext(ctx)
+ user, _ := session.GetUserFromContext(ctx)
return h.impl.GetGames(ctx, request, user)
}
func (h *HandlerWrapper) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) {
- user, ok := GetUserFromContext(ctx)
+ user, ok := session.GetUserFromContext(ctx)
if !ok {
return GetMe401JSONResponse{
Message: "Unauthorized",
@@ -78,12 +81,12 @@ func (h *HandlerWrapper) GetMe(ctx context.Context, request GetMeRequestObject)
}
func (h *HandlerWrapper) GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error) {
- user, _ := GetUserFromContext(ctx)
+ user, _ := session.GetUserFromContext(ctx)
return h.impl.GetTournament(ctx, request, user)
}
func (h *HandlerWrapper) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject) (PostGamePlayCodeResponseObject, error) {
- user, ok := GetUserFromContext(ctx)
+ user, ok := session.GetUserFromContext(ctx)
if !ok {
return PostGamePlayCode401JSONResponse{
Message: "Unauthorized",
@@ -93,7 +96,7 @@ func (h *HandlerWrapper) PostGamePlayCode(ctx context.Context, request PostGameP
}
func (h *HandlerWrapper) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySubmitRequestObject) (PostGamePlaySubmitResponseObject, error) {
- user, ok := GetUserFromContext(ctx)
+ user, ok := session.GetUserFromContext(ctx)
if !ok {
return PostGamePlaySubmit401JSONResponse{
Message: "Unauthorized",
@@ -107,7 +110,7 @@ func (h *HandlerWrapper) PostLogin(ctx context.Context, request PostLoginRequest
}
func (h *HandlerWrapper) PostLogout(ctx context.Context, request PostLogoutRequestObject) (PostLogoutResponseObject, error) {
- user, ok := GetUserFromContext(ctx)
+ user, ok := session.GetUserFromContext(ctx)
if !ok {
return PostLogout401JSONResponse{
Message: "Unauthorized",
diff --git a/backend/game/errors.go b/backend/game/errors.go
new file mode 100644
index 0000000..9f7505a
--- /dev/null
+++ b/backend/game/errors.go
@@ -0,0 +1,9 @@
+package game
+
+import "errors"
+
+var (
+ ErrNotFound = errors.New("not found")
+ ErrGameNotRunning = errors.New("game is not running")
+ ErrForbidden = errors.New("forbidden")
+)
diff --git a/backend/game/service.go b/backend/game/service.go
new file mode 100644
index 0000000..86e0eb3
--- /dev/null
+++ b/backend/game/service.go
@@ -0,0 +1,406 @@
+package game
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "albatross-2026-backend/db"
+)
+
+type GameHubInterface interface {
+ CalcCodeSize(code string, language string) int
+ EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error
+}
+
+type Service struct {
+ q db.Querier
+ txm db.TxManager
+ hub GameHubInterface
+}
+
+func NewService(q db.Querier, txm db.TxManager, hub GameHubInterface) *Service {
+ return &Service{q: q, txm: txm, hub: hub}
+}
+
+// Domain types
+
+type Player struct {
+ UserID int
+ Username string
+ DisplayName string
+ IconPath *string
+ IsAdmin bool
+ Label *string
+}
+
+type ProblemDetail struct {
+ ProblemID int
+ Title string
+ Description string
+ Language string
+ SampleCode string
+}
+
+type GameDetail struct {
+ GameID int
+ GameType string
+ IsPublic bool
+ DisplayName string
+ DurationSeconds int
+ StartedAt *time.Time
+ Problem ProblemDetail
+ MainPlayers []Player
+}
+
+type LatestState struct {
+ Code string
+ Score *int
+ BestScoreSubmittedAt *int64
+ Status string
+}
+
+type RankingEntry struct {
+ Player Player
+ Score int
+ SubmittedAt int64
+ Code *string
+}
+
+type SubmissionDetail struct {
+ SubmissionID int
+ GameID int
+ Code string
+ CodeSize int
+ Status string
+ CreatedAt int64
+}
+
+// Helper functions
+
+func IsGameRunning(startedAt pgtype.Timestamp, durationSeconds int32) bool {
+ if !startedAt.Valid {
+ return false
+ }
+ endTime := startedAt.Time.Add(time.Duration(durationSeconds) * time.Second)
+ return time.Now().Before(endTime)
+}
+
+func IsGameFinished(startedAt pgtype.Timestamp, durationSeconds int32) bool {
+ if !startedAt.Valid {
+ return false
+ }
+ endTime := startedAt.Time.Add(time.Duration(durationSeconds) * time.Second)
+ return !time.Now().Before(endTime)
+}
+
+func playerFromMainPlayerRow(row db.ListMainPlayersRow) Player {
+ return Player{
+ UserID: int(row.UserID),
+ Username: row.Username,
+ DisplayName: row.DisplayName,
+ IconPath: row.IconPath,
+ IsAdmin: row.IsAdmin,
+ Label: row.Label,
+ }
+}
+
+func gameDetailFromPublicRow(row db.ListPublicGamesRow) GameDetail {
+ var startedAt *time.Time
+ if row.StartedAt.Valid {
+ t := row.StartedAt.Time
+ startedAt = &t
+ }
+ return GameDetail{
+ GameID: int(row.GameID),
+ GameType: row.GameType,
+ IsPublic: row.IsPublic,
+ DisplayName: row.DisplayName,
+ DurationSeconds: int(row.DurationSeconds),
+ StartedAt: startedAt,
+ Problem: ProblemDetail{
+ ProblemID: int(row.ProblemID),
+ Title: row.Title,
+ Description: row.Description,
+ Language: row.Language,
+ SampleCode: row.SampleCode,
+ },
+ }
+}
+
+func gameDetailFromGetRow(row db.GetGameByIDRow) GameDetail {
+ var startedAt *time.Time
+ if row.StartedAt.Valid {
+ t := row.StartedAt.Time
+ startedAt = &t
+ }
+ return GameDetail{
+ GameID: int(row.GameID),
+ GameType: row.GameType,
+ IsPublic: row.IsPublic,
+ DisplayName: row.DisplayName,
+ DurationSeconds: int(row.DurationSeconds),
+ StartedAt: startedAt,
+ Problem: ProblemDetail{
+ ProblemID: int(row.ProblemID),
+ Title: row.Title,
+ Description: row.Description,
+ Language: row.Language,
+ SampleCode: row.SampleCode,
+ },
+ }
+}
+
+// Service methods
+
+func (s *Service) ListPublicGames(ctx context.Context) ([]GameDetail, error) {
+ gameRows, err := s.q.ListPublicGames(ctx)
+ if err != nil {
+ return nil, err
+ }
+ games := make([]GameDetail, len(gameRows))
+ gameIDs := make([]int32, len(gameRows))
+ gameID2Index := make(map[int32]int, len(gameRows))
+ for i, row := range gameRows {
+ games[i] = gameDetailFromPublicRow(row)
+ gameIDs[i] = row.GameID
+ gameID2Index[row.GameID] = i
+ }
+ mainPlayerRows, err := s.q.ListMainPlayers(ctx, gameIDs)
+ if err != nil {
+ return nil, err
+ }
+ for _, row := range mainPlayerRows {
+ idx := gameID2Index[row.GameID]
+ games[idx].MainPlayers = append(games[idx].MainPlayers, playerFromMainPlayerRow(row))
+ }
+ return games, nil
+}
+
+func (s *Service) GetGameByID(ctx context.Context, gameID int, isAdmin bool) (GameDetail, error) {
+ row, err := s.q.GetGameByID(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return GameDetail{}, ErrNotFound
+ }
+ return GameDetail{}, err
+ }
+ if !row.IsPublic && !isAdmin {
+ return GameDetail{}, ErrNotFound
+ }
+ game := gameDetailFromGetRow(row)
+ mainPlayerRows, err := s.q.ListMainPlayers(ctx, []int32{int32(gameID)})
+ if err != nil {
+ return GameDetail{}, err
+ }
+ for _, playerRow := range mainPlayerRows {
+ game.MainPlayers = append(game.MainPlayers, playerFromMainPlayerRow(playerRow))
+ }
+ return game, nil
+}
+
+func (s *Service) SaveCode(ctx context.Context, gameID int, userID int32, code string) error {
+ gameRow, err := s.q.GetGameByID(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+ if !IsGameRunning(gameRow.StartedAt, gameRow.DurationSeconds) {
+ return ErrGameNotRunning
+ }
+ return s.q.UpdateCode(ctx, db.UpdateCodeParams{
+ GameID: int32(gameID),
+ UserID: userID,
+ Code: code,
+ Status: "none",
+ })
+}
+
+func (s *Service) SubmitCode(ctx context.Context, gameID int, userID int32, code string) error {
+ gameRow, err := s.q.GetGameByID(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+
+ language := gameRow.Language
+ codeSize := s.hub.CalcCodeSize(code, language)
+
+ if !IsGameRunning(gameRow.StartedAt, gameRow.DurationSeconds) {
+ return ErrGameNotRunning
+ }
+
+ var submissionID int32
+ err = s.txm.RunInTx(ctx, func(qtx db.Querier) error {
+ if err := qtx.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{
+ GameID: int32(gameID),
+ UserID: userID,
+ Code: code,
+ Status: "running",
+ }); err != nil {
+ return err
+ }
+ var err error
+ submissionID, err = qtx.CreateSubmission(ctx, db.CreateSubmissionParams{
+ GameID: int32(gameID),
+ UserID: userID,
+ Code: code,
+ CodeSize: int32(codeSize),
+ })
+ return err
+ })
+ if err != nil {
+ return err
+ }
+
+ return s.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, int(userID), language, code)
+}
+
+func (s *Service) GetLatestState(ctx context.Context, gameID int, userID int32) (LatestState, error) {
+ row, err := s.q.GetLatestState(ctx, db.GetLatestStateParams{
+ GameID: int32(gameID),
+ UserID: userID,
+ })
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return LatestState{Status: "none"}, nil
+ }
+ return LatestState{}, err
+ }
+ var score *int
+ if row.CodeSize != nil {
+ s := int(*row.CodeSize)
+ score = &s
+ }
+ var submittedAt *int64
+ if row.CreatedAt.Valid {
+ ts := row.CreatedAt.Time.Unix()
+ submittedAt = &ts
+ }
+ return LatestState{
+ Code: row.Code,
+ Score: score,
+ BestScoreSubmittedAt: submittedAt,
+ Status: row.Status,
+ }, nil
+}
+
+func (s *Service) GetWatchLatestStates(ctx context.Context, gameID int, userID *int32, isAdmin bool) (map[int]LatestState, error) {
+ rows, err := s.q.GetLatestStatesOfMainPlayers(ctx, int32(gameID))
+ if err != nil {
+ return nil, err
+ }
+ states := make(map[int]LatestState, len(rows))
+ for _, row := range rows {
+ var code string
+ if row.Code != nil {
+ code = *row.Code
+ }
+ var status string
+ if row.Status != nil {
+ status = *row.Status
+ } else {
+ status = "none"
+ }
+ var score *int
+ if row.CodeSize != nil {
+ s := int(*row.CodeSize)
+ score = &s
+ }
+ var submittedAt *int64
+ if row.CreatedAt.Valid {
+ ts := row.CreatedAt.Time.Unix()
+ submittedAt = &ts
+ }
+
+ if userID != nil && row.UserID == *userID && !isAdmin {
+ return nil, ErrForbidden
+ }
+
+ states[int(row.UserID)] = LatestState{
+ Code: code,
+ Score: score,
+ BestScoreSubmittedAt: submittedAt,
+ Status: status,
+ }
+ }
+ return states, nil
+}
+
+func (s *Service) GetRanking(ctx context.Context, gameID int) ([]RankingEntry, bool, error) {
+ gameRow, err := s.q.GetGameByID(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, false, ErrNotFound
+ }
+ return nil, false, err
+ }
+ finished := IsGameFinished(gameRow.StartedAt, gameRow.DurationSeconds)
+
+ rows, err := s.q.GetRanking(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, finished, nil
+ }
+ return nil, false, err
+ }
+ ranking := make([]RankingEntry, len(rows))
+ for i, row := range rows {
+ var code *string
+ if finished {
+ code = &row.Submission.Code
+ }
+ ranking[i] = RankingEntry{
+ Player: Player{
+ UserID: int(row.User.UserID),
+ Username: row.User.Username,
+ DisplayName: row.User.DisplayName,
+ IconPath: row.User.IconPath,
+ IsAdmin: row.User.IsAdmin,
+ Label: row.User.Label,
+ },
+ Score: int(row.Submission.CodeSize),
+ SubmittedAt: row.Submission.CreatedAt.Time.Unix(),
+ Code: code,
+ }
+ }
+ return ranking, finished, nil
+}
+
+func (s *Service) GetSubmissions(ctx context.Context, gameID int, userID int32) ([]SubmissionDetail, error) {
+ _, err := s.q.GetGameByID(ctx, int32(gameID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+
+ rows, err := s.q.GetSubmissionsByGameIDAndUserID(ctx, db.GetSubmissionsByGameIDAndUserIDParams{
+ GameID: int32(gameID),
+ UserID: userID,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ submissions := make([]SubmissionDetail, len(rows))
+ for i, row := range rows {
+ submissions[i] = SubmissionDetail{
+ SubmissionID: int(row.SubmissionID),
+ GameID: int(row.GameID),
+ Code: row.Code,
+ CodeSize: int(row.CodeSize),
+ Status: row.Status,
+ CreatedAt: row.CreatedAt.Time.Unix(),
+ }
+ }
+ return submissions, nil
+}
diff --git a/backend/game/service_test.go b/backend/game/service_test.go
new file mode 100644
index 0000000..95ceef6
--- /dev/null
+++ b/backend/game/service_test.go
@@ -0,0 +1,82 @@
+package game
+
+import (
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func TestIsGameRunning(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ startedAt pgtype.Timestamp
+ durationSeconds int32
+ want bool
+ }{
+ {
+ name: "not started",
+ startedAt: pgtype.Timestamp{Valid: false},
+ durationSeconds: 300,
+ want: false,
+ },
+ {
+ name: "running",
+ startedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true},
+ durationSeconds: 300,
+ want: true,
+ },
+ {
+ name: "finished",
+ 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.startedAt, tt.durationSeconds)
+ 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
+ startedAt pgtype.Timestamp
+ durationSeconds int32
+ want bool
+ }{
+ {
+ name: "not started",
+ startedAt: pgtype.Timestamp{Valid: false},
+ durationSeconds: 300,
+ want: false,
+ },
+ {
+ name: "still running",
+ startedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true},
+ durationSeconds: 300,
+ want: false,
+ },
+ {
+ name: "finished",
+ 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.startedAt, tt.durationSeconds)
+ if got != tt.want {
+ t.Errorf("IsGameFinished() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/backend/gen/api/handler_wrapper_gen.go b/backend/gen/api/handler_wrapper_gen.go
index 88f55c6..ef2baa1 100644
--- a/backend/gen/api/handler_wrapper_gen.go
+++ b/backend/gen/api/handler_wrapper_gen.go
@@ -117,6 +117,9 @@ import (
"albatross-2026-backend/config"
"albatross-2026-backend/db"
+ "albatross-2026-backend/game"
+ "albatross-2026-backend/session"
+ "albatross-2026-backend/tournament"
)
var _ StrictServerInterface = (*HandlerWrapper)(nil)
@@ -125,14 +128,14 @@ type HandlerWrapper struct {
impl Handler
}
-func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth AuthenticatorInterface, conf *config.Config) *HandlerWrapper {
+func NewHandler(gameSvc *game.Service, tournamentSvc *tournament.Service, auth AuthenticatorInterface, queries db.Querier, conf *config.Config) *HandlerWrapper {
return &HandlerWrapper{
impl: Handler{
- q: queries,
- txm: txm,
- hub: hub,
- auth: auth,
- conf: conf,
+ gameSvc: gameSvc,
+ tournamentSvc: tournamentSvc,
+ auth: auth,
+ conf: conf,
+ q: queries,
},
}
}
@@ -140,7 +143,7 @@ func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth
{{ range . }}
func (h *HandlerWrapper) {{ .Name }}(ctx context.Context, request {{ .Name }}RequestObject) ({{ .Name }}ResponseObject, error) {
{{ if .RequiresLogin -}}
- user, ok := GetUserFromContext(ctx)
+ user, ok := session.GetUserFromContext(ctx)
if !ok {
return {{ .Name }}401JSONResponse{
Message: "Unauthorized",
@@ -155,7 +158,7 @@ func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth
{{ end -}}
return h.impl.{{ .Name }}(ctx, request, user)
{{ else if .LoginOptional -}}
- user, _ := GetUserFromContext(ctx)
+ user, _ := session.GetUserFromContext(ctx)
return h.impl.{{ .Name }}(ctx, request, user)
{{ else -}}
return h.impl.{{ .Name }}(ctx, request)
diff --git a/backend/main.go b/backend/main.go
index 2825b84..7220ca6 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -24,6 +24,7 @@ import (
"albatross-2026-backend/game"
"albatross-2026-backend/ratelimit"
"albatross-2026-backend/taskqueue"
+ "albatross-2026-backend/tournament"
)
func connectDB(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
@@ -105,7 +106,9 @@ func main() {
apiGroup.Use(ratelimit.LoginRateLimitMiddleware(loginRL))
apiGroup.Use(api.SessionCookieMiddleware(queries))
apiGroup.Use(oapimiddleware.OapiRequestValidator(openAPISpec))
- apiHandler := api.NewHandler(queries, txm, gameHub, authenticator, conf)
+ gameSvc := game.NewService(queries, txm, gameHub)
+ tournamentSvc := tournament.NewService(queries)
+ apiHandler := api.NewHandler(gameSvc, tournamentSvc, authenticator, queries, conf)
api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil))
adminHandler := admin.NewHandler(queries, txm, gameHub, conf)
diff --git a/backend/session/context.go b/backend/session/context.go
new file mode 100644
index 0000000..60f88b4
--- /dev/null
+++ b/backend/session/context.go
@@ -0,0 +1,39 @@
+package session
+
+import (
+ "context"
+
+ "albatross-2026-backend/db"
+)
+
+type sessionIDContextKey struct{}
+type userContextKey struct{}
+type clientIPContextKey struct{}
+
+func GetSessionIDFromContext(ctx context.Context) (string, bool) {
+ sessionID, ok := ctx.Value(sessionIDContextKey{}).(string)
+ return sessionID, ok
+}
+
+func SetSessionIDInContext(ctx context.Context, sessionID string) context.Context {
+ return context.WithValue(ctx, sessionIDContextKey{}, sessionID)
+}
+
+func GetUserFromContext(ctx context.Context) (*db.User, bool) {
+ user, ok := ctx.Value(userContextKey{}).(*db.User)
+ return user, ok
+}
+
+// SetUserInContext sets a user in the context. Intended for testing.
+func SetUserInContext(ctx context.Context, user *db.User) context.Context {
+ return context.WithValue(ctx, userContextKey{}, user)
+}
+
+func GetClientIPFromContext(ctx context.Context) string {
+ ip, _ := ctx.Value(clientIPContextKey{}).(string)
+ return ip
+}
+
+func SetClientIPInContext(ctx context.Context, ip string) context.Context {
+ return context.WithValue(ctx, clientIPContextKey{}, ip)
+}
diff --git a/backend/tournament/service.go b/backend/tournament/service.go
new file mode 100644
index 0000000..1bc8aeb
--- /dev/null
+++ b/backend/tournament/service.go
@@ -0,0 +1,303 @@
+package tournament
+
+import (
+ "context"
+ "errors"
+
+ "github.com/jackc/pgx/v5"
+
+ "albatross-2026-backend/db"
+ "albatross-2026-backend/game"
+)
+
+type Service struct {
+ q db.Querier
+}
+
+func NewService(q db.Querier) *Service {
+ return &Service{q: q}
+}
+
+// Domain types
+
+type Player struct {
+ UserID int
+ Username string
+ DisplayName string
+ IconPath *string
+ IsAdmin bool
+ Label *string
+}
+
+type TournamentEntry struct {
+ User Player
+ Seed int
+}
+
+type TournamentMatch struct {
+ TournamentMatchID int
+ Round int
+ Position int
+ GameID *int
+ Player1 *Player
+ Player2 *Player
+ Player1Score *int
+ Player2Score *int
+ WinnerUserID *int
+ IsBye bool
+}
+
+type TournamentBracket struct {
+ TournamentID int
+ DisplayName string
+ BracketSize int
+ NumRounds int
+ Entries []TournamentEntry
+ Matches []TournamentMatch
+}
+
+// StandardBracketSeeds returns the seed assignments for each slot in a standard
+// single-elimination bracket.
+func StandardBracketSeeds(bracketSize int) []int {
+ seeds := make([]int, bracketSize)
+ seeds[0] = 1
+ for size := 2; size <= bracketSize; size *= 2 {
+ temp := make([]int, size)
+ for i := 0; i < size/2; i++ {
+ temp[i*2] = seeds[i]
+ temp[i*2+1] = size + 1 - seeds[i]
+ }
+ copy(seeds, temp)
+ }
+ return seeds
+}
+
+func findSeedByUserID(entries []TournamentEntry, userID int) int {
+ for _, e := range entries {
+ if e.User.UserID == userID {
+ return e.Seed
+ }
+ }
+ return 0
+}
+
+func (s *Service) GetTournament(ctx context.Context, tournamentID int) (TournamentBracket, error) {
+ t, err := s.q.GetTournamentByID(ctx, int32(tournamentID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return TournamentBracket{}, game.ErrNotFound
+ }
+ return TournamentBracket{}, err
+ }
+
+ entryRows, err := s.q.ListTournamentEntries(ctx, int32(tournamentID))
+ if err != nil {
+ return TournamentBracket{}, err
+ }
+
+ seedToUser := make(map[int]Player)
+ entries := make([]TournamentEntry, len(entryRows))
+ for i, e := range entryRows {
+ u := Player{
+ UserID: int(e.UserID),
+ Username: e.Username,
+ DisplayName: e.DisplayName,
+ IconPath: e.IconPath,
+ IsAdmin: e.IsAdmin,
+ Label: e.Label,
+ }
+ seedToUser[int(e.Seed)] = u
+ entries[i] = TournamentEntry{
+ User: u,
+ Seed: int(e.Seed),
+ }
+ }
+
+ matchRows, err := s.q.ListTournamentMatches(ctx, int32(tournamentID))
+ if err != nil {
+ return TournamentBracket{}, err
+ }
+
+ bracketSize := int(t.BracketSize)
+ numRounds := int(t.NumRounds)
+ bracketSeeds := StandardBracketSeeds(bracketSize)
+
+ // Index matches by (round, position)
+ type matchKey struct{ round, position int }
+ matchByKey := make(map[matchKey]db.TournamentMatch)
+ for _, m := range matchRows {
+ matchByKey[matchKey{int(m.Round), int(m.Position)}] = m
+ }
+
+ // Collect game IDs for batch fetching
+ gameIDs := make(map[int32]bool)
+ for _, m := range matchRows {
+ if m.GameID != nil {
+ gameIDs[*m.GameID] = true
+ }
+ }
+
+ // Fetch rankings for all games that have started
+ type rankingResult struct {
+ scores map[int]int // userID -> score
+ winnerID int
+ }
+ gameRankings := make(map[int32]*rankingResult)
+ for gid := range gameIDs {
+ gameRow, err := s.q.GetGameByID(ctx, gid)
+ if err != nil {
+ continue
+ }
+ if !gameRow.StartedAt.Valid {
+ continue
+ }
+ rankingRows, err := s.q.GetRanking(ctx, gid)
+ if err != nil || len(rankingRows) == 0 {
+ continue
+ }
+ rr := &rankingResult{scores: make(map[int]int)}
+ for i, r := range rankingRows {
+ rr.scores[int(r.User.UserID)] = int(r.Submission.CodeSize)
+ if i == 0 {
+ rr.winnerID = int(r.User.UserID)
+ }
+ }
+ gameRankings[gid] = rr
+ }
+
+ // Build match results bottom-up
+ type matchResult struct {
+ player1 *Player
+ player2 *Player
+ p1Score *int
+ p2Score *int
+ winnerUID *int
+ isBye bool
+ }
+ resultByKey := make(map[matchKey]*matchResult)
+
+ for round := range numRounds {
+ numPositions := bracketSize / (1 << (round + 1))
+ for pos := range numPositions {
+ m, exists := matchByKey[matchKey{round, pos}]
+ mr := &matchResult{}
+
+ if round == 0 {
+ slot1 := pos * 2
+ slot2 := pos*2 + 1
+ seed1 := bracketSeeds[slot1]
+ seed2 := bracketSeeds[slot2]
+
+ if u, ok := seedToUser[seed1]; ok {
+ mr.player1 = &u
+ }
+ if u, ok := seedToUser[seed2]; ok {
+ mr.player2 = &u
+ }
+ } else {
+ child1 := resultByKey[matchKey{round - 1, pos * 2}]
+ child2 := resultByKey[matchKey{round - 1, pos*2 + 1}]
+
+ if child1 != nil && child1.winnerUID != nil {
+ if u, ok := seedToUser[findSeedByUserID(entries, *child1.winnerUID)]; ok {
+ mr.player1 = &u
+ }
+ }
+ if child2 != nil && child2.winnerUID != nil {
+ if u, ok := seedToUser[findSeedByUserID(entries, *child2.winnerUID)]; ok {
+ mr.player2 = &u
+ }
+ }
+ }
+
+ // Check for bye
+ if mr.player1 == nil && mr.player2 != nil {
+ mr.isBye = true
+ uid := mr.player2.UserID
+ mr.winnerUID = &uid
+ } else if mr.player1 != nil && mr.player2 == nil {
+ mr.isBye = true
+ uid := mr.player1.UserID
+ mr.winnerUID = &uid
+ }
+
+ // Resolve scores from game
+ if exists && m.GameID != nil && !mr.isBye {
+ if rr, ok := gameRankings[*m.GameID]; ok {
+ if mr.player1 != nil {
+ if s, ok := rr.scores[mr.player1.UserID]; ok {
+ score := s
+ mr.p1Score = &score
+ }
+ }
+ if mr.player2 != nil {
+ if s, ok := rr.scores[mr.player2.UserID]; ok {
+ score := s
+ mr.p2Score = &score
+ }
+ }
+ if mr.player1 != nil && mr.player2 != nil {
+ if rr.winnerID == mr.player1.UserID || rr.winnerID == mr.player2.UserID {
+ w := rr.winnerID
+ mr.winnerUID = &w
+ } else {
+ if mr.p1Score != nil && mr.p2Score != nil {
+ if *mr.p1Score <= *mr.p2Score {
+ w := mr.player1.UserID
+ mr.winnerUID = &w
+ } else {
+ w := mr.player2.UserID
+ mr.winnerUID = &w
+ }
+ }
+ }
+ }
+ }
+ }
+
+ resultByKey[matchKey{round, pos}] = mr
+ }
+ }
+
+ // Build response matches
+ apiMatches := make([]TournamentMatch, 0, len(matchRows))
+ for round := 0; round < numRounds; round++ {
+ numPositions := bracketSize / (1 << (round + 1))
+ for pos := 0; pos < numPositions; pos++ {
+ m, exists := matchByKey[matchKey{round, pos}]
+ mr := resultByKey[matchKey{round, pos}]
+
+ matchID := 0
+ var gameID *int
+ if exists {
+ matchID = int(m.TournamentMatchID)
+ if m.GameID != nil {
+ gid := int(*m.GameID)
+ gameID = &gid
+ }
+ }
+
+ apiMatches = append(apiMatches, TournamentMatch{
+ TournamentMatchID: matchID,
+ Round: round,
+ Position: pos,
+ GameID: gameID,
+ Player1: mr.player1,
+ Player2: mr.player2,
+ Player1Score: mr.p1Score,
+ Player2Score: mr.p2Score,
+ WinnerUserID: mr.winnerUID,
+ IsBye: mr.isBye,
+ })
+ }
+ }
+
+ return TournamentBracket{
+ TournamentID: int(t.TournamentID),
+ DisplayName: t.DisplayName,
+ BracketSize: bracketSize,
+ NumRounds: numRounds,
+ Entries: entries,
+ Matches: apiMatches,
+ }, nil
+}
diff --git a/backend/tournament/service_test.go b/backend/tournament/service_test.go
new file mode 100644
index 0000000..d1ca78c
--- /dev/null
+++ b/backend/tournament/service_test.go
@@ -0,0 +1,97 @@
+package tournament
+
+import "testing"
+
+func TestStandardBracketSeeds(t *testing.T) {
+ tests := []struct {
+ name string
+ bracketSize int
+ expected []int
+ }{
+ {
+ name: "bracket_size=2",
+ bracketSize: 2,
+ expected: []int{1, 2},
+ },
+ {
+ name: "bracket_size=4",
+ bracketSize: 4,
+ expected: []int{1, 4, 2, 3},
+ },
+ {
+ name: "bracket_size=8",
+ bracketSize: 8,
+ expected: []int{1, 8, 4, 5, 2, 7, 3, 6},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := StandardBracketSeeds(tt.bracketSize)
+ if len(got) != len(tt.expected) {
+ t.Fatalf("expected length %d, got %d", len(tt.expected), len(got))
+ }
+ for i, v := range tt.expected {
+ if got[i] != v {
+ t.Errorf("position %d: expected seed %d, got %d", i, v, got[i])
+ }
+ }
+ })
+ }
+}
+
+func TestStandardBracketSeeds_Seed1And2OppositeSides(t *testing.T) {
+ seeds := StandardBracketSeeds(8)
+ seed1Pos := -1
+ seed2Pos := -1
+ for i, s := range seeds {
+ if s == 1 {
+ seed1Pos = i
+ }
+ if s == 2 {
+ seed2Pos = i
+ }
+ }
+ if seed1Pos >= 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: Player{UserID: 10}, Seed: 1},
+ {User: Player{UserID: 20}, Seed: 2},
+ {User: Player{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)
+ }
+}