aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/api
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/api
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/api')
-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
6 files changed, 523 insertions, 1236 deletions
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",