feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -0,0 +1,94 @@
package gamestore
import (
"encoding/json"
"fmt"
"time"
"galaxy/lobby/internal/domain/game"
)
// runtimeSnapshotJSON is the on-disk JSONB shape used for the denormalised
// runtime snapshot column on `games`. Keys mirror the field names in
// `game.RuntimeSnapshot` so a round-trip remains naked-comparable.
type runtimeSnapshotJSON struct {
CurrentTurn int `json:"current_turn"`
RuntimeStatus string `json:"runtime_status,omitempty"`
EngineHealthSummary string `json:"engine_health_summary,omitempty"`
}
func marshalRuntimeSnapshot(snapshot game.RuntimeSnapshot) ([]byte, error) {
payload := runtimeSnapshotJSON{
CurrentTurn: snapshot.CurrentTurn,
RuntimeStatus: snapshot.RuntimeStatus,
EngineHealthSummary: snapshot.EngineHealthSummary,
}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal runtime snapshot: %w", err)
}
return encoded, nil
}
func unmarshalRuntimeSnapshot(payload []byte) (game.RuntimeSnapshot, error) {
if len(payload) == 0 {
return game.RuntimeSnapshot{}, nil
}
var stored runtimeSnapshotJSON
if err := json.Unmarshal(payload, &stored); err != nil {
return game.RuntimeSnapshot{}, fmt.Errorf("unmarshal runtime snapshot: %w", err)
}
return game.RuntimeSnapshot{
CurrentTurn: stored.CurrentTurn,
RuntimeStatus: stored.RuntimeStatus,
EngineHealthSummary: stored.EngineHealthSummary,
}, nil
}
// runtimeBindingJSON is the on-disk JSONB shape used for the optional
// runtime binding column on `games`. The `bound_at_ms` field stores Unix
// milliseconds so the JSON serialisation matches the previous Redis JSON
// shape and the timezone is irrelevant inside the JSON payload itself; the
// adapter still re-wraps the resulting time.Time with .UTC() before exposing
// it to callers.
type runtimeBindingJSON struct {
ContainerID string `json:"container_id"`
EngineEndpoint string `json:"engine_endpoint"`
RuntimeJobID string `json:"runtime_job_id"`
BoundAtMS int64 `json:"bound_at_ms"`
}
// marshalRuntimeBinding returns nil bytes (SQL NULL) when binding is nil,
// otherwise the JSON encoding of the binding.
func marshalRuntimeBinding(binding *game.RuntimeBinding) ([]byte, error) {
if binding == nil {
return nil, nil
}
payload := runtimeBindingJSON{
ContainerID: binding.ContainerID,
EngineEndpoint: binding.EngineEndpoint,
RuntimeJobID: binding.RuntimeJobID,
BoundAtMS: binding.BoundAt.UTC().UnixMilli(),
}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal runtime binding: %w", err)
}
return encoded, nil
}
func unmarshalRuntimeBinding(payload []byte) (*game.RuntimeBinding, error) {
if len(payload) == 0 {
return nil, nil
}
var stored runtimeBindingJSON
if err := json.Unmarshal(payload, &stored); err != nil {
return nil, fmt.Errorf("unmarshal runtime binding: %w", err)
}
return &game.RuntimeBinding{
ContainerID: stored.ContainerID,
EngineEndpoint: stored.EngineEndpoint,
RuntimeJobID: stored.RuntimeJobID,
BoundAt: time.UnixMilli(stored.BoundAtMS).UTC(),
}, nil
}
@@ -0,0 +1,610 @@
// Package gamestore implements the PostgreSQL-backed adapter for
// `ports.GameStore`.
//
// The package owns the on-disk shape of the `games` table (defined in
// `galaxy/lobby/internal/adapters/postgres/migrations`) and translates the
// schema-agnostic GameStore interface declared in `internal/ports` into
// concrete go-jet/v2 statements driven by the pgx driver. Per-row
// lifecycle transitions (Save/UpdateStatus/UpdateRuntimeSnapshot/
// UpdateRuntimeBinding) use optimistic concurrency on the `updated_at`
// column rather than retaining a `SELECT ... FOR UPDATE` lock across the
// caller's logic, mirroring the Notification Stage 5 pattern.
//
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
// game records; see `galaxy/lobby/docs/postgres-migration.md` for the full
// decision record.
package gamestore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/lobby/internal/adapters/postgres/jet/lobby/table"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed game store instance. The store
// does not own the underlying *sql.DB lifecycle: the caller (typically the
// service runtime) opens, instruments, migrates, and closes the pool.
type Config struct {
// DB stores the connection pool the store uses for every query.
DB *sql.DB
// OperationTimeout bounds one round trip. The store creates a derived
// context for each operation so callers cannot starve the pool with an
// unbounded ctx.
OperationTimeout time.Duration
}
// Store persists Game Lobby game records in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed game store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres game store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres game store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// gameSelectColumns is the canonical SELECT list for the games table,
// matching scanGame's column order.
var gameSelectColumns = pg.ColumnList{
pgtable.Games.GameID,
pgtable.Games.GameName,
pgtable.Games.Description,
pgtable.Games.GameType,
pgtable.Games.OwnerUserID,
pgtable.Games.Status,
pgtable.Games.MinPlayers,
pgtable.Games.MaxPlayers,
pgtable.Games.StartGapHours,
pgtable.Games.StartGapPlayers,
pgtable.Games.EnrollmentEndsAt,
pgtable.Games.TurnSchedule,
pgtable.Games.TargetEngineVersion,
pgtable.Games.CreatedAt,
pgtable.Games.UpdatedAt,
pgtable.Games.StartedAt,
pgtable.Games.FinishedAt,
pgtable.Games.RuntimeSnapshot,
pgtable.Games.RuntimeBinding,
}
// Save upserts record. The status secondary index is intrinsic to
// `games_status_created_idx` so callers see the same effect as the previous
// Redis adapter without the explicit index rewrite.
//
// The implementation is INSERT ... ON CONFLICT (game_id) DO UPDATE: the
// adapter cannot use plain INSERT because callers (notably the create-game
// service and admin updates) expect Save to be upsert.
func (store *Store) Save(ctx context.Context, record game.Game) error {
if store == nil || store.db == nil {
return errors.New("save game: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("save game: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save game", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
snapshot, err := marshalRuntimeSnapshot(record.RuntimeSnapshot)
if err != nil {
return fmt.Errorf("save game: %w", err)
}
binding, err := marshalRuntimeBinding(record.RuntimeBinding)
if err != nil {
return fmt.Errorf("save game: %w", err)
}
stmt := pgtable.Games.INSERT(
pgtable.Games.GameID,
pgtable.Games.GameName,
pgtable.Games.Description,
pgtable.Games.GameType,
pgtable.Games.OwnerUserID,
pgtable.Games.Status,
pgtable.Games.MinPlayers,
pgtable.Games.MaxPlayers,
pgtable.Games.StartGapHours,
pgtable.Games.StartGapPlayers,
pgtable.Games.EnrollmentEndsAt,
pgtable.Games.TurnSchedule,
pgtable.Games.TargetEngineVersion,
pgtable.Games.CreatedAt,
pgtable.Games.UpdatedAt,
pgtable.Games.StartedAt,
pgtable.Games.FinishedAt,
pgtable.Games.RuntimeSnapshot,
pgtable.Games.RuntimeBinding,
).VALUES(
record.GameID.String(),
record.GameName,
record.Description,
string(record.GameType),
record.OwnerUserID,
string(record.Status),
record.MinPlayers,
record.MaxPlayers,
record.StartGapHours,
record.StartGapPlayers,
record.EnrollmentEndsAt.UTC(),
record.TurnSchedule,
record.TargetEngineVersion,
record.CreatedAt.UTC(),
record.UpdatedAt.UTC(),
sqlx.NullableTimePtr(record.StartedAt),
sqlx.NullableTimePtr(record.FinishedAt),
snapshot,
binding,
).ON_CONFLICT(pgtable.Games.GameID).DO_UPDATE(
pg.SET(
pgtable.Games.GameName.SET(pgtable.Games.EXCLUDED.GameName),
pgtable.Games.Description.SET(pgtable.Games.EXCLUDED.Description),
pgtable.Games.GameType.SET(pgtable.Games.EXCLUDED.GameType),
pgtable.Games.OwnerUserID.SET(pgtable.Games.EXCLUDED.OwnerUserID),
pgtable.Games.Status.SET(pgtable.Games.EXCLUDED.Status),
pgtable.Games.MinPlayers.SET(pgtable.Games.EXCLUDED.MinPlayers),
pgtable.Games.MaxPlayers.SET(pgtable.Games.EXCLUDED.MaxPlayers),
pgtable.Games.StartGapHours.SET(pgtable.Games.EXCLUDED.StartGapHours),
pgtable.Games.StartGapPlayers.SET(pgtable.Games.EXCLUDED.StartGapPlayers),
pgtable.Games.EnrollmentEndsAt.SET(pgtable.Games.EXCLUDED.EnrollmentEndsAt),
pgtable.Games.TurnSchedule.SET(pgtable.Games.EXCLUDED.TurnSchedule),
pgtable.Games.TargetEngineVersion.SET(pgtable.Games.EXCLUDED.TargetEngineVersion),
pgtable.Games.UpdatedAt.SET(pgtable.Games.EXCLUDED.UpdatedAt),
pgtable.Games.StartedAt.SET(pgtable.Games.EXCLUDED.StartedAt),
pgtable.Games.FinishedAt.SET(pgtable.Games.EXCLUDED.FinishedAt),
pgtable.Games.RuntimeSnapshot.SET(pgtable.Games.EXCLUDED.RuntimeSnapshot),
pgtable.Games.RuntimeBinding.SET(pgtable.Games.EXCLUDED.RuntimeBinding),
),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
return fmt.Errorf("save game: %w", err)
}
return nil
}
// Get returns the record identified by gameID. It returns
// game.ErrNotFound when no record exists.
func (store *Store) Get(ctx context.Context, gameID common.GameID) (game.Game, error) {
if store == nil || store.db == nil {
return game.Game{}, errors.New("get game: nil store")
}
if err := gameID.Validate(); err != nil {
return game.Game{}, fmt.Errorf("get game: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get game", store.operationTimeout)
if err != nil {
return game.Game{}, err
}
defer cancel()
stmt := pg.SELECT(gameSelectColumns).
FROM(pgtable.Games).
WHERE(pgtable.Games.GameID.EQ(pg.String(gameID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanGame(row)
if sqlx.IsNoRows(err) {
return game.Game{}, game.ErrNotFound
}
if err != nil {
return game.Game{}, fmt.Errorf("get game: %w", err)
}
return record, nil
}
// GetByStatus returns every record whose status equals status. Records are
// sorted by created_at DESC then game_id DESC, matching the most-recent-first
// ordering Lobby's listing services expect.
func (store *Store) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) {
if store == nil || store.db == nil {
return nil, errors.New("get games by status: nil store")
}
if !status.IsKnown() {
return nil, fmt.Errorf("get games by status: status %q is unsupported", status)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get games by status", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(gameSelectColumns).
FROM(pgtable.Games).
WHERE(pgtable.Games.Status.EQ(pg.String(string(status)))).
ORDER_BY(pgtable.Games.CreatedAt.DESC(), pgtable.Games.GameID.DESC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("get games by status: %w", err)
}
defer rows.Close()
records, err := scanAllGames(rows)
if err != nil {
return nil, fmt.Errorf("get games by status: %w", err)
}
return records, nil
}
// CountByStatus returns the number of records under each known status.
func (store *Store) CountByStatus(ctx context.Context) (map[game.Status]int, error) {
if store == nil || store.db == nil {
return nil, errors.New("count games by status: nil store")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "count games by status", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
countAlias := pg.COUNT(pg.STAR).AS("count")
stmt := pg.SELECT(pgtable.Games.Status, countAlias).
FROM(pgtable.Games).
GROUP_BY(pgtable.Games.Status)
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("count games by status: %w", err)
}
defer rows.Close()
counts := make(map[game.Status]int, len(game.AllStatuses()))
for _, status := range game.AllStatuses() {
counts[status] = 0
}
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, fmt.Errorf("count games by status: scan: %w", err)
}
counts[game.Status(status)] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("count games by status: %w", err)
}
return counts, nil
}
// GetByOwner returns every record whose owner_user_id equals userID. The
// underlying `games_owner_idx` is partial (game_type = 'private'); public
// games carry an empty owner_user_id and are excluded from the index, matching
// the Redis-backed behaviour.
func (store *Store) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) {
if store == nil || store.db == nil {
return nil, errors.New("get games by owner: nil store")
}
trimmed := strings.TrimSpace(userID)
if trimmed == "" {
return nil, fmt.Errorf("get games by owner: user id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get games by owner", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(gameSelectColumns).
FROM(pgtable.Games).
WHERE(pgtable.Games.OwnerUserID.EQ(pg.String(trimmed))).
ORDER_BY(pgtable.Games.CreatedAt.DESC(), pgtable.Games.GameID.DESC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("get games by owner: %w", err)
}
defer rows.Close()
records, err := scanAllGames(rows)
if err != nil {
return nil, fmt.Errorf("get games by owner: %w", err)
}
return records, nil
}
// UpdateStatus applies one status transition with compare-and-swap on the
// current status column. The domain transition gate runs before any SQL
// touch.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
if store == nil || store.db == nil {
return errors.New("update game status: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update game status: %w", err)
}
if err := game.Transition(input.ExpectedFrom, input.To, input.Trigger); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update game status", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
at := input.At.UTC()
var startedAt, finishedAt any
if input.To == game.StatusRunning {
startedAt = at
}
if input.To == game.StatusFinished {
finishedAt = at
}
// COALESCE keeps the existing started_at/finished_at when the new value
// is NULL (the bind parameter is nil unless we are entering the
// running/finished state for the first time).
startedExpr := pg.COALESCE(pgtable.Games.StartedAt, pg.TimestampzT(at))
if startedAt == nil {
startedExpr = pgtable.Games.StartedAt
}
finishedExpr := pg.COALESCE(pgtable.Games.FinishedAt, pg.TimestampzT(at))
if finishedAt == nil {
finishedExpr = pgtable.Games.FinishedAt
}
stmt := pgtable.Games.UPDATE(
pgtable.Games.Status,
pgtable.Games.UpdatedAt,
pgtable.Games.StartedAt,
pgtable.Games.FinishedAt,
).SET(
pg.String(string(input.To)),
pg.TimestampzT(at),
startedExpr,
finishedExpr,
).WHERE(pg.AND(
pgtable.Games.GameID.EQ(pg.String(input.GameID.String())),
pgtable.Games.Status.EQ(pg.String(string(input.ExpectedFrom))),
))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update game status: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update game status: rows affected: %w", err)
}
if affected == 0 {
// distinguish "not found" from "status mismatch" with a follow-up read
probe := pg.SELECT(pgtable.Games.Status).
FROM(pgtable.Games).
WHERE(pgtable.Games.GameID.EQ(pg.String(input.GameID.String())))
probeQuery, probeArgs := probe.Sql()
var current string
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
if err := row.Scan(&current); err != nil {
if sqlx.IsNoRows(err) {
return game.ErrNotFound
}
return fmt.Errorf("update game status: probe: %w", err)
}
return fmt.Errorf("update game status: %w", game.ErrConflict)
}
return nil
}
// UpdateRuntimeSnapshot overwrites the denormalised runtime snapshot fields.
func (store *Store) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime snapshot: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update runtime snapshot: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime snapshot", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
snapshot, err := marshalRuntimeSnapshot(input.Snapshot)
if err != nil {
return fmt.Errorf("update runtime snapshot: %w", err)
}
at := input.At.UTC()
stmt := pgtable.Games.UPDATE(pgtable.Games.RuntimeSnapshot, pgtable.Games.UpdatedAt).
SET(snapshot, at).
WHERE(pgtable.Games.GameID.EQ(pg.String(input.GameID.String())))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime snapshot: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime snapshot: rows affected: %w", err)
}
if affected == 0 {
return game.ErrNotFound
}
return nil
}
// UpdateRuntimeBinding overwrites the runtime binding metadata.
func (store *Store) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime binding: nil store")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime binding", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
binding := input.Binding
encoded, err := marshalRuntimeBinding(&binding)
if err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
at := input.At.UTC()
stmt := pgtable.Games.UPDATE(pgtable.Games.RuntimeBinding, pgtable.Games.UpdatedAt).
SET(encoded, at).
WHERE(pgtable.Games.GameID.EQ(pg.String(input.GameID.String())))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime binding: rows affected: %w", err)
}
if affected == 0 {
return game.ErrNotFound
}
return nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanGame can be shared
// across both single-row reads and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanGame scans one games row from rs. Returns sql.ErrNoRows verbatim so
// callers can distinguish "no row" from a hard error.
func scanGame(rs rowScanner) (game.Game, error) {
var (
gameID string
gameName string
description string
gameType string
ownerUserID string
status string
minPlayers int
maxPlayers int
startGapHours int
startGapPlayers int
enrollmentEndsAt time.Time
turnSchedule string
targetEngineVersion string
createdAt time.Time
updatedAt time.Time
startedAt sql.NullTime
finishedAt sql.NullTime
runtimeSnapshot []byte
runtimeBinding []byte
)
if err := rs.Scan(
&gameID,
&gameName,
&description,
&gameType,
&ownerUserID,
&status,
&minPlayers,
&maxPlayers,
&startGapHours,
&startGapPlayers,
&enrollmentEndsAt,
&turnSchedule,
&targetEngineVersion,
&createdAt,
&updatedAt,
&startedAt,
&finishedAt,
&runtimeSnapshot,
&runtimeBinding,
); err != nil {
return game.Game{}, err
}
snapshot, err := unmarshalRuntimeSnapshot(runtimeSnapshot)
if err != nil {
return game.Game{}, err
}
binding, err := unmarshalRuntimeBinding(runtimeBinding)
if err != nil {
return game.Game{}, err
}
return game.Game{
GameID: common.GameID(gameID),
GameName: gameName,
Description: description,
GameType: game.GameType(gameType),
OwnerUserID: ownerUserID,
Status: game.Status(status),
MinPlayers: minPlayers,
MaxPlayers: maxPlayers,
StartGapHours: startGapHours,
StartGapPlayers: startGapPlayers,
EnrollmentEndsAt: enrollmentEndsAt.UTC(),
TurnSchedule: turnSchedule,
TargetEngineVersion: targetEngineVersion,
CreatedAt: createdAt.UTC(),
UpdatedAt: updatedAt.UTC(),
StartedAt: sqlx.TimePtrFromNullable(startedAt),
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
RuntimeSnapshot: snapshot,
RuntimeBinding: binding,
}, nil
}
func scanAllGames(rows *sql.Rows) ([]game.Game, error) {
records := make([]game.Game, 0)
for rows.Next() {
record, err := scanGame(rows)
if err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(records) == 0 {
return nil, nil
}
return records, nil
}
// Ensure Store satisfies the ports.GameStore interface at compile time.
var _ ports.GameStore = (*Store)(nil)
@@ -0,0 +1,338 @@
package gamestore_test
import (
"context"
"testing"
"time"
"galaxy/lobby/internal/adapters/postgres/gamestore"
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStore(t *testing.T) *gamestore.Store {
t.Helper()
pgtest.TruncateAll(t)
store, err := gamestore.New(gamestore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return store
}
func fixturePublicGame(t *testing.T, id string) game.Game {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
record, err := game.New(game.NewGameInput{
GameID: common.GameID(id),
GameName: "Spring Classic " + id,
Description: "first public game",
GameType: game.GameTypePublic,
MinPlayers: 4,
MaxPlayers: 8,
StartGapHours: 24,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.2.3",
Now: now,
})
require.NoError(t, err)
return record
}
func fixturePrivateGame(t *testing.T, id, ownerID string) game.Game {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
record, err := game.New(game.NewGameInput{
GameID: common.GameID(id),
GameName: "Private " + id,
GameType: game.GameTypePrivate,
OwnerUserID: ownerID,
MinPlayers: 2,
MaxPlayers: 6,
StartGapHours: 12,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.0.0",
Now: now,
})
require.NoError(t, err)
return record
}
func TestSaveAndGet(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, record.GameID, got.GameID)
assert.Equal(t, record.GameName, got.GameName)
assert.Equal(t, record.Status, got.Status)
assert.Equal(t, record.MinPlayers, got.MinPlayers)
assert.Equal(t, record.MaxPlayers, got.MaxPlayers)
assert.True(t, record.EnrollmentEndsAt.Equal(got.EnrollmentEndsAt))
assert.Equal(t, time.UTC, got.CreatedAt.Location())
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
}
func TestGetReturnsNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, common.GameID("game-missing-x"))
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestSaveIsUpsert(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
// edit a few fields, save again
record.GameName = "Renamed"
record.UpdatedAt = record.UpdatedAt.Add(time.Minute)
require.NoError(t, store.Save(ctx, record))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, "Renamed", got.GameName)
}
func TestUpdateStatusHappyPath(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: record.GameID,
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: record.UpdatedAt.Add(time.Minute),
}))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, game.StatusEnrollmentOpen, got.Status)
}
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: record.GameID,
ExpectedFrom: game.StatusEnrollmentOpen, // wrong
To: game.StatusReadyToStart,
Trigger: game.TriggerManual,
At: record.UpdatedAt.Add(time.Minute),
})
require.ErrorIs(t, err, game.ErrConflict)
}
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: common.GameID("game-missing-x"),
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: time.Now().UTC(),
})
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestUpdateStatusSetsStartedAtOnRunning(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
advance := func(from, to game.Status, trigger game.Trigger, at time.Time) {
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: record.GameID, ExpectedFrom: from, To: to, Trigger: trigger, At: at,
}))
}
now := record.UpdatedAt.Add(time.Minute)
advance(game.StatusDraft, game.StatusEnrollmentOpen, game.TriggerCommand, now)
advance(game.StatusEnrollmentOpen, game.StatusReadyToStart, game.TriggerManual, now.Add(time.Minute))
advance(game.StatusReadyToStart, game.StatusStarting, game.TriggerCommand, now.Add(2*time.Minute))
startedAt := now.Add(3 * time.Minute)
advance(game.StatusStarting, game.StatusRunning, game.TriggerRuntimeEvent, startedAt)
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, game.StatusRunning, got.Status)
require.NotNil(t, got.StartedAt)
assert.True(t, got.StartedAt.Equal(startedAt))
}
func TestGetByStatusReturnsExpectedRecords(t *testing.T) {
ctx := context.Background()
store := newStore(t)
a := fixturePublicGame(t, "game-aaa")
b := fixturePublicGame(t, "game-bbb")
c := fixturePublicGame(t, "game-ccc")
for _, r := range []game.Game{a, b, c} {
require.NoError(t, store.Save(ctx, r))
}
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: b.GameID,
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: b.UpdatedAt.Add(time.Minute),
}))
drafts, err := store.GetByStatus(ctx, game.StatusDraft)
require.NoError(t, err)
gotIDs := map[common.GameID]struct{}{}
for _, r := range drafts {
gotIDs[r.GameID] = struct{}{}
}
assert.Contains(t, gotIDs, a.GameID)
assert.Contains(t, gotIDs, c.GameID)
assert.NotContains(t, gotIDs, b.GameID)
open, err := store.GetByStatus(ctx, game.StatusEnrollmentOpen)
require.NoError(t, err)
require.Len(t, open, 1)
assert.Equal(t, b.GameID, open[0].GameID)
}
func TestGetByOwnerOnlyReturnsPrivateGames(t *testing.T) {
ctx := context.Background()
store := newStore(t)
owner := "user-123"
pub := fixturePublicGame(t, "game-pub-001")
priv1 := fixturePrivateGame(t, "game-priv-001", owner)
priv2 := fixturePrivateGame(t, "game-priv-002", owner)
priv3 := fixturePrivateGame(t, "game-priv-003", "user-other")
for _, r := range []game.Game{pub, priv1, priv2, priv3} {
require.NoError(t, store.Save(ctx, r))
}
got, err := store.GetByOwner(ctx, owner)
require.NoError(t, err)
ids := map[common.GameID]struct{}{}
for _, r := range got {
ids[r.GameID] = struct{}{}
}
assert.Contains(t, ids, priv1.GameID)
assert.Contains(t, ids, priv2.GameID)
assert.NotContains(t, ids, priv3.GameID)
assert.NotContains(t, ids, pub.GameID)
}
func TestCountByStatusIncludesAllBuckets(t *testing.T) {
ctx := context.Background()
store := newStore(t)
require.NoError(t, store.Save(ctx, fixturePublicGame(t, "game-aaa")))
require.NoError(t, store.Save(ctx, fixturePublicGame(t, "game-bbb")))
counts, err := store.CountByStatus(ctx)
require.NoError(t, err)
for _, status := range game.AllStatuses() {
_, ok := counts[status]
assert.Truef(t, ok, "missing bucket for %q", status)
}
assert.Equal(t, 2, counts[game.StatusDraft])
}
func TestUpdateRuntimeSnapshotRoundTripsValues(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
snapshot := game.RuntimeSnapshot{
CurrentTurn: 42,
RuntimeStatus: "running_accepting_commands",
EngineHealthSummary: "ok",
}
require.NoError(t, store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
GameID: record.GameID,
Snapshot: snapshot,
At: record.UpdatedAt.Add(time.Minute),
}))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, snapshot, got.RuntimeSnapshot)
}
func TestUpdateRuntimeBindingRoundTripsValues(t *testing.T) {
ctx := context.Background()
store := newStore(t)
record := fixturePublicGame(t, "game-001")
require.NoError(t, store.Save(ctx, record))
at := record.UpdatedAt.Add(time.Minute)
require.NoError(t, store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
GameID: record.GameID,
Binding: game.RuntimeBinding{
ContainerID: "container-7",
EngineEndpoint: "10.0.0.5:9000",
RuntimeJobID: "1700000000-0",
BoundAt: at,
},
At: at,
}))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
require.NotNil(t, got.RuntimeBinding)
assert.Equal(t, "container-7", got.RuntimeBinding.ContainerID)
assert.Equal(t, "10.0.0.5:9000", got.RuntimeBinding.EngineEndpoint)
assert.Equal(t, "1700000000-0", got.RuntimeBinding.RuntimeJobID)
assert.True(t, got.RuntimeBinding.BoundAt.Equal(at))
assert.Equal(t, time.UTC, got.RuntimeBinding.BoundAt.Location())
}
func TestUpdateRuntimeSnapshotReturnsNotFoundForMissing(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
GameID: common.GameID("game-missing-x"),
Snapshot: game.RuntimeSnapshot{CurrentTurn: 1},
At: time.Now().UTC(),
})
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestNewRejectsNilDB(t *testing.T) {
_, err := gamestore.New(gamestore.Config{OperationTimeout: time.Second})
require.Error(t, err)
}
func TestNewRejectsNonPositiveTimeout(t *testing.T) {
_, err := gamestore.New(gamestore.Config{DB: pgtest.Ensure(t).Pool()})
require.Error(t, err)
}