feat: use postgres
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
// Package applicationstore implements the PostgreSQL-backed adapter for
|
||||
// `ports.ApplicationStore`.
|
||||
//
|
||||
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
|
||||
// application records; see `galaxy/lobby/docs/postgres-migration.md` for
|
||||
// the full decision record.
|
||||
package applicationstore
|
||||
|
||||
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/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed application store instance.
|
||||
type Config struct {
|
||||
// DB stores the connection pool the store uses for every query.
|
||||
DB *sql.DB
|
||||
|
||||
// OperationTimeout bounds one round trip.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Lobby application records in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed application store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres application store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres application store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// applicationSelectColumns is the canonical SELECT list for the applications
|
||||
// table, matching scanApplication's column order.
|
||||
var applicationSelectColumns = pg.ColumnList{
|
||||
pgtable.Applications.ApplicationID,
|
||||
pgtable.Applications.GameID,
|
||||
pgtable.Applications.ApplicantUserID,
|
||||
pgtable.Applications.RaceName,
|
||||
pgtable.Applications.Status,
|
||||
pgtable.Applications.CreatedAt,
|
||||
pgtable.Applications.DecidedAt,
|
||||
}
|
||||
|
||||
// Save persists a new submitted application record. The single-active
|
||||
// constraint is enforced by the partial unique index
|
||||
// `applications_active_per_user_game_uidx`.
|
||||
func (store *Store) Save(ctx context.Context, record application.Application) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("save application: nil store")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save application: %w", err)
|
||||
}
|
||||
if record.Status != application.StatusSubmitted {
|
||||
return fmt.Errorf(
|
||||
"save application: status must be %q, got %q",
|
||||
application.StatusSubmitted, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save application", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.Applications.INSERT(
|
||||
pgtable.Applications.ApplicationID,
|
||||
pgtable.Applications.GameID,
|
||||
pgtable.Applications.ApplicantUserID,
|
||||
pgtable.Applications.RaceName,
|
||||
pgtable.Applications.Status,
|
||||
pgtable.Applications.CreatedAt,
|
||||
pgtable.Applications.DecidedAt,
|
||||
).VALUES(
|
||||
record.ApplicationID.String(),
|
||||
record.GameID.String(),
|
||||
record.ApplicantUserID,
|
||||
record.RaceName,
|
||||
string(record.Status),
|
||||
record.CreatedAt.UTC(),
|
||||
sqlx.NullableTimePtr(record.DecidedAt),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
if sqlx.IsUniqueViolation(err) {
|
||||
return fmt.Errorf("save application: %w", application.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("save application: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the record identified by applicationID. It returns
|
||||
// application.ErrNotFound when no record exists.
|
||||
func (store *Store) Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return application.Application{}, errors.New("get application: nil store")
|
||||
}
|
||||
if err := applicationID.Validate(); err != nil {
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get application", store.operationTimeout)
|
||||
if err != nil {
|
||||
return application.Application{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(applicationSelectColumns).
|
||||
FROM(pgtable.Applications).
|
||||
WHERE(pgtable.Applications.ApplicationID.EQ(pg.String(applicationID.String())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
record, err := scanApplication(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return application.Application{}, application.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every application attached to gameID. Sorted by
|
||||
// created_at ASC then application_id ASC.
|
||||
func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("get applications by game: nil store")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get applications by game: %w", err)
|
||||
}
|
||||
|
||||
stmt := pg.SELECT(applicationSelectColumns).
|
||||
FROM(pgtable.Applications).
|
||||
WHERE(pgtable.Applications.GameID.EQ(pg.String(gameID.String()))).
|
||||
ORDER_BY(pgtable.Applications.CreatedAt.ASC(), pgtable.Applications.ApplicationID.ASC())
|
||||
|
||||
return store.queryList(ctx, "get applications by game", stmt)
|
||||
}
|
||||
|
||||
// GetByUser returns every application submitted by applicantUserID.
|
||||
func (store *Store) GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("get applications by user: nil store")
|
||||
}
|
||||
trimmed := strings.TrimSpace(applicantUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get applications by user: applicant user id must not be empty")
|
||||
}
|
||||
|
||||
stmt := pg.SELECT(applicationSelectColumns).
|
||||
FROM(pgtable.Applications).
|
||||
WHERE(pgtable.Applications.ApplicantUserID.EQ(pg.String(trimmed))).
|
||||
ORDER_BY(pgtable.Applications.CreatedAt.ASC(), pgtable.Applications.ApplicationID.ASC())
|
||||
|
||||
return store.queryList(ctx, "get applications by user", stmt)
|
||||
}
|
||||
|
||||
func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]application.Application, error) {
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, operation, store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]application.Application, 0)
|
||||
for rows.Next() {
|
||||
record, err := scanApplication(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: scan: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition with compare-and-swap on the
|
||||
// current status column.
|
||||
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateApplicationStatusInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update application status: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update application status: %w", err)
|
||||
}
|
||||
if err := application.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update application status", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
at := input.At.UTC()
|
||||
stmt := pgtable.Applications.UPDATE(pgtable.Applications.Status, pgtable.Applications.DecidedAt).
|
||||
SET(string(input.To), at).
|
||||
WHERE(pg.AND(
|
||||
pgtable.Applications.ApplicationID.EQ(pg.String(input.ApplicationID.String())),
|
||||
pgtable.Applications.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 application status: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update application status: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
probe := pg.SELECT(pgtable.Applications.Status).
|
||||
FROM(pgtable.Applications).
|
||||
WHERE(pgtable.Applications.ApplicationID.EQ(pg.String(input.ApplicationID.String())))
|
||||
probeQuery, probeArgs := probe.Sql()
|
||||
|
||||
var current string
|
||||
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
if sqlx.IsNoRows(err) {
|
||||
return application.ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("update application status: probe: %w", err)
|
||||
}
|
||||
return fmt.Errorf("update application status: %w", application.ErrConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanApplication(rs rowScanner) (application.Application, error) {
|
||||
var (
|
||||
applicationID string
|
||||
gameID string
|
||||
applicantUserID string
|
||||
raceName string
|
||||
status string
|
||||
createdAt time.Time
|
||||
decidedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
&applicationID,
|
||||
&gameID,
|
||||
&applicantUserID,
|
||||
&raceName,
|
||||
&status,
|
||||
&createdAt,
|
||||
&decidedAt,
|
||||
); err != nil {
|
||||
return application.Application{}, err
|
||||
}
|
||||
return application.Application{
|
||||
ApplicationID: common.ApplicationID(applicationID),
|
||||
GameID: common.GameID(gameID),
|
||||
ApplicantUserID: applicantUserID,
|
||||
RaceName: raceName,
|
||||
Status: application.Status(status),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
DecidedAt: sqlx.TimePtrFromNullable(decidedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.ApplicationStore interface at compile
|
||||
// time.
|
||||
var _ ports.ApplicationStore = (*Store)(nil)
|
||||
@@ -0,0 +1,194 @@
|
||||
package applicationstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/postgres/applicationstore"
|
||||
"galaxy/lobby/internal/adapters/postgres/gamestore"
|
||||
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"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 newStores(t *testing.T) (*gamestore.Store, *applicationstore.Store) {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
gs, err := gamestore.New(gamestore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
as, err := applicationstore.New(applicationstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return gs, as
|
||||
}
|
||||
|
||||
func seedGame(t *testing.T, gs *gamestore.Store, id string) game.Game {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
g, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID(id),
|
||||
GameName: "Game " + id,
|
||||
GameType: game.GameTypePublic,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 8,
|
||||
StartGapHours: 12,
|
||||
StartGapPlayers: 2,
|
||||
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gs.Save(context.Background(), g))
|
||||
return g
|
||||
}
|
||||
|
||||
func newApplication(t *testing.T, id, gameID, userID string) application.Application {
|
||||
t.Helper()
|
||||
a, err := application.New(application.NewApplicationInput{
|
||||
ApplicationID: common.ApplicationID(id),
|
||||
GameID: common.GameID(gameID),
|
||||
ApplicantUserID: userID,
|
||||
RaceName: "Pilot " + id,
|
||||
Now: time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return a
|
||||
}
|
||||
|
||||
func TestSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, as := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newApplication(t, "application-001", "game-001", "user-a")
|
||||
require.NoError(t, as.Save(ctx, rec))
|
||||
|
||||
got, err := as.Get(ctx, rec.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec.ApplicationID, got.ApplicationID)
|
||||
assert.Equal(t, application.StatusSubmitted, got.Status)
|
||||
assert.Equal(t, "user-a", got.ApplicantUserID)
|
||||
assert.Nil(t, got.DecidedAt)
|
||||
}
|
||||
|
||||
func TestSaveRejectsNonSubmittedRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, as := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newApplication(t, "application-001", "game-001", "user-a")
|
||||
rec.Status = application.StatusApproved
|
||||
require.Error(t, as.Save(ctx, rec))
|
||||
}
|
||||
|
||||
func TestSavePartialUniqueRejectsSecondActiveForSameUserGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, as := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
a1 := newApplication(t, "application-001", "game-001", "user-a")
|
||||
require.NoError(t, as.Save(ctx, a1))
|
||||
|
||||
// second submission by the same user against the same game must fail.
|
||||
a2 := newApplication(t, "application-002", "game-001", "user-a")
|
||||
err := as.Save(ctx, a2)
|
||||
require.ErrorIs(t, err, application.ErrConflict)
|
||||
}
|
||||
|
||||
func TestSavePartialUniqueAllowsResubmitAfterRejection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, as := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
a1 := newApplication(t, "application-001", "game-001", "user-a")
|
||||
require.NoError(t, as.Save(ctx, a1))
|
||||
|
||||
require.NoError(t, as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: a1.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusRejected,
|
||||
At: a1.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
// after rejection a new submission for the same (user, game) is allowed.
|
||||
a2 := newApplication(t, "application-002", "game-001", "user-a")
|
||||
require.NoError(t, as.Save(ctx, a2))
|
||||
}
|
||||
|
||||
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, as := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newApplication(t, "application-001", "game-001", "user-a")
|
||||
require.NoError(t, as.Save(ctx, rec))
|
||||
|
||||
// First, transition the row to approved.
|
||||
require.NoError(t, as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: rec.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: rec.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
// Second attempt claims status is still submitted: (submitted, rejected)
|
||||
// is a valid domain transition, but the row is already approved, so the
|
||||
// adapter must surface ErrConflict on the row-level mismatch.
|
||||
err := as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: rec.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusRejected,
|
||||
At: rec.CreatedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.ErrorIs(t, err, application.ErrConflict)
|
||||
}
|
||||
|
||||
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, as := newStores(t)
|
||||
err := as.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: common.ApplicationID("application-missing"),
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGetByGameAndGetByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, as := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
seedGame(t, gs, "game-002")
|
||||
|
||||
require.NoError(t, as.Save(ctx, newApplication(t, "application-001", "game-001", "user-a")))
|
||||
require.NoError(t, as.Save(ctx, newApplication(t, "application-002", "game-001", "user-b")))
|
||||
require.NoError(t, as.Save(ctx, newApplication(t, "application-003", "game-002", "user-a")))
|
||||
|
||||
g1, err := as.GetByGame(ctx, common.GameID("game-001"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, g1, 2)
|
||||
|
||||
userA, err := as.GetByUser(ctx, "user-a")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, userA, 2)
|
||||
}
|
||||
|
||||
func TestGetMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, as := newStores(t)
|
||||
_, err := as.Get(ctx, common.ApplicationID("application-missing"))
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
@@ -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(¤t); 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)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Package pgtest exposes the testcontainers-backed PostgreSQL bootstrap
|
||||
// shared by every Game Lobby PG adapter test. The package is regular Go
|
||||
// code — not a `_test.go` file — so it can be imported by the `_test.go`
|
||||
// files in the four sibling store packages (`gamestore`, `applicationstore`,
|
||||
// `invitestore`, `membershipstore`).
|
||||
//
|
||||
// No production code in `cmd/lobby` or in the runtime imports this package.
|
||||
// The testcontainers-go dependency therefore stays out of the production
|
||||
// binary's import graph.
|
||||
package pgtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/postgres/migrations"
|
||||
"galaxy/postgres"
|
||||
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
postgresImage = "postgres:16-alpine"
|
||||
superUser = "galaxy"
|
||||
superPassword = "galaxy"
|
||||
superDatabase = "galaxy_lobby"
|
||||
serviceRole = "lobbyservice"
|
||||
servicePassword = "lobbyservice"
|
||||
serviceSchema = "lobby"
|
||||
containerStartup = 90 * time.Second
|
||||
|
||||
// OperationTimeout is the per-statement timeout used by every store
|
||||
// constructed via NewStoreConfig. Tests may pass a smaller value if they
|
||||
// need to assert deadline behaviour explicitly.
|
||||
OperationTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Env holds the per-process container plus the *sql.DB pool already
|
||||
// provisioned with the lobby schema, role, and migrations applied.
|
||||
type Env struct {
|
||||
container *tcpostgres.PostgresContainer
|
||||
pool *sql.DB
|
||||
}
|
||||
|
||||
// Pool returns the shared pool. Tests truncate per-table state before each
|
||||
// run via TruncateAll.
|
||||
func (env *Env) Pool() *sql.DB { return env.pool }
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
cur *Env
|
||||
curEr error
|
||||
)
|
||||
|
||||
// Ensure starts the PostgreSQL container on first invocation and applies
|
||||
// the embedded goose migrations. Subsequent invocations reuse the same
|
||||
// container/pool. When Docker is unavailable Ensure calls t.Skip with the
|
||||
// underlying error so the test suite still passes on machines without
|
||||
// Docker.
|
||||
func Ensure(t testing.TB) *Env {
|
||||
t.Helper()
|
||||
once.Do(func() {
|
||||
cur, curEr = start()
|
||||
})
|
||||
if curEr != nil {
|
||||
t.Skipf("postgres container start failed (Docker unavailable?): %v", curEr)
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// TruncateAll wipes every Game Lobby table inside the shared pool, leaving
|
||||
// the schema and indexes intact. Use it from each test that needs a clean
|
||||
// slate.
|
||||
func TruncateAll(t testing.TB) {
|
||||
t.Helper()
|
||||
env := Ensure(t)
|
||||
const stmt = `TRUNCATE TABLE memberships, invites, applications, games, race_names RESTART IDENTITY CASCADE`
|
||||
if _, err := env.pool.ExecContext(context.Background(), stmt); err != nil {
|
||||
t.Fatalf("truncate lobby tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown terminates the shared container and closes the pool. It is
|
||||
// invoked from each test package's TestMain after `m.Run` returns so the
|
||||
// container is released even if individual tests panic.
|
||||
func Shutdown() {
|
||||
if cur == nil {
|
||||
return
|
||||
}
|
||||
if cur.pool != nil {
|
||||
_ = cur.pool.Close()
|
||||
}
|
||||
if cur.container != nil {
|
||||
_ = testcontainers.TerminateContainer(cur.container)
|
||||
}
|
||||
cur = nil
|
||||
}
|
||||
|
||||
// RunMain is a convenience helper for each store package's TestMain: it
|
||||
// runs the test main, captures the exit code, shuts the container down, and
|
||||
// exits. Wiring it through one helper keeps every TestMain to two lines.
|
||||
func RunMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
Shutdown()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func start() (*Env, error) {
|
||||
ctx := context.Background()
|
||||
container, err := tcpostgres.Run(ctx, postgresImage,
|
||||
tcpostgres.WithDatabase(superDatabase),
|
||||
tcpostgres.WithUsername(superUser),
|
||||
tcpostgres.WithPassword(superPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(containerStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := provisionRoleAndSchema(ctx, baseDSN); err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
scopedDSN, err := dsnForServiceRole(baseDSN)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = OperationTimeout
|
||||
pool, err := postgres.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := postgres.Ping(ctx, pool, OperationTimeout); err != nil {
|
||||
_ = pool.Close()
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil {
|
||||
_ = pool.Close()
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
return &Env{container: container, pool: pool}, nil
|
||||
}
|
||||
|
||||
func provisionRoleAndSchema(ctx context.Context, baseDSN string) error {
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = baseDSN
|
||||
cfg.OperationTimeout = OperationTimeout
|
||||
db, err := postgres.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
statements := []string{
|
||||
`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'lobbyservice') THEN
|
||||
CREATE ROLE lobbyservice LOGIN PASSWORD 'lobbyservice';
|
||||
END IF;
|
||||
END $$;`,
|
||||
`CREATE SCHEMA IF NOT EXISTS lobby AUTHORIZATION lobbyservice;`,
|
||||
`GRANT USAGE ON SCHEMA lobby TO lobbyservice;`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dsnForServiceRole(baseDSN string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("search_path", serviceSchema)
|
||||
values.Set("sslmode", "disable")
|
||||
scoped := url.URL{
|
||||
Scheme: parsed.Scheme,
|
||||
User: url.UserPassword(serviceRole, servicePassword),
|
||||
Host: parsed.Host,
|
||||
Path: parsed.Path,
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
return scoped.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package sqlx contains the small set of helpers shared by every Game Lobby
|
||||
// PostgreSQL adapter (gamestore, applicationstore, invitestore,
|
||||
// membershipstore). The helpers centralise the boundary translations from
|
||||
// the per-service ARCHITECTURE.md timestamp-handling rules and from the pgx
|
||||
// SQLSTATE codes the adapters interpret as domain conflicts.
|
||||
package sqlx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// PgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL when
|
||||
// a UNIQUE constraint is violated by INSERT or UPDATE.
|
||||
const PgUniqueViolationCode = "23505"
|
||||
|
||||
// IsUniqueViolation reports whether err is a PostgreSQL unique-violation,
|
||||
// regardless of constraint name.
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
return pgErr.Code == PgUniqueViolationCode
|
||||
}
|
||||
|
||||
// IsNoRows reports whether err is sql.ErrNoRows.
|
||||
func IsNoRows(err error) bool {
|
||||
return errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
// NullableTime returns t.UTC() when non-zero, otherwise nil so the column
|
||||
// is bound as SQL NULL. Several Lobby domain records use *time.Time to
|
||||
// express absent timestamps; for those, callers translate the pointer with
|
||||
// NullableTimePtr instead.
|
||||
func NullableTime(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
// NullableTimePtr returns t.UTC() when t is non-nil and non-zero, otherwise
|
||||
// nil. The helper is the *time.Time companion of NullableTime: every Lobby
|
||||
// domain record has at least one optional `*time.Time` field
|
||||
// (`StartedAt`, `FinishedAt`, `DecidedAt`, `RemovedAt`) that maps to a
|
||||
// nullable timestamptz column.
|
||||
func NullableTimePtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return NullableTime(*t)
|
||||
}
|
||||
|
||||
// TimeFromNullable copies an optional sql.NullTime read from PostgreSQL
|
||||
// into a domain time.Time, applying the global UTC normalisation rule.
|
||||
// Invalid (NULL) values become the zero time.Time.
|
||||
func TimeFromNullable(value sql.NullTime) time.Time {
|
||||
if !value.Valid {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.Time.UTC()
|
||||
}
|
||||
|
||||
// TimePtrFromNullable copies an optional sql.NullTime into a domain
|
||||
// *time.Time. NULL becomes nil; non-NULL values are wrapped after UTC
|
||||
// normalisation.
|
||||
func TimePtrFromNullable(value sql.NullTime) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
t := value.Time.UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// WithTimeout derives a child context bounded by timeout and prefixes
|
||||
// context errors with operation. Callers must always invoke the returned
|
||||
// cancel.
|
||||
func WithTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) {
|
||||
if ctx == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation)
|
||||
}
|
||||
bounded, cancel := context.WithTimeout(ctx, timeout)
|
||||
return bounded, cancel, nil
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Package invitestore implements the PostgreSQL-backed adapter for
|
||||
// `ports.InviteStore`.
|
||||
//
|
||||
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
|
||||
// invite records.
|
||||
package invitestore
|
||||
|
||||
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/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed invite store instance.
|
||||
type Config struct {
|
||||
DB *sql.DB
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Lobby invite records in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed invite store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres invite store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres invite store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// inviteSelectColumns is the canonical SELECT list for the invites table,
|
||||
// matching scanInvite's column order.
|
||||
var inviteSelectColumns = pg.ColumnList{
|
||||
pgtable.Invites.InviteID,
|
||||
pgtable.Invites.GameID,
|
||||
pgtable.Invites.InviterUserID,
|
||||
pgtable.Invites.InviteeUserID,
|
||||
pgtable.Invites.RaceName,
|
||||
pgtable.Invites.Status,
|
||||
pgtable.Invites.CreatedAt,
|
||||
pgtable.Invites.ExpiresAt,
|
||||
pgtable.Invites.DecidedAt,
|
||||
}
|
||||
|
||||
// Save persists a new created invite record. Save is create-only; a second
|
||||
// save against the same invite id maps the unique-violation to
|
||||
// invite.ErrConflict.
|
||||
func (store *Store) Save(ctx context.Context, record invite.Invite) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("save invite: nil store")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save invite: %w", err)
|
||||
}
|
||||
if record.Status != invite.StatusCreated {
|
||||
return fmt.Errorf(
|
||||
"save invite: status must be %q, got %q",
|
||||
invite.StatusCreated, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save invite", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.Invites.INSERT(
|
||||
pgtable.Invites.InviteID,
|
||||
pgtable.Invites.GameID,
|
||||
pgtable.Invites.InviterUserID,
|
||||
pgtable.Invites.InviteeUserID,
|
||||
pgtable.Invites.RaceName,
|
||||
pgtable.Invites.Status,
|
||||
pgtable.Invites.CreatedAt,
|
||||
pgtable.Invites.ExpiresAt,
|
||||
pgtable.Invites.DecidedAt,
|
||||
).VALUES(
|
||||
record.InviteID.String(),
|
||||
record.GameID.String(),
|
||||
record.InviterUserID,
|
||||
record.InviteeUserID,
|
||||
record.RaceName,
|
||||
string(record.Status),
|
||||
record.CreatedAt.UTC(),
|
||||
record.ExpiresAt.UTC(),
|
||||
sqlx.NullableTimePtr(record.DecidedAt),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
if sqlx.IsUniqueViolation(err) {
|
||||
return fmt.Errorf("save invite: %w", invite.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("save invite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the record identified by inviteID.
|
||||
func (store *Store) Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return invite.Invite{}, errors.New("get invite: nil store")
|
||||
}
|
||||
if err := inviteID.Validate(); err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get invite", store.operationTimeout)
|
||||
if err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(inviteSelectColumns).
|
||||
FROM(pgtable.Invites).
|
||||
WHERE(pgtable.Invites.InviteID.EQ(pg.String(inviteID.String())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
record, err := scanInvite(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return invite.Invite{}, invite.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every invite attached to gameID.
|
||||
func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("get invites by game: nil store")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get invites by game: %w", err)
|
||||
}
|
||||
|
||||
stmt := pg.SELECT(inviteSelectColumns).
|
||||
FROM(pgtable.Invites).
|
||||
WHERE(pgtable.Invites.GameID.EQ(pg.String(gameID.String()))).
|
||||
ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC())
|
||||
|
||||
return store.queryList(ctx, "get invites by game", stmt)
|
||||
}
|
||||
|
||||
// GetByUser returns every invite addressed to inviteeUserID.
|
||||
func (store *Store) GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("get invites by user: nil store")
|
||||
}
|
||||
trimmed := strings.TrimSpace(inviteeUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get invites by user: invitee user id must not be empty")
|
||||
}
|
||||
|
||||
stmt := pg.SELECT(inviteSelectColumns).
|
||||
FROM(pgtable.Invites).
|
||||
WHERE(pgtable.Invites.InviteeUserID.EQ(pg.String(trimmed))).
|
||||
ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC())
|
||||
|
||||
return store.queryList(ctx, "get invites by user", stmt)
|
||||
}
|
||||
|
||||
// GetByInviter returns every invite created by inviterUserID.
|
||||
func (store *Store) GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("get invites by inviter: nil store")
|
||||
}
|
||||
trimmed := strings.TrimSpace(inviterUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get invites by inviter: inviter user id must not be empty")
|
||||
}
|
||||
|
||||
stmt := pg.SELECT(inviteSelectColumns).
|
||||
FROM(pgtable.Invites).
|
||||
WHERE(pgtable.Invites.InviterUserID.EQ(pg.String(trimmed))).
|
||||
ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC())
|
||||
|
||||
return store.queryList(ctx, "get invites by inviter", stmt)
|
||||
}
|
||||
|
||||
func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]invite.Invite, error) {
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, operation, store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]invite.Invite, 0)
|
||||
for rows.Next() {
|
||||
record, err := scanInvite(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: scan: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition with compare-and-swap on the
|
||||
// current status column. When transitioning to redeemed the row's race_name
|
||||
// column is replaced with the trimmed input value.
|
||||
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateInviteStatusInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update invite status: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update invite status: %w", err)
|
||||
}
|
||||
if err := invite.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update invite status", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
at := input.At.UTC()
|
||||
raceName := strings.TrimSpace(input.RaceName)
|
||||
|
||||
// race_name is replaced only when the caller supplies a non-empty value;
|
||||
// otherwise the existing value is preserved (CASE WHEN '' THEN race_name).
|
||||
raceExpr := pg.CASE().
|
||||
WHEN(pg.String(raceName).EQ(pg.String(""))).THEN(pgtable.Invites.RaceName).
|
||||
ELSE(pg.String(raceName))
|
||||
|
||||
stmt := pgtable.Invites.UPDATE(
|
||||
pgtable.Invites.Status,
|
||||
pgtable.Invites.DecidedAt,
|
||||
pgtable.Invites.RaceName,
|
||||
).SET(
|
||||
pg.String(string(input.To)),
|
||||
pg.TimestampzT(at),
|
||||
raceExpr,
|
||||
).WHERE(pg.AND(
|
||||
pgtable.Invites.InviteID.EQ(pg.String(input.InviteID.String())),
|
||||
pgtable.Invites.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 invite status: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update invite status: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
probe := pg.SELECT(pgtable.Invites.Status).
|
||||
FROM(pgtable.Invites).
|
||||
WHERE(pgtable.Invites.InviteID.EQ(pg.String(input.InviteID.String())))
|
||||
probeQuery, probeArgs := probe.Sql()
|
||||
|
||||
var current string
|
||||
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
if sqlx.IsNoRows(err) {
|
||||
return invite.ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("update invite status: probe: %w", err)
|
||||
}
|
||||
return fmt.Errorf("update invite status: %w", invite.ErrConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanInvite(rs rowScanner) (invite.Invite, error) {
|
||||
var (
|
||||
inviteID string
|
||||
gameID string
|
||||
inviterUserID string
|
||||
inviteeUserID string
|
||||
raceName string
|
||||
status string
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
decidedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
&inviteID,
|
||||
&gameID,
|
||||
&inviterUserID,
|
||||
&inviteeUserID,
|
||||
&raceName,
|
||||
&status,
|
||||
&createdAt,
|
||||
&expiresAt,
|
||||
&decidedAt,
|
||||
); err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
return invite.Invite{
|
||||
InviteID: common.InviteID(inviteID),
|
||||
GameID: common.GameID(gameID),
|
||||
InviterUserID: inviterUserID,
|
||||
InviteeUserID: inviteeUserID,
|
||||
RaceName: raceName,
|
||||
Status: invite.Status(status),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
ExpiresAt: expiresAt.UTC(),
|
||||
DecidedAt: sqlx.TimePtrFromNullable(decidedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.InviteStore interface at compile time.
|
||||
var _ ports.InviteStore = (*Store)(nil)
|
||||
@@ -0,0 +1,199 @@
|
||||
package invitestore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/postgres/gamestore"
|
||||
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/lobby/internal/adapters/postgres/invitestore"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStores(t *testing.T) (*gamestore.Store, *invitestore.Store) {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
gs, err := gamestore.New(gamestore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
is, err := invitestore.New(invitestore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return gs, is
|
||||
}
|
||||
|
||||
func seedPrivateGame(t *testing.T, gs *gamestore.Store, id, ownerID string) game.Game {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
g, 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)
|
||||
require.NoError(t, gs.Save(context.Background(), g))
|
||||
return g
|
||||
}
|
||||
|
||||
func newInvite(t *testing.T, id, gameID, inviter, invitee string) invite.Invite {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
rec, err := invite.New(invite.NewInviteInput{
|
||||
InviteID: common.InviteID(id),
|
||||
GameID: common.GameID(gameID),
|
||||
InviterUserID: inviter,
|
||||
InviteeUserID: invitee,
|
||||
Now: now,
|
||||
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, is := newStores(t)
|
||||
seedPrivateGame(t, gs, "game-001", "owner-1")
|
||||
|
||||
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
|
||||
require.NoError(t, is.Save(ctx, rec))
|
||||
|
||||
got, err := is.Get(ctx, rec.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec.InviteID, got.InviteID)
|
||||
assert.Equal(t, invite.StatusCreated, got.Status)
|
||||
assert.Equal(t, "invitee-1", got.InviteeUserID)
|
||||
assert.True(t, got.ExpiresAt.Equal(rec.ExpiresAt))
|
||||
}
|
||||
|
||||
func TestSaveRejectsNonCreated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, is := newStores(t)
|
||||
seedPrivateGame(t, gs, "game-001", "owner-1")
|
||||
|
||||
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
|
||||
rec.Status = invite.StatusRedeemed
|
||||
require.Error(t, is.Save(ctx, rec))
|
||||
}
|
||||
|
||||
func TestSaveDuplicateReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, is := newStores(t)
|
||||
seedPrivateGame(t, gs, "game-001", "owner-1")
|
||||
|
||||
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
|
||||
require.NoError(t, is.Save(ctx, rec))
|
||||
err := is.Save(ctx, rec)
|
||||
require.ErrorIs(t, err, invite.ErrConflict)
|
||||
}
|
||||
|
||||
func TestUpdateStatusRedeemSetsRaceName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, is := newStores(t)
|
||||
seedPrivateGame(t, gs, "game-001", "owner-1")
|
||||
|
||||
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
|
||||
require.NoError(t, is.Save(ctx, rec))
|
||||
|
||||
require.NoError(t, is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: rec.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRedeemed,
|
||||
At: rec.CreatedAt.Add(time.Minute),
|
||||
RaceName: "PilotRedeemed",
|
||||
}))
|
||||
|
||||
got, err := is.Get(ctx, rec.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusRedeemed, got.Status)
|
||||
assert.Equal(t, "PilotRedeemed", got.RaceName)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
}
|
||||
|
||||
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, is := newStores(t)
|
||||
seedPrivateGame(t, gs, "game-001", "owner-1")
|
||||
|
||||
rec := newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")
|
||||
require.NoError(t, is.Save(ctx, rec))
|
||||
|
||||
// Move row out of `created` so the next attempt's `WHERE status = ?`
|
||||
// fails on persistence even though the (created → revoked) transition is
|
||||
// itself valid in the domain table.
|
||||
require.NoError(t, is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: rec.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: rec.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
err := is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: rec.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRevoked,
|
||||
At: rec.CreatedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.ErrorIs(t, err, invite.ErrConflict)
|
||||
}
|
||||
|
||||
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, is := newStores(t)
|
||||
err := is.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: common.InviteID("invite-missing"),
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, invite.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGetByGameUserInviter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, is := newStores(t)
|
||||
seedPrivateGame(t, gs, "game-001", "owner-1")
|
||||
seedPrivateGame(t, gs, "game-002", "owner-2")
|
||||
|
||||
require.NoError(t, is.Save(ctx, newInvite(t, "invite-001", "game-001", "owner-1", "invitee-1")))
|
||||
require.NoError(t, is.Save(ctx, newInvite(t, "invite-002", "game-001", "owner-1", "invitee-2")))
|
||||
require.NoError(t, is.Save(ctx, newInvite(t, "invite-003", "game-002", "owner-2", "invitee-1")))
|
||||
|
||||
g1, err := is.GetByGame(ctx, common.GameID("game-001"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, g1, 2)
|
||||
|
||||
user1, err := is.GetByUser(ctx, "invitee-1")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, user1, 2)
|
||||
|
||||
by1, err := is.GetByInviter(ctx, "owner-1")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, by1, 2)
|
||||
}
|
||||
|
||||
func TestGetMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, is := newStores(t)
|
||||
_, err := is.Get(ctx, common.InviteID("invite-missing"))
|
||||
require.ErrorIs(t, err, invite.ErrNotFound)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Applications struct {
|
||||
ApplicationID string `sql:"primary_key"`
|
||||
GameID string
|
||||
ApplicantUserID string
|
||||
RaceName string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
DecidedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Games struct {
|
||||
GameID string `sql:"primary_key"`
|
||||
GameName string
|
||||
Description string
|
||||
GameType string
|
||||
OwnerUserID string
|
||||
Status string
|
||||
MinPlayers int32
|
||||
MaxPlayers int32
|
||||
StartGapHours int32
|
||||
StartGapPlayers int32
|
||||
EnrollmentEndsAt time.Time
|
||||
TurnSchedule string
|
||||
TargetEngineVersion string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
RuntimeSnapshot string
|
||||
RuntimeBinding *string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type GooseDbVersion struct {
|
||||
ID int32 `sql:"primary_key"`
|
||||
VersionID int64
|
||||
IsApplied bool
|
||||
Tstamp time.Time
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Invites struct {
|
||||
InviteID string `sql:"primary_key"`
|
||||
GameID string
|
||||
InviterUserID string
|
||||
InviteeUserID string
|
||||
RaceName string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
DecidedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Memberships struct {
|
||||
MembershipID string `sql:"primary_key"`
|
||||
GameID string
|
||||
UserID string
|
||||
RaceName string
|
||||
CanonicalKey string
|
||||
Status string
|
||||
JoinedAt time.Time
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
type RaceNames struct {
|
||||
CanonicalKey string `sql:"primary_key"`
|
||||
GameID string `sql:"primary_key"`
|
||||
HolderUserID string
|
||||
RaceName string
|
||||
BindingKind string
|
||||
SourceGameID string
|
||||
ReservedAtMs int64
|
||||
EligibleUntilMs *int64
|
||||
RegisteredAtMs *int64
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Applications = newApplicationsTable("lobby", "applications", "")
|
||||
|
||||
type applicationsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ApplicationID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
ApplicantUserID postgres.ColumnString
|
||||
RaceName postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
DecidedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ApplicationsTable struct {
|
||||
applicationsTable
|
||||
|
||||
EXCLUDED applicationsTable
|
||||
}
|
||||
|
||||
// AS creates new ApplicationsTable with assigned alias
|
||||
func (a ApplicationsTable) AS(alias string) *ApplicationsTable {
|
||||
return newApplicationsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ApplicationsTable with assigned schema name
|
||||
func (a ApplicationsTable) FromSchema(schemaName string) *ApplicationsTable {
|
||||
return newApplicationsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ApplicationsTable with assigned table prefix
|
||||
func (a ApplicationsTable) WithPrefix(prefix string) *ApplicationsTable {
|
||||
return newApplicationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ApplicationsTable with assigned table suffix
|
||||
func (a ApplicationsTable) WithSuffix(suffix string) *ApplicationsTable {
|
||||
return newApplicationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newApplicationsTable(schemaName, tableName, alias string) *ApplicationsTable {
|
||||
return &ApplicationsTable{
|
||||
applicationsTable: newApplicationsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newApplicationsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newApplicationsTableImpl(schemaName, tableName, alias string) applicationsTable {
|
||||
var (
|
||||
ApplicationIDColumn = postgres.StringColumn("application_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
ApplicantUserIDColumn = postgres.StringColumn("applicant_user_id")
|
||||
RaceNameColumn = postgres.StringColumn("race_name")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
DecidedAtColumn = postgres.TimestampzColumn("decided_at")
|
||||
allColumns = postgres.ColumnList{ApplicationIDColumn, GameIDColumn, ApplicantUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, DecidedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, ApplicantUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, DecidedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{}
|
||||
)
|
||||
|
||||
return applicationsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ApplicationID: ApplicationIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
ApplicantUserID: ApplicantUserIDColumn,
|
||||
RaceName: RaceNameColumn,
|
||||
Status: StatusColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
DecidedAt: DecidedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Games = newGamesTable("lobby", "games", "")
|
||||
|
||||
type gamesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
GameID postgres.ColumnString
|
||||
GameName postgres.ColumnString
|
||||
Description postgres.ColumnString
|
||||
GameType postgres.ColumnString
|
||||
OwnerUserID postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
MinPlayers postgres.ColumnInteger
|
||||
MaxPlayers postgres.ColumnInteger
|
||||
StartGapHours postgres.ColumnInteger
|
||||
StartGapPlayers postgres.ColumnInteger
|
||||
EnrollmentEndsAt postgres.ColumnTimestampz
|
||||
TurnSchedule postgres.ColumnString
|
||||
TargetEngineVersion postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
StartedAt postgres.ColumnTimestampz
|
||||
FinishedAt postgres.ColumnTimestampz
|
||||
RuntimeSnapshot postgres.ColumnString
|
||||
RuntimeBinding postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GamesTable struct {
|
||||
gamesTable
|
||||
|
||||
EXCLUDED gamesTable
|
||||
}
|
||||
|
||||
// AS creates new GamesTable with assigned alias
|
||||
func (a GamesTable) AS(alias string) *GamesTable {
|
||||
return newGamesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GamesTable with assigned schema name
|
||||
func (a GamesTable) FromSchema(schemaName string) *GamesTable {
|
||||
return newGamesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GamesTable with assigned table prefix
|
||||
func (a GamesTable) WithPrefix(prefix string) *GamesTable {
|
||||
return newGamesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GamesTable with assigned table suffix
|
||||
func (a GamesTable) WithSuffix(suffix string) *GamesTable {
|
||||
return newGamesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGamesTable(schemaName, tableName, alias string) *GamesTable {
|
||||
return &GamesTable{
|
||||
gamesTable: newGamesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGamesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
var (
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
GameNameColumn = postgres.StringColumn("game_name")
|
||||
DescriptionColumn = postgres.StringColumn("description")
|
||||
GameTypeColumn = postgres.StringColumn("game_type")
|
||||
OwnerUserIDColumn = postgres.StringColumn("owner_user_id")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
MinPlayersColumn = postgres.IntegerColumn("min_players")
|
||||
MaxPlayersColumn = postgres.IntegerColumn("max_players")
|
||||
StartGapHoursColumn = postgres.IntegerColumn("start_gap_hours")
|
||||
StartGapPlayersColumn = postgres.IntegerColumn("start_gap_players")
|
||||
EnrollmentEndsAtColumn = postgres.TimestampzColumn("enrollment_ends_at")
|
||||
TurnScheduleColumn = postgres.StringColumn("turn_schedule")
|
||||
TargetEngineVersionColumn = postgres.StringColumn("target_engine_version")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
StartedAtColumn = postgres.TimestampzColumn("started_at")
|
||||
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
||||
RuntimeSnapshotColumn = postgres.StringColumn("runtime_snapshot")
|
||||
RuntimeBindingColumn = postgres.StringColumn("runtime_binding")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, DescriptionColumn, GameTypeColumn, OwnerUserIDColumn, StatusColumn, MinPlayersColumn, MaxPlayersColumn, StartGapHoursColumn, StartGapPlayersColumn, EnrollmentEndsAtColumn, TurnScheduleColumn, TargetEngineVersionColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, FinishedAtColumn, RuntimeSnapshotColumn, RuntimeBindingColumn}
|
||||
mutableColumns = postgres.ColumnList{GameNameColumn, DescriptionColumn, GameTypeColumn, OwnerUserIDColumn, StatusColumn, MinPlayersColumn, MaxPlayersColumn, StartGapHoursColumn, StartGapPlayersColumn, EnrollmentEndsAtColumn, TurnScheduleColumn, TargetEngineVersionColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, FinishedAtColumn, RuntimeSnapshotColumn, RuntimeBindingColumn}
|
||||
defaultColumns = postgres.ColumnList{DescriptionColumn, OwnerUserIDColumn, RuntimeSnapshotColumn}
|
||||
)
|
||||
|
||||
return gamesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
GameID: GameIDColumn,
|
||||
GameName: GameNameColumn,
|
||||
Description: DescriptionColumn,
|
||||
GameType: GameTypeColumn,
|
||||
OwnerUserID: OwnerUserIDColumn,
|
||||
Status: StatusColumn,
|
||||
MinPlayers: MinPlayersColumn,
|
||||
MaxPlayers: MaxPlayersColumn,
|
||||
StartGapHours: StartGapHoursColumn,
|
||||
StartGapPlayers: StartGapPlayersColumn,
|
||||
EnrollmentEndsAt: EnrollmentEndsAtColumn,
|
||||
TurnSchedule: TurnScheduleColumn,
|
||||
TargetEngineVersion: TargetEngineVersionColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
StartedAt: StartedAtColumn,
|
||||
FinishedAt: FinishedAtColumn,
|
||||
RuntimeSnapshot: RuntimeSnapshotColumn,
|
||||
RuntimeBinding: RuntimeBindingColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GooseDbVersion = newGooseDbVersionTable("lobby", "goose_db_version", "")
|
||||
|
||||
type gooseDbVersionTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnInteger
|
||||
VersionID postgres.ColumnInteger
|
||||
IsApplied postgres.ColumnBool
|
||||
Tstamp postgres.ColumnTimestamp
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GooseDbVersionTable struct {
|
||||
gooseDbVersionTable
|
||||
|
||||
EXCLUDED gooseDbVersionTable
|
||||
}
|
||||
|
||||
// AS creates new GooseDbVersionTable with assigned alias
|
||||
func (a GooseDbVersionTable) AS(alias string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GooseDbVersionTable with assigned schema name
|
||||
func (a GooseDbVersionTable) FromSchema(schemaName string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GooseDbVersionTable with assigned table prefix
|
||||
func (a GooseDbVersionTable) WithPrefix(prefix string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GooseDbVersionTable with assigned table suffix
|
||||
func (a GooseDbVersionTable) WithSuffix(suffix string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGooseDbVersionTable(schemaName, tableName, alias string) *GooseDbVersionTable {
|
||||
return &GooseDbVersionTable{
|
||||
gooseDbVersionTable: newGooseDbVersionTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGooseDbVersionTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGooseDbVersionTableImpl(schemaName, tableName, alias string) gooseDbVersionTable {
|
||||
var (
|
||||
IDColumn = postgres.IntegerColumn("id")
|
||||
VersionIDColumn = postgres.IntegerColumn("version_id")
|
||||
IsAppliedColumn = postgres.BoolColumn("is_applied")
|
||||
TstampColumn = postgres.TimestampColumn("tstamp")
|
||||
allColumns = postgres.ColumnList{IDColumn, VersionIDColumn, IsAppliedColumn, TstampColumn}
|
||||
mutableColumns = postgres.ColumnList{VersionIDColumn, IsAppliedColumn, TstampColumn}
|
||||
defaultColumns = postgres.ColumnList{TstampColumn}
|
||||
)
|
||||
|
||||
return gooseDbVersionTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
VersionID: VersionIDColumn,
|
||||
IsApplied: IsAppliedColumn,
|
||||
Tstamp: TstampColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Invites = newInvitesTable("lobby", "invites", "")
|
||||
|
||||
type invitesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
InviteID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
InviterUserID postgres.ColumnString
|
||||
InviteeUserID postgres.ColumnString
|
||||
RaceName postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
DecidedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type InvitesTable struct {
|
||||
invitesTable
|
||||
|
||||
EXCLUDED invitesTable
|
||||
}
|
||||
|
||||
// AS creates new InvitesTable with assigned alias
|
||||
func (a InvitesTable) AS(alias string) *InvitesTable {
|
||||
return newInvitesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new InvitesTable with assigned schema name
|
||||
func (a InvitesTable) FromSchema(schemaName string) *InvitesTable {
|
||||
return newInvitesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new InvitesTable with assigned table prefix
|
||||
func (a InvitesTable) WithPrefix(prefix string) *InvitesTable {
|
||||
return newInvitesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new InvitesTable with assigned table suffix
|
||||
func (a InvitesTable) WithSuffix(suffix string) *InvitesTable {
|
||||
return newInvitesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newInvitesTable(schemaName, tableName, alias string) *InvitesTable {
|
||||
return &InvitesTable{
|
||||
invitesTable: newInvitesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newInvitesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newInvitesTableImpl(schemaName, tableName, alias string) invitesTable {
|
||||
var (
|
||||
InviteIDColumn = postgres.StringColumn("invite_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
InviterUserIDColumn = postgres.StringColumn("inviter_user_id")
|
||||
InviteeUserIDColumn = postgres.StringColumn("invitee_user_id")
|
||||
RaceNameColumn = postgres.StringColumn("race_name")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
DecidedAtColumn = postgres.TimestampzColumn("decided_at")
|
||||
allColumns = postgres.ColumnList{InviteIDColumn, GameIDColumn, InviterUserIDColumn, InviteeUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, ExpiresAtColumn, DecidedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, InviterUserIDColumn, InviteeUserIDColumn, RaceNameColumn, StatusColumn, CreatedAtColumn, ExpiresAtColumn, DecidedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{RaceNameColumn}
|
||||
)
|
||||
|
||||
return invitesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
InviteID: InviteIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
InviterUserID: InviterUserIDColumn,
|
||||
InviteeUserID: InviteeUserIDColumn,
|
||||
RaceName: RaceNameColumn,
|
||||
Status: StatusColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
DecidedAt: DecidedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Memberships = newMembershipsTable("lobby", "memberships", "")
|
||||
|
||||
type membershipsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
MembershipID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
RaceName postgres.ColumnString
|
||||
CanonicalKey postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
JoinedAt postgres.ColumnTimestampz
|
||||
RemovedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type MembershipsTable struct {
|
||||
membershipsTable
|
||||
|
||||
EXCLUDED membershipsTable
|
||||
}
|
||||
|
||||
// AS creates new MembershipsTable with assigned alias
|
||||
func (a MembershipsTable) AS(alias string) *MembershipsTable {
|
||||
return newMembershipsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new MembershipsTable with assigned schema name
|
||||
func (a MembershipsTable) FromSchema(schemaName string) *MembershipsTable {
|
||||
return newMembershipsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new MembershipsTable with assigned table prefix
|
||||
func (a MembershipsTable) WithPrefix(prefix string) *MembershipsTable {
|
||||
return newMembershipsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new MembershipsTable with assigned table suffix
|
||||
func (a MembershipsTable) WithSuffix(suffix string) *MembershipsTable {
|
||||
return newMembershipsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newMembershipsTable(schemaName, tableName, alias string) *MembershipsTable {
|
||||
return &MembershipsTable{
|
||||
membershipsTable: newMembershipsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newMembershipsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newMembershipsTableImpl(schemaName, tableName, alias string) membershipsTable {
|
||||
var (
|
||||
MembershipIDColumn = postgres.StringColumn("membership_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
RaceNameColumn = postgres.StringColumn("race_name")
|
||||
CanonicalKeyColumn = postgres.StringColumn("canonical_key")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
JoinedAtColumn = postgres.TimestampzColumn("joined_at")
|
||||
RemovedAtColumn = postgres.TimestampzColumn("removed_at")
|
||||
allColumns = postgres.ColumnList{MembershipIDColumn, GameIDColumn, UserIDColumn, RaceNameColumn, CanonicalKeyColumn, StatusColumn, JoinedAtColumn, RemovedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, UserIDColumn, RaceNameColumn, CanonicalKeyColumn, StatusColumn, JoinedAtColumn, RemovedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{}
|
||||
)
|
||||
|
||||
return membershipsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
MembershipID: MembershipIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
RaceName: RaceNameColumn,
|
||||
CanonicalKey: CanonicalKeyColumn,
|
||||
Status: StatusColumn,
|
||||
JoinedAt: JoinedAtColumn,
|
||||
RemovedAt: RemovedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var RaceNames = newRaceNamesTable("lobby", "race_names", "")
|
||||
|
||||
type raceNamesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
CanonicalKey postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
HolderUserID postgres.ColumnString
|
||||
RaceName postgres.ColumnString
|
||||
BindingKind postgres.ColumnString
|
||||
SourceGameID postgres.ColumnString
|
||||
ReservedAtMs postgres.ColumnInteger
|
||||
EligibleUntilMs postgres.ColumnInteger
|
||||
RegisteredAtMs postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type RaceNamesTable struct {
|
||||
raceNamesTable
|
||||
|
||||
EXCLUDED raceNamesTable
|
||||
}
|
||||
|
||||
// AS creates new RaceNamesTable with assigned alias
|
||||
func (a RaceNamesTable) AS(alias string) *RaceNamesTable {
|
||||
return newRaceNamesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new RaceNamesTable with assigned schema name
|
||||
func (a RaceNamesTable) FromSchema(schemaName string) *RaceNamesTable {
|
||||
return newRaceNamesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new RaceNamesTable with assigned table prefix
|
||||
func (a RaceNamesTable) WithPrefix(prefix string) *RaceNamesTable {
|
||||
return newRaceNamesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new RaceNamesTable with assigned table suffix
|
||||
func (a RaceNamesTable) WithSuffix(suffix string) *RaceNamesTable {
|
||||
return newRaceNamesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newRaceNamesTable(schemaName, tableName, alias string) *RaceNamesTable {
|
||||
return &RaceNamesTable{
|
||||
raceNamesTable: newRaceNamesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newRaceNamesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newRaceNamesTableImpl(schemaName, tableName, alias string) raceNamesTable {
|
||||
var (
|
||||
CanonicalKeyColumn = postgres.StringColumn("canonical_key")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
HolderUserIDColumn = postgres.StringColumn("holder_user_id")
|
||||
RaceNameColumn = postgres.StringColumn("race_name")
|
||||
BindingKindColumn = postgres.StringColumn("binding_kind")
|
||||
SourceGameIDColumn = postgres.StringColumn("source_game_id")
|
||||
ReservedAtMsColumn = postgres.IntegerColumn("reserved_at_ms")
|
||||
EligibleUntilMsColumn = postgres.IntegerColumn("eligible_until_ms")
|
||||
RegisteredAtMsColumn = postgres.IntegerColumn("registered_at_ms")
|
||||
allColumns = postgres.ColumnList{CanonicalKeyColumn, GameIDColumn, HolderUserIDColumn, RaceNameColumn, BindingKindColumn, SourceGameIDColumn, ReservedAtMsColumn, EligibleUntilMsColumn, RegisteredAtMsColumn}
|
||||
mutableColumns = postgres.ColumnList{HolderUserIDColumn, RaceNameColumn, BindingKindColumn, SourceGameIDColumn, ReservedAtMsColumn, EligibleUntilMsColumn, RegisteredAtMsColumn}
|
||||
defaultColumns = postgres.ColumnList{GameIDColumn, SourceGameIDColumn, ReservedAtMsColumn}
|
||||
)
|
||||
|
||||
return raceNamesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
CanonicalKey: CanonicalKeyColumn,
|
||||
GameID: GameIDColumn,
|
||||
HolderUserID: HolderUserIDColumn,
|
||||
RaceName: RaceNameColumn,
|
||||
BindingKind: BindingKindColumn,
|
||||
SourceGameID: SourceGameIDColumn,
|
||||
ReservedAtMs: ReservedAtMsColumn,
|
||||
EligibleUntilMs: EligibleUntilMsColumn,
|
||||
RegisteredAtMs: RegisteredAtMsColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
Applications = Applications.FromSchema(schema)
|
||||
Games = Games.FromSchema(schema)
|
||||
GooseDbVersion = GooseDbVersion.FromSchema(schema)
|
||||
Invites = Invites.FromSchema(schema)
|
||||
Memberships = Memberships.FromSchema(schema)
|
||||
RaceNames = RaceNames.FromSchema(schema)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// Package membershipstore implements the PostgreSQL-backed adapter for
|
||||
// `ports.MembershipStore`.
|
||||
//
|
||||
// PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable
|
||||
// membership records.
|
||||
package membershipstore
|
||||
|
||||
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/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed membership store instance.
|
||||
type Config struct {
|
||||
DB *sql.DB
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Lobby membership records in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed membership store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres membership store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres membership store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// membershipSelectColumns is the canonical SELECT list for the memberships
|
||||
// table, matching scanMembership's column order.
|
||||
var membershipSelectColumns = pg.ColumnList{
|
||||
pgtable.Memberships.MembershipID,
|
||||
pgtable.Memberships.GameID,
|
||||
pgtable.Memberships.UserID,
|
||||
pgtable.Memberships.RaceName,
|
||||
pgtable.Memberships.CanonicalKey,
|
||||
pgtable.Memberships.Status,
|
||||
pgtable.Memberships.JoinedAt,
|
||||
pgtable.Memberships.RemovedAt,
|
||||
}
|
||||
|
||||
// Save persists a new active membership record. Save is create-only; a
|
||||
// second save against the same membership id maps the unique-violation to
|
||||
// membership.ErrConflict.
|
||||
func (store *Store) Save(ctx context.Context, record membership.Membership) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("save membership: nil store")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save membership: %w", err)
|
||||
}
|
||||
if record.Status != membership.StatusActive {
|
||||
return fmt.Errorf(
|
||||
"save membership: status must be %q, got %q",
|
||||
membership.StatusActive, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save membership", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.Memberships.INSERT(
|
||||
pgtable.Memberships.MembershipID,
|
||||
pgtable.Memberships.GameID,
|
||||
pgtable.Memberships.UserID,
|
||||
pgtable.Memberships.RaceName,
|
||||
pgtable.Memberships.CanonicalKey,
|
||||
pgtable.Memberships.Status,
|
||||
pgtable.Memberships.JoinedAt,
|
||||
pgtable.Memberships.RemovedAt,
|
||||
).VALUES(
|
||||
record.MembershipID.String(),
|
||||
record.GameID.String(),
|
||||
record.UserID,
|
||||
record.RaceName,
|
||||
record.CanonicalKey,
|
||||
string(record.Status),
|
||||
record.JoinedAt.UTC(),
|
||||
sqlx.NullableTimePtr(record.RemovedAt),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
if sqlx.IsUniqueViolation(err) {
|
||||
return fmt.Errorf("save membership: %w", membership.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("save membership: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the record identified by membershipID.
|
||||
func (store *Store) Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return membership.Membership{}, errors.New("get membership: nil store")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get membership", store.operationTimeout)
|
||||
if err != nil {
|
||||
return membership.Membership{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(membershipSelectColumns).
|
||||
FROM(pgtable.Memberships).
|
||||
WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(membershipID.String())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
record, err := scanMembership(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return membership.Membership{}, membership.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every membership attached to gameID.
|
||||
func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("get memberships by game: nil store")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get memberships by game: %w", err)
|
||||
}
|
||||
|
||||
stmt := pg.SELECT(membershipSelectColumns).
|
||||
FROM(pgtable.Memberships).
|
||||
WHERE(pgtable.Memberships.GameID.EQ(pg.String(gameID.String()))).
|
||||
ORDER_BY(pgtable.Memberships.JoinedAt.ASC(), pgtable.Memberships.MembershipID.ASC())
|
||||
|
||||
return store.queryList(ctx, "get memberships by game", stmt)
|
||||
}
|
||||
|
||||
// GetByUser returns every membership held by userID.
|
||||
func (store *Store) GetByUser(ctx context.Context, userID string) ([]membership.Membership, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("get memberships by user: nil store")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get memberships by user: user id must not be empty")
|
||||
}
|
||||
|
||||
stmt := pg.SELECT(membershipSelectColumns).
|
||||
FROM(pgtable.Memberships).
|
||||
WHERE(pgtable.Memberships.UserID.EQ(pg.String(trimmed))).
|
||||
ORDER_BY(pgtable.Memberships.JoinedAt.ASC(), pgtable.Memberships.MembershipID.ASC())
|
||||
|
||||
return store.queryList(ctx, "get memberships by user", stmt)
|
||||
}
|
||||
|
||||
func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]membership.Membership, error) {
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, operation, store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]membership.Membership, 0)
|
||||
for rows.Next() {
|
||||
record, err := scanMembership(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: scan: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition with compare-and-swap on the
|
||||
// current status column. RemovedAt is set to input.At when transitioning out
|
||||
// of active.
|
||||
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update membership status: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update membership status: %w", err)
|
||||
}
|
||||
if err := membership.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update membership status", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
at := input.At.UTC()
|
||||
stmt := pgtable.Memberships.UPDATE(pgtable.Memberships.Status, pgtable.Memberships.RemovedAt).
|
||||
SET(string(input.To), at).
|
||||
WHERE(pg.AND(
|
||||
pgtable.Memberships.MembershipID.EQ(pg.String(input.MembershipID.String())),
|
||||
pgtable.Memberships.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 membership status: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update membership status: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
probe := pg.SELECT(pgtable.Memberships.Status).
|
||||
FROM(pgtable.Memberships).
|
||||
WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(input.MembershipID.String())))
|
||||
probeQuery, probeArgs := probe.Sql()
|
||||
|
||||
var current string
|
||||
row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...)
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
if sqlx.IsNoRows(err) {
|
||||
return membership.ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("update membership status: probe: %w", err)
|
||||
}
|
||||
return fmt.Errorf("update membership status: %w", membership.ErrConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the membership record identified by membershipID. The
|
||||
// pre-start removemember path uses Delete; the post-start path uses
|
||||
// UpdateStatus(active → removed).
|
||||
func (store *Store) Delete(ctx context.Context, membershipID common.MembershipID) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("delete membership: nil store")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete membership", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.Memberships.DELETE().
|
||||
WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(membershipID.String())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete membership: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return membership.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanMembership(rs rowScanner) (membership.Membership, error) {
|
||||
var (
|
||||
membershipID string
|
||||
gameID string
|
||||
userID string
|
||||
raceName string
|
||||
canonicalKey string
|
||||
status string
|
||||
joinedAt time.Time
|
||||
removedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
&membershipID,
|
||||
&gameID,
|
||||
&userID,
|
||||
&raceName,
|
||||
&canonicalKey,
|
||||
&status,
|
||||
&joinedAt,
|
||||
&removedAt,
|
||||
); err != nil {
|
||||
return membership.Membership{}, err
|
||||
}
|
||||
return membership.Membership{
|
||||
MembershipID: common.MembershipID(membershipID),
|
||||
GameID: common.GameID(gameID),
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
CanonicalKey: canonicalKey,
|
||||
Status: membership.Status(status),
|
||||
JoinedAt: joinedAt.UTC(),
|
||||
RemovedAt: sqlx.TimePtrFromNullable(removedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.MembershipStore interface at compile
|
||||
// time.
|
||||
var _ ports.MembershipStore = (*Store)(nil)
|
||||
@@ -0,0 +1,213 @@
|
||||
package membershipstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/postgres/gamestore"
|
||||
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/lobby/internal/adapters/postgres/membershipstore"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStores(t *testing.T) (*gamestore.Store, *membershipstore.Store) {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
gs, err := gamestore.New(gamestore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ms, err := membershipstore.New(membershipstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return gs, ms
|
||||
}
|
||||
|
||||
func seedGame(t *testing.T, gs *gamestore.Store, id string) game.Game {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
g, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID(id),
|
||||
GameName: "G " + id,
|
||||
GameType: game.GameTypePublic,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 8,
|
||||
StartGapHours: 12,
|
||||
StartGapPlayers: 2,
|
||||
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gs.Save(context.Background(), g))
|
||||
return g
|
||||
}
|
||||
|
||||
func newMembership(t *testing.T, id, gameID, userID, race, canon string) membership.Membership {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
rec, err := membership.New(membership.NewMembershipInput{
|
||||
MembershipID: common.MembershipID(id),
|
||||
GameID: common.GameID(gameID),
|
||||
UserID: userID,
|
||||
RaceName: race,
|
||||
CanonicalKey: canon,
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, ms := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot Alpha", "pilot-alpha")
|
||||
require.NoError(t, ms.Save(ctx, rec))
|
||||
|
||||
got, err := ms.Get(ctx, rec.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec.MembershipID, got.MembershipID)
|
||||
assert.Equal(t, "Pilot Alpha", got.RaceName)
|
||||
assert.Equal(t, "pilot-alpha", got.CanonicalKey)
|
||||
assert.Equal(t, membership.StatusActive, got.Status)
|
||||
assert.Nil(t, got.RemovedAt)
|
||||
}
|
||||
|
||||
func TestSaveRejectsNonActive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, ms := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
|
||||
rec.Status = membership.StatusRemoved
|
||||
require.Error(t, ms.Save(ctx, rec))
|
||||
}
|
||||
|
||||
func TestSaveDuplicateReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, ms := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
|
||||
require.NoError(t, ms.Save(ctx, rec))
|
||||
err := ms.Save(ctx, rec)
|
||||
require.ErrorIs(t, err, membership.ErrConflict)
|
||||
}
|
||||
|
||||
func TestUpdateStatusToRemovedSetsRemovedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, ms := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
|
||||
require.NoError(t, ms.Save(ctx, rec))
|
||||
at := rec.JoinedAt.Add(time.Minute)
|
||||
require.NoError(t, ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: rec.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusRemoved,
|
||||
At: at,
|
||||
}))
|
||||
got, err := ms.Get(ctx, rec.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, membership.StatusRemoved, got.Status)
|
||||
require.NotNil(t, got.RemovedAt)
|
||||
assert.True(t, got.RemovedAt.Equal(at))
|
||||
}
|
||||
|
||||
func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, ms := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
|
||||
require.NoError(t, ms.Save(ctx, rec))
|
||||
|
||||
// Move the row out of `active` first; the next attempt's
|
||||
// `WHERE status = 'active'` then fails on persistence even though
|
||||
// (active → blocked) is itself a valid transition in the domain table.
|
||||
require.NoError(t, ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: rec.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusRemoved,
|
||||
At: rec.JoinedAt.Add(time.Minute),
|
||||
}))
|
||||
err := ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: rec.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusBlocked,
|
||||
At: rec.JoinedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.ErrorIs(t, err, membership.ErrConflict)
|
||||
}
|
||||
|
||||
func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, ms := newStores(t)
|
||||
err := ms.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: common.MembershipID("membership-missing"),
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusRemoved,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteRemovesRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, ms := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
|
||||
rec := newMembership(t, "membership-001", "game-001", "user-a", "Pilot", "pilot")
|
||||
require.NoError(t, ms.Save(ctx, rec))
|
||||
require.NoError(t, ms.Delete(ctx, rec.MembershipID))
|
||||
|
||||
_, err := ms.Get(ctx, rec.MembershipID)
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteReturnsNotFoundForMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, ms := newStores(t)
|
||||
err := ms.Delete(ctx, common.MembershipID("membership-missing"))
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGetByGameAndUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gs, ms := newStores(t)
|
||||
seedGame(t, gs, "game-001")
|
||||
seedGame(t, gs, "game-002")
|
||||
|
||||
require.NoError(t, ms.Save(ctx, newMembership(t, "membership-001", "game-001", "user-a", "P-a", "p-a")))
|
||||
require.NoError(t, ms.Save(ctx, newMembership(t, "membership-002", "game-001", "user-b", "P-b", "p-b")))
|
||||
require.NoError(t, ms.Save(ctx, newMembership(t, "membership-003", "game-002", "user-a", "P-a2", "p-a2")))
|
||||
|
||||
g1, err := ms.GetByGame(ctx, common.GameID("game-001"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, g1, 2)
|
||||
|
||||
userA, err := ms.GetByUser(ctx, "user-a")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, userA, 2)
|
||||
}
|
||||
|
||||
func TestGetMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, ms := newStores(t)
|
||||
_, err := ms.Get(ctx, common.MembershipID("membership-missing"))
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
-- +goose Up
|
||||
-- Initial Game Lobby PostgreSQL schema.
|
||||
--
|
||||
-- Five tables cover the durable surface of the service:
|
||||
-- * games, applications, invites, memberships — the four core
|
||||
-- enrollment entities;
|
||||
-- * race_names — the Race Name Directory, holding the registered /
|
||||
-- reservation / pending_registration bindings keyed by canonical key.
|
||||
--
|
||||
-- Schema and the matching `lobbyservice` role are provisioned outside
|
||||
-- this script (in tests via
|
||||
-- integration/internal/harness/postgres_container.go::EnsureRoleAndSchema;
|
||||
-- in production via an ops init script). This migration runs as the
|
||||
-- schema owner with `search_path=lobby` and only contains DDL for the
|
||||
-- service-owned tables and indexes.
|
||||
|
||||
-- games holds one durable record per platform game session. The status +
|
||||
-- created_at index serves the listing/scheduler queries that previously
|
||||
-- read `lobby:games_by_status:*`. The partial owner index serves the
|
||||
-- per-owner listings used by user-lifecycle cascade and "my games"
|
||||
-- listings; public games carry an empty owner_user_id and never enter
|
||||
-- the index.
|
||||
CREATE TABLE games (
|
||||
game_id text PRIMARY KEY,
|
||||
game_name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
game_type text NOT NULL,
|
||||
owner_user_id text NOT NULL DEFAULT '',
|
||||
status text NOT NULL,
|
||||
min_players integer NOT NULL,
|
||||
max_players integer NOT NULL,
|
||||
start_gap_hours integer NOT NULL,
|
||||
start_gap_players integer NOT NULL,
|
||||
enrollment_ends_at timestamptz NOT NULL,
|
||||
turn_schedule text NOT NULL,
|
||||
target_engine_version text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
runtime_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
runtime_binding jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX games_status_created_idx
|
||||
ON games (status, created_at DESC, game_id DESC);
|
||||
|
||||
CREATE INDEX games_owner_idx
|
||||
ON games (owner_user_id) WHERE game_type = 'private';
|
||||
|
||||
-- applications carries one row per public-game enrollment request. The
|
||||
-- partial UNIQUE on (applicant_user_id, game_id) WHERE status <> 'rejected'
|
||||
-- replaces the Redis lookup key `lobby:user_game_application:*:*` and
|
||||
-- enforces the single-active constraint at the database level. Rejected
|
||||
-- applications are kept (one applicant may produce multiple rejected rows
|
||||
-- before submitting a successful one).
|
||||
CREATE TABLE applications (
|
||||
application_id text PRIMARY KEY,
|
||||
game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
applicant_user_id text NOT NULL,
|
||||
race_name text NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
decided_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX applications_game_idx ON applications (game_id);
|
||||
|
||||
CREATE INDEX applications_user_idx ON applications (applicant_user_id);
|
||||
|
||||
CREATE UNIQUE INDEX applications_active_per_user_game_uidx
|
||||
ON applications (applicant_user_id, game_id)
|
||||
WHERE status <> 'rejected';
|
||||
|
||||
-- invites carries one row per private-game invitation. race_name is empty
|
||||
-- until the invite transitions to redeemed. The (status, expires_at) index
|
||||
-- serves the enrollment-automation expiration sweep; the per-game,
|
||||
-- per-invitee, and per-inviter indexes serve listing queries from the
|
||||
-- service layer.
|
||||
CREATE TABLE invites (
|
||||
invite_id text PRIMARY KEY,
|
||||
game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
inviter_user_id text NOT NULL,
|
||||
invitee_user_id text NOT NULL,
|
||||
race_name text NOT NULL DEFAULT '',
|
||||
status text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
decided_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX invites_game_idx ON invites (game_id);
|
||||
CREATE INDEX invites_invitee_idx ON invites (invitee_user_id);
|
||||
CREATE INDEX invites_inviter_idx ON invites (inviter_user_id);
|
||||
CREATE INDEX invites_status_expires_idx ON invites (status, expires_at);
|
||||
|
||||
-- memberships carries one row per platform roster entry. Both race_name
|
||||
-- (original casing) and canonical_key are stored explicitly because
|
||||
-- downstream readers (capability evaluation, cascade release) consume the
|
||||
-- canonical form without re-deriving it from race_name. Race-name
|
||||
-- uniqueness is enforced by the Race Name Directory (the race_names
|
||||
-- table below) — this table intentionally has no unique constraint on
|
||||
-- canonical_key.
|
||||
CREATE TABLE memberships (
|
||||
membership_id text PRIMARY KEY,
|
||||
game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
race_name text NOT NULL,
|
||||
canonical_key text NOT NULL,
|
||||
status text NOT NULL,
|
||||
joined_at timestamptz NOT NULL,
|
||||
removed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX memberships_game_idx ON memberships (game_id);
|
||||
CREATE INDEX memberships_user_idx ON memberships (user_id);
|
||||
|
||||
-- race_names is the durable Race Name Directory store. One row covers one
|
||||
-- of three bindings on a canonical key: a registered name (one per
|
||||
-- canonical_key, immutable holder), a per-game reservation, or a
|
||||
-- pending_registration that is waiting on lobby.race_name.register inside
|
||||
-- the eligible_until_ms window. The composite primary key (canonical_key,
|
||||
-- game_id) lets the same user hold reservations for the same race name
|
||||
-- across multiple active games concurrently, matching the behaviour the
|
||||
-- shared port test suite (lobby/internal/ports/racenamedirtest) covers.
|
||||
-- Registered rows store game_id = '' and keep the source game in
|
||||
-- source_game_id so the per-canonical uniqueness rule expresses cleanly
|
||||
-- as a partial UNIQUE index. Cross-user uniqueness on canonical_key is
|
||||
-- enforced at write time inside transactions guarded by
|
||||
-- pg_advisory_xact_lock(hashtextextended(canonical_key, 0)).
|
||||
CREATE TABLE race_names (
|
||||
canonical_key text NOT NULL,
|
||||
game_id text NOT NULL DEFAULT '',
|
||||
holder_user_id text NOT NULL,
|
||||
race_name text NOT NULL,
|
||||
binding_kind text NOT NULL,
|
||||
source_game_id text NOT NULL DEFAULT '',
|
||||
reserved_at_ms bigint NOT NULL DEFAULT 0,
|
||||
eligible_until_ms bigint,
|
||||
registered_at_ms bigint,
|
||||
PRIMARY KEY (canonical_key, game_id),
|
||||
CONSTRAINT race_names_binding_kind_chk
|
||||
CHECK (binding_kind IN ('registered', 'reservation', 'pending_registration'))
|
||||
);
|
||||
|
||||
-- Exactly one registered binding per canonical_key. Reservations and
|
||||
-- pending_registration entries are differentiated by game_id within the
|
||||
-- primary key.
|
||||
CREATE UNIQUE INDEX race_names_registered_uidx
|
||||
ON race_names (canonical_key)
|
||||
WHERE binding_kind = 'registered';
|
||||
|
||||
-- Per-user listings used by ListRegistered / ListReservations /
|
||||
-- ListPendingRegistrations.
|
||||
CREATE INDEX race_names_holder_idx
|
||||
ON race_names (holder_user_id, binding_kind);
|
||||
|
||||
-- Pending-registration expiration scanner reads only the pending subset
|
||||
-- ordered by eligible_until_ms.
|
||||
CREATE INDEX race_names_pending_eligible_idx
|
||||
ON race_names (eligible_until_ms)
|
||||
WHERE binding_kind = 'pending_registration';
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS race_names;
|
||||
DROP TABLE IF EXISTS memberships;
|
||||
DROP TABLE IF EXISTS invites;
|
||||
DROP TABLE IF EXISTS applications;
|
||||
DROP TABLE IF EXISTS games;
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package migrations exposes the embedded goose migration files used by
|
||||
// Game Lobby Service to provision its `lobby` schema in PostgreSQL.
|
||||
//
|
||||
// The embedded filesystem is consumed by `pkg/postgres.RunMigrations` during
|
||||
// lobby-service startup and by `cmd/jetgen` when regenerating the
|
||||
// `internal/adapters/postgres/jet/` code against a transient PostgreSQL
|
||||
// instance.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var fs embed.FS
|
||||
|
||||
// FS returns the embedded filesystem containing every numbered goose
|
||||
// migration shipped with Game Lobby Service.
|
||||
func FS() embed.FS {
|
||||
return fs
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
package racenamedir_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/lobby/internal/adapters/postgres/racenamedir"
|
||||
"galaxy/lobby/internal/domain/racename"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/ports/racenamedirtest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMain wires the per-package PostgreSQL container shared by every
|
||||
// store test in this module.
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
// newDirectory builds one Race Name Directory adapter against a freshly
|
||||
// truncated lobby schema. now selects between the deterministic clock the
|
||||
// shared suite supplies and the default time.Now.
|
||||
func newDirectory(t *testing.T, now func() time.Time) *racenamedir.Directory {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
policy, err := racename.NewPolicy()
|
||||
require.NoError(t, err)
|
||||
cfg := racenamedir.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
Policy: policy,
|
||||
}
|
||||
if now != nil {
|
||||
cfg.Clock = now
|
||||
}
|
||||
directory, err := racenamedir.New(cfg)
|
||||
require.NoError(t, err)
|
||||
return directory
|
||||
}
|
||||
|
||||
// TestRaceNameDirectoryContract runs the shared behavioural suite that
|
||||
// every ports.RaceNameDirectory implementation must pass.
|
||||
func TestRaceNameDirectoryContract(t *testing.T) {
|
||||
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
|
||||
return newDirectory(t, now)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRejectsNilDB(t *testing.T) {
|
||||
policy, err := racename.NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = racenamedir.New(racenamedir.Config{
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
Policy: policy,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewRejectsNilPolicy(t *testing.T) {
|
||||
_, err := racenamedir.New(racenamedir.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewRejectsNonPositiveTimeout(t *testing.T) {
|
||||
policy, err := racename.NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = racenamedir.New(racenamedir.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
Policy: policy,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestRegisteredRowShape validates the on-disk shape of a registered
|
||||
// binding so future schema migrations have an explicit anchor.
|
||||
func TestRegisteredRowShape(t *testing.T) {
|
||||
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
directory := newDirectory(t, func() time.Time { return now })
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
gameID = "game-shape-1"
|
||||
userID = "user-shape-1"
|
||||
raceName = "PilotNova"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, now.Add(time.Hour)))
|
||||
require.NoError(t, directory.Register(ctx, gameID, userID, raceName))
|
||||
|
||||
pool := pgtest.Ensure(t).Pool()
|
||||
|
||||
canonical, err := directory.Canonicalize(raceName)
|
||||
require.NoError(t, err)
|
||||
|
||||
row := pool.QueryRowContext(ctx, `
|
||||
SELECT canonical_key, game_id, holder_user_id, race_name, binding_kind,
|
||||
source_game_id, reserved_at_ms, eligible_until_ms, registered_at_ms
|
||||
FROM race_names
|
||||
WHERE canonical_key = $1
|
||||
`, canonical)
|
||||
|
||||
var (
|
||||
canonicalKey string
|
||||
storedGameID string
|
||||
holderUserID string
|
||||
raceNameCol string
|
||||
bindingKind string
|
||||
sourceGameID string
|
||||
reservedAtMs int64
|
||||
eligibleAtMs sql.NullInt64
|
||||
registeredAtMs sql.NullInt64
|
||||
)
|
||||
require.NoError(t, row.Scan(
|
||||
&canonicalKey,
|
||||
&storedGameID,
|
||||
&holderUserID,
|
||||
&raceNameCol,
|
||||
&bindingKind,
|
||||
&sourceGameID,
|
||||
&reservedAtMs,
|
||||
&eligibleAtMs,
|
||||
®isteredAtMs,
|
||||
))
|
||||
|
||||
assert.Equal(t, canonical, canonicalKey)
|
||||
assert.Equal(t, "", storedGameID, "registered rows store game_id = ''")
|
||||
assert.Equal(t, userID, holderUserID)
|
||||
assert.Equal(t, raceName, raceNameCol)
|
||||
assert.Equal(t, ports.KindRegistered, bindingKind)
|
||||
assert.Equal(t, gameID, sourceGameID)
|
||||
assert.True(t, registeredAtMs.Valid)
|
||||
assert.Equal(t, now.UTC().UnixMilli(), registeredAtMs.Int64)
|
||||
assert.False(t, eligibleAtMs.Valid, "registered rows null out eligible_until_ms")
|
||||
assert.Equal(t, now.UTC().UnixMilli(), reservedAtMs, "reserved_at_ms is preserved across promote+register")
|
||||
}
|
||||
|
||||
// TestRegisteredPartialUniqueIndex confirms that a second user cannot
|
||||
// register the same canonical key, even when they own a separate
|
||||
// reservation row at a different (canonical_key, game_id) PK.
|
||||
func TestRegisteredPartialUniqueIndex(t *testing.T) {
|
||||
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
directory := newDirectory(t, func() time.Time { return now })
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
raceName = "PilotNova"
|
||||
gameA = "game-unique-a"
|
||||
userA = "user-unique-a"
|
||||
userB = "user-unique-b"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceName))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceName, now.Add(time.Hour)))
|
||||
require.NoError(t, directory.Register(ctx, gameA, userA, raceName))
|
||||
|
||||
err := directory.Reserve(ctx, gameA, userB, raceName)
|
||||
require.ErrorIs(t, err, ports.ErrNameTaken)
|
||||
}
|
||||
|
||||
// TestExpirePendingRegistrationsBatched seeds two pending entries with
|
||||
// distinct canonical keys and asserts both are released by a single pass
|
||||
// even when the worker iterates via separate advisory locks.
|
||||
func TestExpirePendingRegistrationsBatched(t *testing.T) {
|
||||
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
directory := newDirectory(t, func() time.Time { return now })
|
||||
ctx := context.Background()
|
||||
|
||||
for index := range 3 {
|
||||
gameID := "game-batch-" + strconv.Itoa(index)
|
||||
userID := "user-batch-" + strconv.Itoa(index)
|
||||
raceName := "PilotBatch" + strconv.Itoa(index)
|
||||
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, now.Add(time.Hour)))
|
||||
}
|
||||
|
||||
expired, err := directory.ExpirePendingRegistrations(ctx, now.Add(2*time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, expired, 3)
|
||||
|
||||
expired, err = directory.ExpirePendingRegistrations(ctx, now.Add(2*time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, expired, "second pass releases nothing")
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ApplicationStore provides Redis-backed durable storage for application
|
||||
// records.
|
||||
type ApplicationStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewApplicationStore constructs one Redis-backed application store. It
|
||||
// returns an error when client is nil.
|
||||
func NewApplicationStore(client *redis.Client) (*ApplicationStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new application store: nil redis client")
|
||||
}
|
||||
|
||||
return &ApplicationStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save persists a new submitted application record and enforces the
|
||||
// single-active (non-rejected) constraint per (applicant, game) pair.
|
||||
func (store *ApplicationStore) Save(ctx context.Context, record application.Application) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save application: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save application: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save application: %w", err)
|
||||
}
|
||||
if record.Status != application.StatusSubmitted {
|
||||
return fmt.Errorf(
|
||||
"save application: status must be %q, got %q",
|
||||
application.StatusSubmitted, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := MarshalApplication(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save application: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Application(record.ApplicationID)
|
||||
activeLookupKey := store.keys.UserGameApplication(record.ApplicantUserID, record.GameID)
|
||||
gameIndexKey := store.keys.ApplicationsByGame(record.GameID)
|
||||
userIndexKey := store.keys.ApplicationsByUser(record.ApplicantUserID)
|
||||
member := record.ApplicationID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
existingPrimary, getErr := tx.Exists(ctx, primaryKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save application: %w", getErr)
|
||||
}
|
||||
if existingPrimary != 0 {
|
||||
return fmt.Errorf("save application: %w", application.ErrConflict)
|
||||
}
|
||||
|
||||
existingActive, getErr := tx.Exists(ctx, activeLookupKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save application: %w", getErr)
|
||||
}
|
||||
if existingActive != 0 {
|
||||
return fmt.Errorf("save application: %w", application.ErrConflict)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, ApplicationRecordTTL)
|
||||
pipe.Set(ctx, activeLookupKey, member, ApplicationRecordTTL)
|
||||
pipe.SAdd(ctx, gameIndexKey, member)
|
||||
pipe.SAdd(ctx, userIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey, activeLookupKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save application: %w", application.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by applicationID.
|
||||
func (store *ApplicationStore) Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return application.Application{}, errors.New("get application: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return application.Application{}, errors.New("get application: nil context")
|
||||
}
|
||||
if err := applicationID.Validate(); err != nil {
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Application(applicationID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return application.Application{}, application.ErrNotFound
|
||||
case err != nil:
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalApplication(payload)
|
||||
if err != nil {
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every application attached to gameID.
|
||||
func (store *ApplicationStore) GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get applications by game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get applications by game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get applications by game: %w", err)
|
||||
}
|
||||
|
||||
return store.loadApplicationsBySet(ctx,
|
||||
"get applications by game",
|
||||
store.keys.ApplicationsByGame(gameID),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByUser returns every application submitted by applicantUserID.
|
||||
func (store *ApplicationStore) GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get applications by user: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get applications by user: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(applicantUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get applications by user: applicant user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadApplicationsBySet(ctx,
|
||||
"get applications by user",
|
||||
store.keys.ApplicationsByUser(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// loadApplicationsBySet materializes applications whose ids are stored in
|
||||
// setKey. Stale set members (primary key removed out-of-band) are dropped
|
||||
// silently, mirroring gamestore.GetByStatus.
|
||||
func (store *ApplicationStore) loadApplicationsBySet(ctx context.Context, operation, setKey string) ([]application.Application, error) {
|
||||
members, err := store.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Application(common.ApplicationID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
records := make([]application.Application, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry)
|
||||
}
|
||||
record, err := UnmarshalApplication([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap fashion.
|
||||
func (store *ApplicationStore) UpdateStatus(ctx context.Context, input ports.UpdateApplicationStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update application status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update application status: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update application status: %w", err)
|
||||
}
|
||||
|
||||
if err := application.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Application(input.ApplicationID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return application.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update application status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalApplication(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update application status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update application status: %w", application.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
decidedAt := at
|
||||
existing.DecidedAt = &decidedAt
|
||||
|
||||
encoded, err := MarshalApplication(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update application status: %w", err)
|
||||
}
|
||||
|
||||
activeLookupKey := store.keys.UserGameApplication(existing.ApplicantUserID, existing.GameID)
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, ApplicationRecordTTL)
|
||||
if input.To == application.StatusRejected {
|
||||
pipe.Del(ctx, activeLookupKey)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update application status: %w", application.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure ApplicationStore satisfies the ports.ApplicationStore interface
|
||||
// at compile time.
|
||||
var _ ports.ApplicationStore = (*ApplicationStore)(nil)
|
||||
@@ -1,360 +0,0 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newApplicationTestStore(t *testing.T) (*redisstate.ApplicationStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewApplicationStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureApplication(t *testing.T, id common.ApplicationID, userID string, gameID common.GameID) application.Application {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record, err := application.New(application.NewApplicationInput{
|
||||
ApplicationID: id,
|
||||
GameID: gameID,
|
||||
ApplicantUserID: userID,
|
||||
RaceName: "Spring Racer",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
|
||||
func TestNewApplicationStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewApplicationStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.ApplicationID, got.ApplicationID)
|
||||
assert.Equal(t, record.GameID, got.GameID)
|
||||
assert.Equal(t, record.ApplicantUserID, got.ApplicantUserID)
|
||||
assert.Equal(t, record.RaceName, got.RaceName)
|
||||
assert.Equal(t, application.StatusSubmitted, got.Status)
|
||||
assert.Nil(t, got.DecidedAt)
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_applications:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.ApplicationID.String()}, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_applications:"+base64URL(record.ApplicantUserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.ApplicationID.String()}, byUser)
|
||||
|
||||
active, err := client.Get(ctx,
|
||||
"lobby:user_game_application:"+base64URL(record.ApplicantUserID)+":"+base64URL(record.GameID.String()),
|
||||
).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.ApplicationID.String(), active)
|
||||
}
|
||||
|
||||
func TestApplicationStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.ApplicationID("application-missing"))
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveRejectsNonSubmitted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
record.Status = application.StatusApproved
|
||||
decidedAt := record.CreatedAt.Add(time.Minute)
|
||||
record.DecidedAt = &decidedAt
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, application.ErrConflict))
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveRejectsSecondActiveForSameUserGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
first := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, first))
|
||||
|
||||
second := fixtureApplication(t, "application-b", "user-1", "game-1")
|
||||
err := store.Save(ctx, second)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrConflict))
|
||||
|
||||
_, err = store.Get(ctx, second.ApplicationID)
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveRejectsDuplicateApplicationID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
first := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, first))
|
||||
|
||||
err := store.Save(ctx, first)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrConflict))
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveAllowsSameUserDifferentGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
first := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
second := fixtureApplication(t, "application-b", "user-1", "game-2")
|
||||
|
||||
require.NoError(t, store.Save(ctx, first))
|
||||
require.NoError(t, store.Save(ctx, second))
|
||||
|
||||
byUser, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser, 2)
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusApproveKeepsActiveKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, application.StatusApproved, got.Status)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.True(t, got.DecidedAt.Equal(at.UTC()))
|
||||
|
||||
activeKey := "lobby:user_game_application:" + base64URL(record.ApplicantUserID) + ":" + base64URL(record.GameID.String())
|
||||
stored, err := client.Get(ctx, activeKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.ApplicationID.String(), stored)
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusRejectClearsActiveKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusRejected,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, application.StatusRejected, got.Status)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
|
||||
activeKey := "lobby:user_game_application:" + base64URL(record.ApplicantUserID) + ":" + base64URL(record.GameID.String())
|
||||
_, err = client.Get(ctx, activeKey).Result()
|
||||
require.ErrorIs(t, err, redis.Nil)
|
||||
|
||||
// After rejection, the same user may re-apply to the same game.
|
||||
reapplied := fixtureApplication(t, "application-b", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, reapplied))
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusApproved,
|
||||
To: application.StatusSubmitted,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrInvalidTransition))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, application.StatusSubmitted, got.Status)
|
||||
assert.Nil(t, got.DecidedAt)
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusRejected,
|
||||
At: record.CreatedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrConflict))
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: common.ApplicationID("application-missing"),
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestApplicationStoreGetByGameAndByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
a1 := fixtureApplication(t, "application-a1", "user-1", "game-1")
|
||||
a2 := fixtureApplication(t, "application-a2", "user-2", "game-1")
|
||||
a3 := fixtureApplication(t, "application-a3", "user-1", "game-2")
|
||||
|
||||
for _, record := range []application.Application{a1, a2, a3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byGame1, err := store.GetByGame(ctx, "game-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byGame1, 2)
|
||||
|
||||
byUser1, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser1, 2)
|
||||
|
||||
ids := collectApplicationIDs(byUser1)
|
||||
sort.Strings(ids)
|
||||
assert.Equal(t, []string{"application-a1", "application-a3"}, ids)
|
||||
|
||||
byUser3, err := store.GetByUser(ctx, "user-missing")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byUser3)
|
||||
}
|
||||
|
||||
func TestApplicationStoreGetByGameDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
server.Del("lobby:applications:" + base64URL(record.ApplicationID.String()))
|
||||
|
||||
records, err := store.GetByGame(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestApplicationStoreConcurrentSaveHasExactlyOneWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _, client := newApplicationTestStore(t)
|
||||
|
||||
storeA, err := redisstate.NewApplicationStore(client)
|
||||
require.NoError(t, err)
|
||||
storeB, err := redisstate.NewApplicationStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
recordA := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
recordB := fixtureApplication(t, "application-b", "user-1", "game-1")
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
successes atomic.Int32
|
||||
conflicts atomic.Int32
|
||||
others atomic.Int32
|
||||
)
|
||||
|
||||
apply := func(target *redisstate.ApplicationStore, record application.Application) {
|
||||
defer wg.Done()
|
||||
err := target.Save(ctx, record)
|
||||
switch {
|
||||
case err == nil:
|
||||
successes.Add(1)
|
||||
case errors.Is(err, application.ErrConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
others.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go apply(storeA, recordA)
|
||||
go apply(storeB, recordB)
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int32(0), others.Load(), "unexpected non-conflict error")
|
||||
assert.Equal(t, int32(1), successes.Load(), "expected exactly one success")
|
||||
assert.Equal(t, int32(1), conflicts.Load(), "expected exactly one conflict")
|
||||
}
|
||||
|
||||
func collectApplicationIDs(records []application.Application) []string {
|
||||
ids := make([]string, len(records))
|
||||
for index, record := range records {
|
||||
ids[index] = record.ApplicationID.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
)
|
||||
|
||||
// gameRecord stores the strict Redis JSON shape used for one game record.
|
||||
type gameRecord struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
GameType game.GameType `json:"game_type"`
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
Status game.Status `json:"status"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAtSec int64 `json:"enrollment_ends_at_sec"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
|
||||
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status,omitempty"`
|
||||
EngineHealthSummary string `json:"engine_health_summary,omitempty"`
|
||||
RuntimeBinding *runtimeBindingRecord `json:"runtime_binding,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeBindingRecord stores the strict Redis JSON shape used for the
|
||||
// optional runtime binding object on one game record.
|
||||
type runtimeBindingRecord struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
RuntimeJobID string `json:"runtime_job_id"`
|
||||
BoundAtMS int64 `json:"bound_at_ms"`
|
||||
}
|
||||
|
||||
// MarshalGame encodes record into the strict Redis JSON shape used for
|
||||
// game records. The record is re-validated before marshalling.
|
||||
func MarshalGame(record game.Game) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis game record: %w", err)
|
||||
}
|
||||
|
||||
stored := gameRecord{
|
||||
GameID: record.GameID.String(),
|
||||
GameName: record.GameName,
|
||||
Description: record.Description,
|
||||
GameType: record.GameType,
|
||||
OwnerUserID: record.OwnerUserID,
|
||||
Status: record.Status,
|
||||
MinPlayers: record.MinPlayers,
|
||||
MaxPlayers: record.MaxPlayers,
|
||||
StartGapHours: record.StartGapHours,
|
||||
StartGapPlayers: record.StartGapPlayers,
|
||||
EnrollmentEndsAtSec: record.EnrollmentEndsAt.UTC().Unix(),
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
TargetEngineVersion: record.TargetEngineVersion,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
StartedAtMS: optionalUnixMilli(record.StartedAt),
|
||||
FinishedAtMS: optionalUnixMilli(record.FinishedAt),
|
||||
CurrentTurn: record.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: record.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealthSummary: record.RuntimeSnapshot.EngineHealthSummary,
|
||||
}
|
||||
if record.RuntimeBinding != nil {
|
||||
stored.RuntimeBinding = &runtimeBindingRecord{
|
||||
ContainerID: record.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: record.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: record.RuntimeBinding.RuntimeJobID,
|
||||
BoundAtMS: record.RuntimeBinding.BoundAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis game record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalGame decodes payload from the strict Redis JSON shape used for
|
||||
// game records. The decoded record is validated before returning.
|
||||
func UnmarshalGame(payload []byte) (game.Game, error) {
|
||||
var stored gameRecord
|
||||
if err := decodeStrictJSON("decode redis game record", payload, &stored); err != nil {
|
||||
return game.Game{}, err
|
||||
}
|
||||
|
||||
record := game.Game{
|
||||
GameID: common.GameID(stored.GameID),
|
||||
GameName: stored.GameName,
|
||||
Description: stored.Description,
|
||||
GameType: stored.GameType,
|
||||
OwnerUserID: stored.OwnerUserID,
|
||||
Status: stored.Status,
|
||||
MinPlayers: stored.MinPlayers,
|
||||
MaxPlayers: stored.MaxPlayers,
|
||||
StartGapHours: stored.StartGapHours,
|
||||
StartGapPlayers: stored.StartGapPlayers,
|
||||
EnrollmentEndsAt: time.Unix(stored.EnrollmentEndsAtSec, 0).UTC(),
|
||||
TurnSchedule: stored.TurnSchedule,
|
||||
TargetEngineVersion: stored.TargetEngineVersion,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
|
||||
StartedAt: inflateOptionalTime(stored.StartedAtMS),
|
||||
FinishedAt: inflateOptionalTime(stored.FinishedAtMS),
|
||||
RuntimeSnapshot: game.RuntimeSnapshot{
|
||||
CurrentTurn: stored.CurrentTurn,
|
||||
RuntimeStatus: stored.RuntimeStatus,
|
||||
EngineHealthSummary: stored.EngineHealthSummary,
|
||||
},
|
||||
}
|
||||
if stored.RuntimeBinding != nil {
|
||||
record.RuntimeBinding = &game.RuntimeBinding{
|
||||
ContainerID: stored.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: stored.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: stored.RuntimeBinding.RuntimeJobID,
|
||||
BoundAt: time.UnixMilli(stored.RuntimeBinding.BoundAtMS).UTC(),
|
||||
}
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return game.Game{}, fmt.Errorf("decode redis game record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(operation string, payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%s: unexpected trailing JSON input", operation)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalUnixMilli(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
milliseconds := value.UTC().UnixMilli()
|
||||
return &milliseconds
|
||||
}
|
||||
|
||||
func inflateOptionalTime(value *int64) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
converted := time.UnixMilli(*value).UTC()
|
||||
return &converted
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
)
|
||||
|
||||
// applicationRecord stores the strict Redis JSON shape used for one
|
||||
// application record.
|
||||
type applicationRecord struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status application.Status `json:"status"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
DecidedAtMS *int64 `json:"decided_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalApplication encodes record into the strict Redis JSON shape
|
||||
// used for application records. The record is re-validated before
|
||||
// marshalling.
|
||||
func MarshalApplication(record application.Application) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis application record: %w", err)
|
||||
}
|
||||
|
||||
stored := applicationRecord{
|
||||
ApplicationID: record.ApplicationID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
ApplicantUserID: record.ApplicantUserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: record.Status,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
DecidedAtMS: optionalUnixMilli(record.DecidedAt),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis application record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalApplication decodes payload from the strict Redis JSON shape
|
||||
// used for application records. The decoded record is validated before
|
||||
// returning.
|
||||
func UnmarshalApplication(payload []byte) (application.Application, error) {
|
||||
var stored applicationRecord
|
||||
if err := decodeStrictJSON("decode redis application record", payload, &stored); err != nil {
|
||||
return application.Application{}, err
|
||||
}
|
||||
|
||||
record := application.Application{
|
||||
ApplicationID: common.ApplicationID(stored.ApplicationID),
|
||||
GameID: common.GameID(stored.GameID),
|
||||
ApplicantUserID: stored.ApplicantUserID,
|
||||
RaceName: stored.RaceName,
|
||||
Status: stored.Status,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
DecidedAt: inflateOptionalTime(stored.DecidedAtMS),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return application.Application{}, fmt.Errorf("decode redis application record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
)
|
||||
|
||||
// inviteRecord stores the strict Redis JSON shape used for one invite
|
||||
// record.
|
||||
type inviteRecord struct {
|
||||
InviteID string `json:"invite_id"`
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
RaceName string `json:"race_name,omitempty"`
|
||||
Status invite.Status `json:"status"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||
DecidedAtMS *int64 `json:"decided_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalInvite encodes record into the strict Redis JSON shape used for
|
||||
// invite records. The record is re-validated before marshalling.
|
||||
func MarshalInvite(record invite.Invite) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis invite record: %w", err)
|
||||
}
|
||||
|
||||
stored := inviteRecord{
|
||||
InviteID: record.InviteID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
InviterUserID: record.InviterUserID,
|
||||
InviteeUserID: record.InviteeUserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: record.Status,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
ExpiresAtMS: record.ExpiresAt.UTC().UnixMilli(),
|
||||
DecidedAtMS: optionalUnixMilli(record.DecidedAt),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis invite record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalInvite decodes payload from the strict Redis JSON shape used
|
||||
// for invite records. The decoded record is validated before returning.
|
||||
func UnmarshalInvite(payload []byte) (invite.Invite, error) {
|
||||
var stored inviteRecord
|
||||
if err := decodeStrictJSON("decode redis invite record", payload, &stored); err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
|
||||
record := invite.Invite{
|
||||
InviteID: common.InviteID(stored.InviteID),
|
||||
GameID: common.GameID(stored.GameID),
|
||||
InviterUserID: stored.InviterUserID,
|
||||
InviteeUserID: stored.InviteeUserID,
|
||||
RaceName: stored.RaceName,
|
||||
Status: stored.Status,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
ExpiresAt: time.UnixMilli(stored.ExpiresAtMS).UTC(),
|
||||
DecidedAt: inflateOptionalTime(stored.DecidedAtMS),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("decode redis invite record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
)
|
||||
|
||||
// membershipRecord stores the strict Redis JSON shape used for one
|
||||
// membership record.
|
||||
type membershipRecord struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
GameID string `json:"game_id"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
Status membership.Status `json:"status"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms"`
|
||||
RemovedAtMS *int64 `json:"removed_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalMembership encodes record into the strict Redis JSON shape used
|
||||
// for membership records. The record is re-validated before marshalling.
|
||||
func MarshalMembership(record membership.Membership) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis membership record: %w", err)
|
||||
}
|
||||
|
||||
stored := membershipRecord{
|
||||
MembershipID: record.MembershipID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
UserID: record.UserID,
|
||||
RaceName: record.RaceName,
|
||||
CanonicalKey: record.CanonicalKey,
|
||||
Status: record.Status,
|
||||
JoinedAtMS: record.JoinedAt.UTC().UnixMilli(),
|
||||
RemovedAtMS: optionalUnixMilli(record.RemovedAt),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis membership record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalMembership decodes payload from the strict Redis JSON shape
|
||||
// used for membership records. The decoded record is validated before
|
||||
// returning.
|
||||
func UnmarshalMembership(payload []byte) (membership.Membership, error) {
|
||||
var stored membershipRecord
|
||||
if err := decodeStrictJSON("decode redis membership record", payload, &stored); err != nil {
|
||||
return membership.Membership{}, err
|
||||
}
|
||||
|
||||
record := membership.Membership{
|
||||
MembershipID: common.MembershipID(stored.MembershipID),
|
||||
GameID: common.GameID(stored.GameID),
|
||||
UserID: stored.UserID,
|
||||
RaceName: stored.RaceName,
|
||||
CanonicalKey: stored.CanonicalKey,
|
||||
Status: stored.Status,
|
||||
JoinedAt: time.UnixMilli(stored.JoinedAtMS).UTC(),
|
||||
RemovedAt: inflateOptionalTime(stored.RemovedAtMS),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("decode redis membership record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registeredRecord stores the strict Redis JSON shape of one registered
|
||||
// race name. The canonical key is stored only as the Redis key suffix and
|
||||
// is not duplicated inside the blob.
|
||||
type registeredRecord struct {
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
SourceGameID string `json:"source_game_id"`
|
||||
RegisteredAtMS int64 `json:"registered_at_ms"`
|
||||
}
|
||||
|
||||
// reservationStatusReserved marks a per-game race name reservation that
|
||||
// has not yet been promoted by capability evaluation.
|
||||
const reservationStatusReserved = "reserved"
|
||||
|
||||
// reservationStatusPending marks a reservation that has been promoted to
|
||||
// pending_registration by the capability evaluator at game_finished.
|
||||
const reservationStatusPending = "pending_registration"
|
||||
|
||||
// reservationRecord stores the strict Redis JSON shape of one per-game
|
||||
// race name reservation. The game_id and canonical key are carried by the
|
||||
// Redis key suffix; the blob never duplicates them.
|
||||
type reservationRecord struct {
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
ReservedAtMS int64 `json:"reserved_at_ms"`
|
||||
Status string `json:"status"`
|
||||
EligibleUntilMS *int64 `json:"eligible_until_ms,omitempty"`
|
||||
}
|
||||
|
||||
// canonicalLookupRecord stores the eager canonical-lookup cache entry
|
||||
// used by Check to return availability without scanning the authoritative
|
||||
// keys. GameID is populated only for reservation and pending_registration
|
||||
// kinds; it is omitted for registered bindings.
|
||||
type canonicalLookupRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
HolderUserID string `json:"holder_user_id"`
|
||||
GameID string `json:"game_id,omitempty"`
|
||||
}
|
||||
|
||||
// marshalRegisteredRecord encodes record into the strict Redis JSON shape
|
||||
// used for registered race names.
|
||||
func marshalRegisteredRecord(record registeredRecord) ([]byte, error) {
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis registered race name record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalRegisteredRecord decodes payload from the strict Redis JSON
|
||||
// shape used for registered race names.
|
||||
func unmarshalRegisteredRecord(payload []byte) (registeredRecord, error) {
|
||||
var record registeredRecord
|
||||
if err := decodeStrictJSON("decode redis registered race name record", payload, &record); err != nil {
|
||||
return registeredRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// marshalReservationRecord encodes record into the strict Redis JSON
|
||||
// shape used for per-game race name reservations.
|
||||
func marshalReservationRecord(record reservationRecord) ([]byte, error) {
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis race name reservation record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalReservationRecord decodes payload from the strict Redis JSON
|
||||
// shape used for per-game race name reservations.
|
||||
func unmarshalReservationRecord(payload []byte) (reservationRecord, error) {
|
||||
var record reservationRecord
|
||||
if err := decodeStrictJSON("decode redis race name reservation record", payload, &record); err != nil {
|
||||
return reservationRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// marshalCanonicalLookupRecord encodes record into the strict Redis JSON
|
||||
// shape used for canonical-lookup cache entries.
|
||||
func marshalCanonicalLookupRecord(record canonicalLookupRecord) ([]byte, error) {
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis race name canonical lookup record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalCanonicalLookupRecord decodes payload from the strict Redis
|
||||
// JSON shape used for canonical-lookup cache entries.
|
||||
func unmarshalCanonicalLookupRecord(payload []byte) (canonicalLookupRecord, error) {
|
||||
var record canonicalLookupRecord
|
||||
if err := decodeStrictJSON("decode redis race name canonical lookup record", payload, &record); err != nil {
|
||||
return canonicalLookupRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
// Package redisstate defines the frozen Game Lobby Service Redis keyspace,
|
||||
// strict JSON record shapes, and low-level mutation helpers used by the
|
||||
// Game Lobby store adapters.
|
||||
// Package redisstate defines the Game Lobby Service Redis keyspace and
|
||||
// the adapters for the runtime-coordination state that intentionally
|
||||
// stays on Redis after the PG_PLAN.md §6A and §6B migrations.
|
||||
//
|
||||
// Adapters in this package implement ports.GameStore,
|
||||
// ports.ApplicationStore, ports.InviteStore, and ports.MembershipStore on
|
||||
// top of a `*redis.Client`. Every marshal and unmarshal round-trip calls
|
||||
// the domain-level Validate method to guarantee that the store never
|
||||
// exposes malformed records.
|
||||
// Adapters in this package implement ports.GameTurnStatsStore,
|
||||
// ports.GapActivationStore, ports.EvaluationGuardStore, and
|
||||
// ports.StreamOffsetStore plus the StreamLagProbe used for telemetry. The
|
||||
// durable enrollment entities (game, application, invite, membership)
|
||||
// and the Race Name Directory live in PostgreSQL; their previous Redis
|
||||
// adapters and codecs have been removed.
|
||||
package redisstate
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// GameStore provides Redis-backed durable storage for game records.
|
||||
type GameStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewGameStore constructs one Redis-backed game store. It returns an
|
||||
// error when client is nil.
|
||||
func NewGameStore(client *redis.Client) (*GameStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new game store: nil redis client")
|
||||
}
|
||||
|
||||
return &GameStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save upserts record and rewrites the status secondary index when the
|
||||
// status changes.
|
||||
func (store *GameStore) Save(ctx context.Context, record game.Game) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save game: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save game: %w", err)
|
||||
}
|
||||
|
||||
payload, err := MarshalGame(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save game: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(record.GameID)
|
||||
newIndexKey := store.keys.GamesByStatus(record.Status)
|
||||
member := record.GameID.String()
|
||||
createdAtScore := CreatedAtScore(record.CreatedAt)
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
var previousStatus game.Status
|
||||
existingPayload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
previousStatus = ""
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("save game: %w", getErr)
|
||||
default:
|
||||
existing, err := UnmarshalGame(existingPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save game: %w", err)
|
||||
}
|
||||
previousStatus = existing.Status
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, GameRecordTTL)
|
||||
if previousStatus != "" && previousStatus != record.Status {
|
||||
pipe.ZRem(ctx, store.keys.GamesByStatus(previousStatus), member)
|
||||
}
|
||||
pipe.ZAdd(ctx, newIndexKey, redis.Z{
|
||||
Score: createdAtScore,
|
||||
Member: member,
|
||||
})
|
||||
if owner := strings.TrimSpace(record.OwnerUserID); owner != "" {
|
||||
pipe.SAdd(ctx, store.keys.GamesByOwner(owner), member)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save game: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by gameID.
|
||||
func (store *GameStore) Get(ctx context.Context, gameID common.GameID) (game.Game, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return game.Game{}, errors.New("get game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return game.Game{}, errors.New("get game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return game.Game{}, fmt.Errorf("get game: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Game(gameID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return game.Game{}, game.ErrNotFound
|
||||
case err != nil:
|
||||
return game.Game{}, fmt.Errorf("get game: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return game.Game{}, fmt.Errorf("get game: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByStatus returns every record indexed under status. Stale index
|
||||
// entries (primary key removed out-of-band) are dropped silently.
|
||||
func (store *GameStore) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get games by status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get games by status: nil context")
|
||||
}
|
||||
if !status.IsKnown() {
|
||||
return nil, fmt.Errorf("get games by status: status %q is unsupported", status)
|
||||
}
|
||||
|
||||
members, err := store.client.ZRange(ctx, store.keys.GamesByStatus(status), 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by status: %w", err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Game(common.GameID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by status: %w", err)
|
||||
}
|
||||
|
||||
records := make([]game.Game, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get games by status: unexpected payload type %T", entry)
|
||||
}
|
||||
record, err := UnmarshalGame([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by status: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// CountByStatus returns the number of game identifiers indexed under each
|
||||
// known status. The map carries one entry per game.AllStatuses, with zero
|
||||
// counts for empty buckets. The implementation issues one ZCARD per status
|
||||
// in a single Redis pipeline so the cost stays O(number of statuses).
|
||||
func (store *GameStore) CountByStatus(ctx context.Context) (map[game.Status]int, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("count games by status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("count games by status: nil context")
|
||||
}
|
||||
|
||||
statuses := game.AllStatuses()
|
||||
pipeline := store.client.Pipeline()
|
||||
results := make([]*redis.IntCmd, len(statuses))
|
||||
for index, status := range statuses {
|
||||
results[index] = pipeline.ZCard(ctx, store.keys.GamesByStatus(status))
|
||||
}
|
||||
if _, err := pipeline.Exec(ctx); err != nil {
|
||||
return nil, fmt.Errorf("count games by status: %w", err)
|
||||
}
|
||||
|
||||
counts := make(map[game.Status]int, len(statuses))
|
||||
for index, status := range statuses {
|
||||
count, err := results[index].Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count games by status: %s: %w", status, err)
|
||||
}
|
||||
counts[status] = int(count)
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// GetByOwner returns every record whose OwnerUserID equals userID.
|
||||
// Stale index entries (primary key removed out-of-band) are dropped
|
||||
// silently. The slice order is adapter-defined.
|
||||
func (store *GameStore) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get games by owner: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get games by owner: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get games by owner: user id must not be empty")
|
||||
}
|
||||
|
||||
members, err := store.client.SMembers(ctx, store.keys.GamesByOwner(trimmed)).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by owner: %w", err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Game(common.GameID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by owner: %w", err)
|
||||
}
|
||||
|
||||
records := make([]game.Game, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get games by owner: unexpected payload type %T", entry)
|
||||
}
|
||||
record, err := UnmarshalGame([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by owner: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap
|
||||
// fashion.
|
||||
func (store *GameStore) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update game status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update game status: nil context")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(input.GameID)
|
||||
member := input.GameID.String()
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return game.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update game status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update game status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update game status: %w", game.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
existing.UpdatedAt = at
|
||||
if input.To == game.StatusRunning && existing.StartedAt == nil {
|
||||
startedAt := at
|
||||
existing.StartedAt = &startedAt
|
||||
}
|
||||
if input.To == game.StatusFinished && existing.FinishedAt == nil {
|
||||
finishedAt := at
|
||||
existing.FinishedAt = &finishedAt
|
||||
}
|
||||
|
||||
encoded, err := MarshalGame(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update game status: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, GameRecordTTL)
|
||||
pipe.ZRem(ctx, store.keys.GamesByStatus(input.ExpectedFrom), member)
|
||||
pipe.ZAdd(ctx, store.keys.GamesByStatus(input.To), redis.Z{
|
||||
Score: CreatedAtScore(existing.CreatedAt),
|
||||
Member: member,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update game status: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot
|
||||
// fields on the record identified by input.GameID.
|
||||
func (store *GameStore) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update runtime snapshot: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update runtime snapshot: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update runtime snapshot: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(input.GameID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return game.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update runtime snapshot: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime snapshot: %w", err)
|
||||
}
|
||||
|
||||
existing.RuntimeSnapshot = input.Snapshot
|
||||
existing.UpdatedAt = at
|
||||
|
||||
encoded, err := MarshalGame(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime snapshot: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, GameRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update runtime snapshot: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateRuntimeBinding overwrites the runtime binding metadata on the
|
||||
// record identified by input.GameID. calls this method from
|
||||
// the runtimejobresult worker after a successful container start.
|
||||
func (store *GameStore) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update runtime binding: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update runtime binding: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update runtime binding: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(input.GameID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return game.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update runtime binding: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime binding: %w", err)
|
||||
}
|
||||
|
||||
binding := input.Binding
|
||||
existing.RuntimeBinding = &binding
|
||||
existing.UpdatedAt = at
|
||||
|
||||
encoded, err := MarshalGame(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime binding: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, GameRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update runtime binding: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure GameStore satisfies the ports.GameStore interface at compile
|
||||
// time.
|
||||
var _ ports.GameStore = (*GameStore)(nil)
|
||||
@@ -1,557 +0,0 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) (*redisstate.GameStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewGameStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureGame(t *testing.T) 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("game-1"),
|
||||
GameName: "Spring Classic",
|
||||
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 statusIndexMembers(t *testing.T, client *redis.Client, status game.Status) []string {
|
||||
t.Helper()
|
||||
|
||||
members, err := client.ZRange(context.Background(), "lobby:games_by_status:"+base64URL(string(status)), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
return members
|
||||
}
|
||||
|
||||
func TestNewGameStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewGameStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGameStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
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.Status, got.Status)
|
||||
assert.Equal(t, record.GameName, got.GameName)
|
||||
assert.Equal(t, record.MinPlayers, got.MinPlayers)
|
||||
assert.Equal(t, record.MaxPlayers, got.MaxPlayers)
|
||||
assert.Equal(t, record.EnrollmentEndsAt.Unix(), got.EnrollmentEndsAt.Unix())
|
||||
|
||||
members := statusIndexMembers(t, client, game.StatusDraft)
|
||||
assert.Contains(t, members, record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.GameID("game-missing"))
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreSaveRewritesStatusIndexOnStatusChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
record.Status = game.StatusEnrollmentOpen
|
||||
record.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
assert.Empty(t, statusIndexMembers(t, client, game.StatusDraft))
|
||||
assert.Contains(t, statusIndexMembers(t, client, game.StatusEnrollmentOpen), record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreCountByStatusReturnsAllBuckets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record1 := fixtureGame(t)
|
||||
record1.GameID = common.GameID("game-count-a")
|
||||
|
||||
record2 := fixtureGame(t)
|
||||
record2.GameID = common.GameID("game-count-b")
|
||||
record2.CreatedAt = record2.CreatedAt.Add(time.Second)
|
||||
record2.UpdatedAt = record2.CreatedAt
|
||||
|
||||
record3 := fixtureGame(t)
|
||||
record3.GameID = common.GameID("game-count-c")
|
||||
record3.Status = game.StatusEnrollmentOpen
|
||||
|
||||
for _, record := range []game.Game{record1, record2, record3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
counts, err := store.CountByStatus(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, status := range game.AllStatuses() {
|
||||
_, present := counts[status]
|
||||
require.True(t, present, "expected %s bucket", status)
|
||||
}
|
||||
require.Equal(t, 2, counts[game.StatusDraft])
|
||||
require.Equal(t, 1, counts[game.StatusEnrollmentOpen])
|
||||
require.Equal(t, 0, counts[game.StatusRunning])
|
||||
}
|
||||
|
||||
func TestGameStoreGetByStatusReturnsMatchingRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record1 := fixtureGame(t)
|
||||
record1.GameID = common.GameID("game-a")
|
||||
|
||||
record2 := fixtureGame(t)
|
||||
record2.GameID = common.GameID("game-b")
|
||||
record2.CreatedAt = record2.CreatedAt.Add(time.Second)
|
||||
record2.UpdatedAt = record2.CreatedAt
|
||||
|
||||
record3 := fixtureGame(t)
|
||||
record3.GameID = common.GameID("game-c")
|
||||
record3.Status = game.StatusEnrollmentOpen
|
||||
|
||||
for _, record := range []game.Game{record1, record2, record3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
drafts, err := store.GetByStatus(ctx, game.StatusDraft)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, drafts, 2)
|
||||
gotIDs := []string{drafts[0].GameID.String(), drafts[1].GameID.String()}
|
||||
assert.Contains(t, gotIDs, record1.GameID.String())
|
||||
assert.Contains(t, gotIDs, record2.GameID.String())
|
||||
|
||||
enrollment, err := store.GetByStatus(ctx, game.StatusEnrollmentOpen)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, enrollment, 1)
|
||||
assert.Equal(t, record3.GameID, enrollment[0].GameID)
|
||||
|
||||
running, err := store.GetByStatus(ctx, game.StatusRunning)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, running)
|
||||
}
|
||||
|
||||
func TestGameStoreGetByOwnerReturnsOwnedGames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record1, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID("game-priv-a"),
|
||||
GameName: "Owner A first",
|
||||
GameType: game.GameTypePrivate,
|
||||
OwnerUserID: "user-owner-a",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record2, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID("game-priv-b"),
|
||||
GameName: "Owner A second",
|
||||
GameType: game.GameTypePrivate,
|
||||
OwnerUserID: "user-owner-a",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now.Add(time.Second),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record3, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID("game-priv-c"),
|
||||
GameName: "Owner B",
|
||||
GameType: game.GameTypePrivate,
|
||||
OwnerUserID: "user-owner-b",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
publicRecord := fixtureGame(t)
|
||||
|
||||
for _, record := range []game.Game{record1, record2, record3, publicRecord} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
ownerA, err := store.GetByOwner(ctx, "user-owner-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ownerA, 2)
|
||||
|
||||
ownerB, err := store.GetByOwner(ctx, "user-owner-b")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ownerB, 1)
|
||||
assert.Equal(t, record3.GameID, ownerB[0].GameID)
|
||||
|
||||
ownerNone, err := store.GetByOwner(ctx, "user-owner-none")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ownerNone)
|
||||
}
|
||||
|
||||
func TestGameStoreGetByStatusDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
// Delete the primary key out-of-band, leaving the index entry stale.
|
||||
server.Del("lobby:games:" + base64URL(record.GameID.String()))
|
||||
|
||||
records, err := store.GetByStatus(ctx, game.StatusDraft)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusValidTransition(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusEnrollmentOpen, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(at.UTC()))
|
||||
assert.Nil(t, got.StartedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
|
||||
assert.Empty(t, statusIndexMembers(t, client, game.StatusDraft))
|
||||
assert.Contains(t, statusIndexMembers(t, client, game.StatusEnrollmentOpen), record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusSetsStartedAtAndFinishedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
record.Status = game.StatusStarting
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
startedAt := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusStarting,
|
||||
To: game.StatusRunning,
|
||||
Trigger: game.TriggerRuntimeEvent,
|
||||
At: 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.UTC()))
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
|
||||
finishedAt := startedAt.Add(2 * time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusRunning,
|
||||
To: game.StatusFinished,
|
||||
Trigger: game.TriggerRuntimeEvent,
|
||||
At: finishedAt,
|
||||
}))
|
||||
|
||||
got, err = store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusFinished, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(startedAt.UTC()))
|
||||
require.NotNil(t, got.FinishedAt)
|
||||
assert.True(t, got.FinishedAt.Equal(finishedAt.UTC()))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusRunning,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, game.ErrInvalidTransition))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusDraft, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(record.UpdatedAt))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusRejectsWrongTrigger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerDeadline,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, game.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusEnrollmentOpen,
|
||||
To: game.StatusReadyToStart,
|
||||
Trigger: game.TriggerManual,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, game.ErrConflict))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: common.GameID("game-missing"),
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
record.Status = game.StatusRunning
|
||||
startedAt := record.CreatedAt.Add(time.Hour)
|
||||
record.StartedAt = &startedAt
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := startedAt.Add(10 * time.Minute)
|
||||
require.NoError(t, store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
|
||||
GameID: record.GameID,
|
||||
Snapshot: game.RuntimeSnapshot{
|
||||
CurrentTurn: 5,
|
||||
RuntimeStatus: "running_accepting_commands",
|
||||
EngineHealthSummary: "ok",
|
||||
},
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, got.RuntimeSnapshot.CurrentTurn)
|
||||
assert.Equal(t, "running_accepting_commands", got.RuntimeSnapshot.RuntimeStatus)
|
||||
assert.Equal(t, "ok", got.RuntimeSnapshot.EngineHealthSummary)
|
||||
assert.True(t, got.UpdatedAt.Equal(at.UTC()))
|
||||
assert.Equal(t, game.StatusRunning, got.Status)
|
||||
|
||||
assert.Contains(t, statusIndexMembers(t, client, game.StatusRunning), record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeSnapshotReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
|
||||
GameID: common.GameID("game-missing"),
|
||||
Snapshot: game.RuntimeSnapshot{},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
record.Status = game.StatusStarting
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
bound := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
|
||||
GameID: record.GameID,
|
||||
Binding: game.RuntimeBinding{
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
RuntimeJobID: "1700000000000-0",
|
||||
BoundAt: bound,
|
||||
},
|
||||
At: bound,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.RuntimeBinding)
|
||||
assert.Equal(t, "container-1", got.RuntimeBinding.ContainerID)
|
||||
assert.Equal(t, "engine.local:9000", got.RuntimeBinding.EngineEndpoint)
|
||||
assert.Equal(t, "1700000000000-0", got.RuntimeBinding.RuntimeJobID)
|
||||
assert.True(t, got.RuntimeBinding.BoundAt.Equal(bound.UTC()))
|
||||
assert.Equal(t, game.StatusStarting, got.Status, "binding update must not change status")
|
||||
assert.True(t, got.UpdatedAt.Equal(bound.UTC()))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeBindingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
err := store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
|
||||
GameID: common.GameID("game-missing"),
|
||||
Binding: game.RuntimeBinding{
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
RuntimeJobID: "1700000000000-0",
|
||||
BoundAt: time.Now().UTC(),
|
||||
},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreConcurrentUpdateStatusHasExactlyOneWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
storeA, err := redisstate.NewGameStore(client)
|
||||
require.NoError(t, err)
|
||||
storeB, err := redisstate.NewGameStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
successes atomic.Int32
|
||||
conflicts atomic.Int32
|
||||
others atomic.Int32
|
||||
)
|
||||
|
||||
apply := func(target *redisstate.GameStore) {
|
||||
defer wg.Done()
|
||||
err := target.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
successes.Add(1)
|
||||
case errors.Is(err, game.ErrConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
others.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go apply(storeA)
|
||||
go apply(storeB)
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int32(0), others.Load(), "unexpected non-conflict error")
|
||||
assert.Equal(t, int32(1), successes.Load(), "expected exactly one success")
|
||||
assert.Equal(t, int32(1), conflicts.Load(), "expected exactly one conflict")
|
||||
}
|
||||
|
||||
// base64URL mirrors the private key-segment encoding used by Keyspace.
|
||||
// The tests use it to assert on exact Redis key shapes.
|
||||
func base64URL(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// InviteStore provides Redis-backed durable storage for invite records.
|
||||
type InviteStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewInviteStore constructs one Redis-backed invite store. It returns an
|
||||
// error when client is nil.
|
||||
func NewInviteStore(client *redis.Client) (*InviteStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new invite store: nil redis client")
|
||||
}
|
||||
|
||||
return &InviteStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save persists a new created invite record. Save is create-only; a
|
||||
// second save against the same invite id returns invite.ErrConflict.
|
||||
func (store *InviteStore) Save(ctx context.Context, record invite.Invite) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save invite: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save invite: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save invite: %w", err)
|
||||
}
|
||||
if record.Status != invite.StatusCreated {
|
||||
return fmt.Errorf(
|
||||
"save invite: status must be %q, got %q",
|
||||
invite.StatusCreated, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := MarshalInvite(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save invite: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Invite(record.InviteID)
|
||||
gameIndexKey := store.keys.InvitesByGame(record.GameID)
|
||||
userIndexKey := store.keys.InvitesByUser(record.InviteeUserID)
|
||||
inviterIndexKey := store.keys.InvitesByInviter(record.InviterUserID)
|
||||
member := record.InviteID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
existing, getErr := tx.Exists(ctx, primaryKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save invite: %w", getErr)
|
||||
}
|
||||
if existing != 0 {
|
||||
return fmt.Errorf("save invite: %w", invite.ErrConflict)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, InviteRecordTTL)
|
||||
pipe.SAdd(ctx, gameIndexKey, member)
|
||||
pipe.SAdd(ctx, userIndexKey, member)
|
||||
pipe.SAdd(ctx, inviterIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save invite: %w", invite.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by inviteID.
|
||||
func (store *InviteStore) Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return invite.Invite{}, errors.New("get invite: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return invite.Invite{}, errors.New("get invite: nil context")
|
||||
}
|
||||
if err := inviteID.Validate(); err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Invite(inviteID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return invite.Invite{}, invite.ErrNotFound
|
||||
case err != nil:
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalInvite(payload)
|
||||
if err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every invite attached to gameID.
|
||||
func (store *InviteStore) GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get invites by game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get invites by game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get invites by game: %w", err)
|
||||
}
|
||||
|
||||
return store.loadInvitesBySet(ctx,
|
||||
"get invites by game",
|
||||
store.keys.InvitesByGame(gameID),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByUser returns every invite addressed to inviteeUserID.
|
||||
func (store *InviteStore) GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get invites by user: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get invites by user: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(inviteeUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get invites by user: invitee user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadInvitesBySet(ctx,
|
||||
"get invites by user",
|
||||
store.keys.InvitesByUser(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByInviter returns every invite created by inviterUserID.
|
||||
func (store *InviteStore) GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get invites by inviter: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get invites by inviter: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(inviterUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get invites by inviter: inviter user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadInvitesBySet(ctx,
|
||||
"get invites by inviter",
|
||||
store.keys.InvitesByInviter(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// loadInvitesBySet materializes invites whose ids are stored in setKey.
|
||||
// Stale set members (primary key removed out-of-band) are dropped silently.
|
||||
func (store *InviteStore) loadInvitesBySet(ctx context.Context, operation, setKey string) ([]invite.Invite, error) {
|
||||
members, err := store.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Invite(common.InviteID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
records := make([]invite.Invite, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry)
|
||||
}
|
||||
record, err := UnmarshalInvite([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap fashion.
|
||||
func (store *InviteStore) UpdateStatus(ctx context.Context, input ports.UpdateInviteStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update invite status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update invite status: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update invite status: %w", err)
|
||||
}
|
||||
|
||||
if err := invite.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Invite(input.InviteID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return invite.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update invite status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalInvite(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update invite status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update invite status: %w", invite.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
decidedAt := at
|
||||
existing.DecidedAt = &decidedAt
|
||||
if input.To == invite.StatusRedeemed {
|
||||
existing.RaceName = strings.TrimSpace(input.RaceName)
|
||||
}
|
||||
|
||||
encoded, err := MarshalInvite(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update invite status: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, InviteRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update invite status: %w", invite.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure InviteStore satisfies the ports.InviteStore interface at
|
||||
// compile time.
|
||||
var _ ports.InviteStore = (*InviteStore)(nil)
|
||||
@@ -1,363 +0,0 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newInviteTestStore(t *testing.T) (*redisstate.InviteStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewInviteStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureInvite(t *testing.T, id common.InviteID, inviter, invitee string, gameID common.GameID) invite.Invite {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record, err := invite.New(invite.NewInviteInput{
|
||||
InviteID: id,
|
||||
GameID: gameID,
|
||||
InviterUserID: inviter,
|
||||
InviteeUserID: invitee,
|
||||
Now: now,
|
||||
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
|
||||
func TestNewInviteStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewInviteStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestInviteStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.InviteID, got.InviteID)
|
||||
assert.Equal(t, record.InviteeUserID, got.InviteeUserID)
|
||||
assert.Equal(t, invite.StatusCreated, got.Status)
|
||||
assert.Equal(t, "", got.RaceName)
|
||||
assert.Nil(t, got.DecidedAt)
|
||||
assert.True(t, got.ExpiresAt.Equal(record.ExpiresAt))
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_invites:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.InviteID.String()}, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_invites:"+base64URL(record.InviteeUserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.InviteID.String()}, byUser)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.InviteID("invite-missing"))
|
||||
require.ErrorIs(t, err, invite.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestInviteStoreSaveRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, invite.ErrConflict))
|
||||
}
|
||||
|
||||
func TestInviteStoreSaveRejectsNonCreated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
record.Status = invite.StatusRevoked
|
||||
decidedAt := record.CreatedAt.Add(time.Minute)
|
||||
record.DecidedAt = &decidedAt
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, invite.ErrConflict))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRedeemSetsRaceName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRedeemed,
|
||||
At: at,
|
||||
RaceName: "Lunar Raider",
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusRedeemed, got.Status)
|
||||
assert.Equal(t, "Lunar Raider", got.RaceName)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.True(t, got.DecidedAt.Equal(at.UTC()))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusTerminalTransitions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target invite.Status
|
||||
}{
|
||||
{"declined", invite.StatusDeclined},
|
||||
{"revoked", invite.StatusRevoked},
|
||||
{"expired", invite.StatusExpired},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, common.InviteID("invite-"+tc.name), "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(30 * time.Minute)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: tc.target,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.target, got.Status)
|
||||
assert.Equal(t, "", got.RaceName)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.True(t, got.DecidedAt.Equal(at.UTC()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRejectsRedeemWithoutRaceName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRedeemed,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, invite.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRejectsRaceNameOnNonRedeem(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
RaceName: "Nope",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, invite.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusRedeemed,
|
||||
To: invite.StatusExpired,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, invite.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRevoked,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: record.CreatedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, invite.ErrConflict))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: common.InviteID("invite-missing"),
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, invite.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByGameAndByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
i1 := fixtureInvite(t, "invite-a1", "user-owner", "user-1", "game-1")
|
||||
i2 := fixtureInvite(t, "invite-a2", "user-owner", "user-2", "game-1")
|
||||
i3 := fixtureInvite(t, "invite-a3", "user-owner", "user-1", "game-2")
|
||||
|
||||
for _, record := range []invite.Invite{i1, i2, i3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byGame1, err := store.GetByGame(ctx, "game-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byGame1, 2)
|
||||
|
||||
byUser1, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser1, 2)
|
||||
|
||||
ids := collectInviteIDs(byUser1)
|
||||
sort.Strings(ids)
|
||||
assert.Equal(t, []string{"invite-a1", "invite-a3"}, ids)
|
||||
|
||||
byGameMissing, err := store.GetByGame(ctx, "game-missing")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byGameMissing)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByInviter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
i1 := fixtureInvite(t, "invite-i1", "user-owner-a", "user-guest-1", "game-1")
|
||||
i2 := fixtureInvite(t, "invite-i2", "user-owner-a", "user-guest-2", "game-2")
|
||||
i3 := fixtureInvite(t, "invite-i3", "user-owner-b", "user-guest-1", "game-3")
|
||||
|
||||
for _, record := range []invite.Invite{i1, i2, i3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byInviterA, err := store.GetByInviter(ctx, "user-owner-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byInviterA, 2)
|
||||
idsA := collectInviteIDs(byInviterA)
|
||||
sort.Strings(idsA)
|
||||
assert.Equal(t, []string{"invite-i1", "invite-i2"}, idsA)
|
||||
|
||||
byInviterB, err := store.GetByInviter(ctx, "user-owner-b")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byInviterB, 1)
|
||||
assert.Equal(t, "invite-i3", byInviterB[0].InviteID.String())
|
||||
|
||||
byInviterMissing, err := store.GetByInviter(ctx, "user-owner-none")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byInviterMissing)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByInviterRetainsAfterStatusChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-i", "user-owner-a", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRevoked,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
matches, err := store.GetByInviter(ctx, "user-owner-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, matches, 1)
|
||||
assert.Equal(t, invite.StatusRevoked, matches[0].Status)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByGameDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
server.Del("lobby:invites:" + base64URL(record.InviteID.String()))
|
||||
|
||||
records, err := store.GetByGame(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func collectInviteIDs(records []invite.Invite) []string {
|
||||
ids := make([]string, len(records))
|
||||
for index, record := range records {
|
||||
ids[index] = record.InviteID.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -2,178 +2,25 @@ package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/racename"
|
||||
)
|
||||
|
||||
// defaultPrefix is the mandatory `lobby:` namespace prefix shared by every
|
||||
// Game Lobby Redis key.
|
||||
const defaultPrefix = "lobby:"
|
||||
|
||||
// GameRecordTTL is the Redis retention applied to game records. The
|
||||
// value is zero (no expiry); a future stage will revisit this
|
||||
// choice when the platform locks in archival/GDPR policy.
|
||||
const GameRecordTTL time.Duration = 0
|
||||
|
||||
// ApplicationRecordTTL is the Redis retention applied to application
|
||||
// records. uses zero (no expiry) to match game records; the
|
||||
// archival policy will be revisited when the platform locks it in.
|
||||
const ApplicationRecordTTL time.Duration = 0
|
||||
|
||||
// InviteRecordTTL is the Redis retention applied to invite records.
|
||||
// uses zero (no expiry); the `expires_at` field is a business
|
||||
// deadline enforced by the service layer, not a Redis TTL.
|
||||
const InviteRecordTTL time.Duration = 0
|
||||
|
||||
// MembershipRecordTTL is the Redis retention applied to membership
|
||||
// records. uses zero (no expiry) to match the other participant
|
||||
// entities.
|
||||
const MembershipRecordTTL time.Duration = 0
|
||||
|
||||
// Keyspace builds the frozen Game Lobby Redis keys. All dynamic key
|
||||
// segments are encoded with base64url so raw key structure does not
|
||||
// depend on user-provided or caller-provided characters.
|
||||
// Keyspace builds the Game Lobby Redis keys that survive the PG_PLAN.md
|
||||
// §6A and §6B migrations: per-game ephemeral runtime aggregates,
|
||||
// capability-evaluation guards, gap activation timestamps, and stream
|
||||
// consumer offsets. The four core enrollment entities (game, application,
|
||||
// invite, membership) and the Race Name Directory live in PostgreSQL —
|
||||
// their previous keyspace methods are gone.
|
||||
//
|
||||
// All dynamic key segments are encoded with base64url so raw key structure
|
||||
// does not depend on user-provided or caller-provided characters.
|
||||
type Keyspace struct{}
|
||||
|
||||
// Game returns the primary Redis key for one game record.
|
||||
func (Keyspace) Game(gameID common.GameID) string {
|
||||
return defaultPrefix + "games:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// GamesByStatus returns the sorted-set key that stores game identifiers
|
||||
// indexed by their current status.
|
||||
func (Keyspace) GamesByStatus(status game.Status) string {
|
||||
return defaultPrefix + "games_by_status:" + encodeKeyComponent(string(status))
|
||||
}
|
||||
|
||||
// GamesByOwner returns the set key that stores game identifiers owned
|
||||
// by one user. The set is maintained for private games whose
|
||||
// OwnerUserID is non-empty (public games are admin-owned and carry an
|
||||
// empty OwnerUserID, so they never enter the index).
|
||||
func (Keyspace) GamesByOwner(userID string) string {
|
||||
return defaultPrefix + "games_by_owner:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// Application returns the primary Redis key for one application record.
|
||||
func (Keyspace) Application(applicationID common.ApplicationID) string {
|
||||
return defaultPrefix + "applications:" + encodeKeyComponent(applicationID.String())
|
||||
}
|
||||
|
||||
// ApplicationsByGame returns the set key that stores application
|
||||
// identifiers attached to one game.
|
||||
func (Keyspace) ApplicationsByGame(gameID common.GameID) string {
|
||||
return defaultPrefix + "game_applications:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// ApplicationsByUser returns the set key that stores application
|
||||
// identifiers submitted by one applicant.
|
||||
func (Keyspace) ApplicationsByUser(applicantUserID string) string {
|
||||
return defaultPrefix + "user_applications:" + encodeKeyComponent(applicantUserID)
|
||||
}
|
||||
|
||||
// UserGameApplication returns the lookup key that stores the single
|
||||
// non-rejected application identifier for one (user, game) pair. Presence
|
||||
// of this key blocks a second submitted/approved application for the
|
||||
// same user and game.
|
||||
func (Keyspace) UserGameApplication(applicantUserID string, gameID common.GameID) string {
|
||||
return defaultPrefix + "user_game_application:" +
|
||||
encodeKeyComponent(applicantUserID) + ":" +
|
||||
encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// Invite returns the primary Redis key for one invite record.
|
||||
func (Keyspace) Invite(inviteID common.InviteID) string {
|
||||
return defaultPrefix + "invites:" + encodeKeyComponent(inviteID.String())
|
||||
}
|
||||
|
||||
// InvitesByGame returns the set key that stores invite identifiers
|
||||
// attached to one game.
|
||||
func (Keyspace) InvitesByGame(gameID common.GameID) string {
|
||||
return defaultPrefix + "game_invites:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// InvitesByUser returns the set key that stores invite identifiers
|
||||
// addressed to one invitee.
|
||||
func (Keyspace) InvitesByUser(inviteeUserID string) string {
|
||||
return defaultPrefix + "user_invites:" + encodeKeyComponent(inviteeUserID)
|
||||
}
|
||||
|
||||
// InvitesByInviter returns the set key that stores invite identifiers
|
||||
// created by one inviter (private-game owner). The set retains
|
||||
// invite_ids regardless of subsequent status transitions; callers
|
||||
// filter by status when needed.
|
||||
func (Keyspace) InvitesByInviter(inviterUserID string) string {
|
||||
return defaultPrefix + "user_inviter_invites:" + encodeKeyComponent(inviterUserID)
|
||||
}
|
||||
|
||||
// Membership returns the primary Redis key for one membership record.
|
||||
func (Keyspace) Membership(membershipID common.MembershipID) string {
|
||||
return defaultPrefix + "memberships:" + encodeKeyComponent(membershipID.String())
|
||||
}
|
||||
|
||||
// MembershipsByGame returns the set key that stores membership
|
||||
// identifiers attached to one game.
|
||||
func (Keyspace) MembershipsByGame(gameID common.GameID) string {
|
||||
return defaultPrefix + "game_memberships:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// MembershipsByUser returns the set key that stores membership
|
||||
// identifiers held by one user.
|
||||
func (Keyspace) MembershipsByUser(userID string) string {
|
||||
return defaultPrefix + "user_memberships:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// RegisteredRaceName returns the Redis key that stores the registered
|
||||
// race name bound to canonical.
|
||||
func (Keyspace) RegisteredRaceName(canonical racename.CanonicalKey) string {
|
||||
return defaultPrefix + "race_names:registered:" + encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// UserRegisteredRaceNames returns the set key that stores canonical keys
|
||||
// of every registered race name owned by userID.
|
||||
func (Keyspace) UserRegisteredRaceNames(userID string) string {
|
||||
return defaultPrefix + "race_names:user_registered:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// RaceNameReservation returns the Redis key that stores the per-game race
|
||||
// name reservation bound to (gameID, canonical).
|
||||
func (Keyspace) RaceNameReservation(gameID common.GameID, canonical racename.CanonicalKey) string {
|
||||
return defaultPrefix + "race_names:reservations:" +
|
||||
encodeKeyComponent(gameID.String()) + ":" +
|
||||
encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// UserRaceNameReservations returns the set key that stores
|
||||
// `<encodedGameID>:<encodedCanonical>` tuples of every active reservation
|
||||
// (including pending_registration) owned by userID.
|
||||
func (Keyspace) UserRaceNameReservations(userID string) string {
|
||||
return defaultPrefix + "race_names:user_reservations:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// RaceNameCanonicalLookup returns the Redis key that stores the eager
|
||||
// canonical-lookup cache entry for canonical. The cache surfaces the
|
||||
// strongest existing binding (registered > pending_registration >
|
||||
// reservation) so Check remains an O(1) read.
|
||||
func (Keyspace) RaceNameCanonicalLookup(canonical racename.CanonicalKey) string {
|
||||
return defaultPrefix + "race_names:canonical_lookup:" + encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// PendingRaceNameIndex returns the singleton sorted-set key that indexes
|
||||
// pending registrations by eligible_until_ms for the expiration worker.
|
||||
func (Keyspace) PendingRaceNameIndex() string {
|
||||
return defaultPrefix + "race_names:pending_index"
|
||||
}
|
||||
|
||||
// RaceNameReservationMember returns the canonical member representation
|
||||
// stored inside UserRaceNameReservations and PendingRaceNameIndex for
|
||||
// (gameID, canonical).
|
||||
func (Keyspace) RaceNameReservationMember(gameID common.GameID, canonical racename.CanonicalKey) string {
|
||||
return encodeKeyComponent(gameID.String()) + ":" + encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// GapActivatedAt returns the Redis key that stores the gap-window
|
||||
// activation timestamp for one game.
|
||||
func (Keyspace) GapActivatedAt(gameID common.GameID) string {
|
||||
@@ -216,12 +63,6 @@ func (Keyspace) CapabilityEvaluationGuard(gameID common.GameID) string {
|
||||
encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// CreatedAtScore returns the frozen sorted-set score representation for
|
||||
// game creation timestamps stored in the status index.
|
||||
func CreatedAtScore(createdAt time.Time) float64 {
|
||||
return float64(createdAt.UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func encodeKeyComponent(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package redisstate_test
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
// base64URL is the test helper that mirrors the encodeKeyComponent function
|
||||
// inside Keyspace. Per-store tests use it to assert the exact Redis key
|
||||
// shape the adapter writes.
|
||||
func base64URL(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// MembershipStore provides Redis-backed durable storage for membership
|
||||
// records.
|
||||
type MembershipStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewMembershipStore constructs one Redis-backed membership store. It
|
||||
// returns an error when client is nil.
|
||||
func NewMembershipStore(client *redis.Client) (*MembershipStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new membership store: nil redis client")
|
||||
}
|
||||
|
||||
return &MembershipStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save persists a new active membership record. Save is create-only; a
|
||||
// second save against the same membership id returns
|
||||
// membership.ErrConflict.
|
||||
func (store *MembershipStore) Save(ctx context.Context, record membership.Membership) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save membership: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save membership: %w", err)
|
||||
}
|
||||
if record.Status != membership.StatusActive {
|
||||
return fmt.Errorf(
|
||||
"save membership: status must be %q, got %q",
|
||||
membership.StatusActive, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := MarshalMembership(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save membership: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Membership(record.MembershipID)
|
||||
gameIndexKey := store.keys.MembershipsByGame(record.GameID)
|
||||
userIndexKey := store.keys.MembershipsByUser(record.UserID)
|
||||
member := record.MembershipID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
existing, getErr := tx.Exists(ctx, primaryKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save membership: %w", getErr)
|
||||
}
|
||||
if existing != 0 {
|
||||
return fmt.Errorf("save membership: %w", membership.ErrConflict)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, MembershipRecordTTL)
|
||||
pipe.SAdd(ctx, gameIndexKey, member)
|
||||
pipe.SAdd(ctx, userIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save membership: %w", membership.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by membershipID.
|
||||
func (store *MembershipStore) Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return membership.Membership{}, errors.New("get membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return membership.Membership{}, errors.New("get membership: nil context")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Membership(membershipID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return membership.Membership{}, membership.ErrNotFound
|
||||
case err != nil:
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalMembership(payload)
|
||||
if err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every membership attached to gameID.
|
||||
func (store *MembershipStore) GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get memberships by game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get memberships by game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get memberships by game: %w", err)
|
||||
}
|
||||
|
||||
return store.loadMembershipsBySet(ctx,
|
||||
"get memberships by game",
|
||||
store.keys.MembershipsByGame(gameID),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByUser returns every membership held by userID.
|
||||
func (store *MembershipStore) GetByUser(ctx context.Context, userID string) ([]membership.Membership, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get memberships by user: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get memberships by user: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get memberships by user: user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadMembershipsBySet(ctx,
|
||||
"get memberships by user",
|
||||
store.keys.MembershipsByUser(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// loadMembershipsBySet materializes memberships whose ids are stored in
|
||||
// setKey. Stale set members are dropped silently.
|
||||
func (store *MembershipStore) loadMembershipsBySet(ctx context.Context, operation, setKey string) ([]membership.Membership, error) {
|
||||
members, err := store.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Membership(common.MembershipID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
records := make([]membership.Membership, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry)
|
||||
}
|
||||
record, err := UnmarshalMembership([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap fashion.
|
||||
func (store *MembershipStore) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update membership status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update membership status: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update membership status: %w", err)
|
||||
}
|
||||
|
||||
if err := membership.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Membership(input.MembershipID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return membership.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update membership status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalMembership(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update membership status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update membership status: %w", membership.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
removedAt := at
|
||||
existing.RemovedAt = &removedAt
|
||||
|
||||
encoded, err := MarshalMembership(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update membership status: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, MembershipRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update membership status: %w", membership.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes the membership record identified by membershipID from
|
||||
// the primary store and from the per-game and per-user index sets in
|
||||
// one transaction. It returns membership.ErrNotFound when no record
|
||||
// exists for the id and membership.ErrConflict when a concurrent
|
||||
// mutation invalidates the watched key.
|
||||
func (store *MembershipStore) Delete(ctx context.Context, membershipID common.MembershipID) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("delete membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("delete membership: nil context")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Membership(membershipID)
|
||||
member := membershipID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return membership.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("delete membership: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalMembership(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
|
||||
gameIndexKey := store.keys.MembershipsByGame(existing.GameID)
|
||||
userIndexKey := store.keys.MembershipsByUser(existing.UserID)
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Del(ctx, primaryKey)
|
||||
pipe.SRem(ctx, gameIndexKey, member)
|
||||
pipe.SRem(ctx, userIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("delete membership: %w", membership.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure MembershipStore satisfies the ports.MembershipStore interface at
|
||||
// compile time.
|
||||
var _ ports.MembershipStore = (*MembershipStore)(nil)
|
||||
@@ -1,299 +0,0 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newMembershipTestStore(t *testing.T) (*redisstate.MembershipStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewMembershipStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureMembership(t *testing.T, id common.MembershipID, userID, raceName string, gameID common.GameID) membership.Membership {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record, err := membership.New(membership.NewMembershipInput{
|
||||
MembershipID: id,
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
CanonicalKey: strings.ToLower(strings.ReplaceAll(raceName, " ", "")),
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
|
||||
func TestNewMembershipStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewMembershipStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMembershipStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.MembershipID, got.MembershipID)
|
||||
assert.Equal(t, "Solar Pilot", got.RaceName)
|
||||
assert.Equal(t, membership.StatusActive, got.Status)
|
||||
assert.Nil(t, got.RemovedAt)
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_memberships:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.MembershipID.String()}, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_memberships:"+base64URL(record.UserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.MembershipID.String()}, byUser)
|
||||
}
|
||||
|
||||
func TestMembershipStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.MembershipID("membership-missing"))
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestMembershipStoreSaveRejectsNonActive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
record.Status = membership.StatusRemoved
|
||||
removedAt := record.JoinedAt.Add(time.Hour)
|
||||
record.RemovedAt = &removedAt
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, membership.ErrConflict))
|
||||
}
|
||||
|
||||
func TestMembershipStoreSaveRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, membership.ErrConflict))
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusSetsRemovedAt(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target membership.Status
|
||||
}{
|
||||
{"removed", membership.StatusRemoved},
|
||||
{"blocked", membership.StatusBlocked},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, common.MembershipID("membership-"+tc.name), "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.JoinedAt.Add(2 * time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: tc.target,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.target, got.Status)
|
||||
require.NotNil(t, got.RemovedAt)
|
||||
assert.True(t, got.RemovedAt.Equal(at.UTC()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusRemoved,
|
||||
To: membership.StatusBlocked,
|
||||
At: record.JoinedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, membership.ErrInvalidTransition))
|
||||
|
||||
got, err := store.Get(ctx, record.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, membership.StatusActive, got.Status)
|
||||
assert.Nil(t, got.RemovedAt)
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusReturnsConflictWhenStatusDiverges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusBlocked,
|
||||
At: record.JoinedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusRemoved,
|
||||
At: record.JoinedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, membership.ErrConflict))
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: common.MembershipID("membership-missing"),
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusRemoved,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestMembershipStoreGetByGameAndByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
m1 := fixtureMembership(t, "membership-a1", "user-1", "Racer A", "game-1")
|
||||
m2 := fixtureMembership(t, "membership-a2", "user-2", "Racer B", "game-1")
|
||||
m3 := fixtureMembership(t, "membership-a3", "user-1", "Racer C", "game-2")
|
||||
|
||||
for _, record := range []membership.Membership{m1, m2, m3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byGame1, err := store.GetByGame(ctx, "game-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byGame1, 2)
|
||||
|
||||
byUser1, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser1, 2)
|
||||
|
||||
ids := collectMembershipIDs(byUser1)
|
||||
sort.Strings(ids)
|
||||
assert.Equal(t, []string{"membership-a1", "membership-a3"}, ids)
|
||||
|
||||
byUserMissing, err := store.GetByUser(ctx, "user-missing")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byUserMissing)
|
||||
}
|
||||
|
||||
func TestMembershipStoreGetByUserDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
server.Del("lobby:memberships:" + base64URL(record.MembershipID.String()))
|
||||
|
||||
records, err := store.GetByUser(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestMembershipStoreDeleteRemovesPrimaryAndIndexes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, record.MembershipID))
|
||||
|
||||
_, err := store.Get(ctx, record.MembershipID)
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_memberships:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_memberships:"+base64URL(record.UserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byUser)
|
||||
}
|
||||
|
||||
func TestMembershipStoreDeleteReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
err := store.Delete(ctx, common.MembershipID("membership-missing"))
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestMembershipStoreDeleteIsIdempotentAfterFirstSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, record.MembershipID))
|
||||
|
||||
err := store.Delete(ctx, record.MembershipID)
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func collectMembershipIDs(records []membership.Membership) []string {
|
||||
ids := make([]string, len(records))
|
||||
for index, record := range records {
|
||||
ids[index] = record.MembershipID.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
package redisstate
|
||||
|
||||
// releaseAllByUserScript atomically clears every registered, reservation,
|
||||
// and pending_registration binding owned by one user. Inputs:
|
||||
//
|
||||
// KEYS[1] — user_registered set key
|
||||
// KEYS[2] — user_reservations set key
|
||||
// KEYS[3] — pending_index sorted-set key
|
||||
// ARGV[1] — Lobby Redis key prefix (e.g. "lobby:")
|
||||
//
|
||||
// The script returns a three-entry table `{registeredCount,
|
||||
// reservationsTotal, pendingCount}` so callers can emit telemetry without
|
||||
// a second round-trip. reservationsTotal includes both reserved and
|
||||
// pending_registration entries; pendingCount is the pending-only subset.
|
||||
const releaseAllByUserScript = `
|
||||
local userRegisteredKey = KEYS[1]
|
||||
local userReservationsKey = KEYS[2]
|
||||
local pendingIndexKey = KEYS[3]
|
||||
local prefix = ARGV[1]
|
||||
|
||||
local registered = redis.call('SMEMBERS', userRegisteredKey)
|
||||
for _, canonical in ipairs(registered) do
|
||||
redis.call('DEL', prefix .. 'race_names:registered:' .. canonical)
|
||||
redis.call('DEL', prefix .. 'race_names:canonical_lookup:' .. canonical)
|
||||
end
|
||||
local registeredCount = #registered
|
||||
if registeredCount > 0 then
|
||||
redis.call('DEL', userRegisteredKey)
|
||||
end
|
||||
|
||||
local reservations = redis.call('SMEMBERS', userReservationsKey)
|
||||
local pendingCount = 0
|
||||
for _, member in ipairs(reservations) do
|
||||
local sep = string.find(member, ':', 1, true)
|
||||
if sep then
|
||||
local encGame = string.sub(member, 1, sep - 1)
|
||||
local encCanonical = string.sub(member, sep + 1)
|
||||
redis.call('DEL', prefix .. 'race_names:reservations:' .. encGame .. ':' .. encCanonical)
|
||||
local pendingRemoved = redis.call('ZREM', pendingIndexKey, member)
|
||||
if pendingRemoved == 1 then
|
||||
pendingCount = pendingCount + 1
|
||||
end
|
||||
redis.call('DEL', prefix .. 'race_names:canonical_lookup:' .. encCanonical)
|
||||
end
|
||||
end
|
||||
local reservationsTotal = #reservations
|
||||
if reservationsTotal > 0 then
|
||||
redis.call('DEL', userReservationsKey)
|
||||
end
|
||||
|
||||
return {registeredCount, reservationsTotal, pendingCount}
|
||||
`
|
||||
@@ -1,244 +0,0 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/racename"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/ports/racenamedirtest"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newRaceNameDirectoryAdapter(
|
||||
t *testing.T,
|
||||
now func() time.Time,
|
||||
) (*redisstate.RaceNameDirectory, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
policy, err := racename.NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
var opts []redisstate.RaceNameDirectoryOption
|
||||
if now != nil {
|
||||
opts = append(opts, redisstate.WithRaceNameDirectoryClock(now))
|
||||
}
|
||||
directory, err := redisstate.NewRaceNameDirectory(client, policy, opts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
return directory, server, client
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryContract(t *testing.T) {
|
||||
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
|
||||
directory, _, _ := newRaceNameDirectoryAdapter(t, now)
|
||||
return directory
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRaceNameDirectoryRejectsNilClient(t *testing.T) {
|
||||
policy, err := racename.NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = redisstate.NewRaceNameDirectory(nil, policy)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewRaceNameDirectoryRejectsNilPolicy(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
_, err := redisstate.NewRaceNameDirectory(client, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryPersistsExactKeyShapes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
|
||||
const (
|
||||
gameID = "game-shape"
|
||||
userID = "user-shape"
|
||||
raceName = "PilotNova"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
|
||||
|
||||
canonical, err := directory.Canonicalize(raceName)
|
||||
require.NoError(t, err)
|
||||
|
||||
encGame := base64URL(gameID)
|
||||
encUser := base64URL(userID)
|
||||
encCanonical := base64URL(canonical)
|
||||
|
||||
require.True(t, server.Exists("lobby:race_names:reservations:"+encGame+":"+encCanonical))
|
||||
require.True(t, server.Exists("lobby:race_names:canonical_lookup:"+encCanonical))
|
||||
require.True(t, server.Exists("lobby:race_names:user_reservations:"+encUser))
|
||||
|
||||
members, err := server.SMembers("lobby:race_names:user_reservations:" + encUser)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, members, encGame+":"+encCanonical)
|
||||
|
||||
lookupPayload, err := server.Get("lobby:race_names:canonical_lookup:" + encCanonical)
|
||||
require.NoError(t, err)
|
||||
var lookup map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(lookupPayload), &lookup))
|
||||
assert.Equal(t, ports.KindReservation, lookup["kind"])
|
||||
assert.Equal(t, userID, lookup["holder_user_id"])
|
||||
assert.Equal(t, gameID, lookup["game_id"])
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryCanonicalLookupUpgradesOnPendingAndRegistered(t *testing.T) {
|
||||
now, _ := fixedNow(t)
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
gameID = "game-upgrade"
|
||||
userID = "user-upgrade"
|
||||
raceName = "UpgradePilot"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
|
||||
|
||||
canonical, err := directory.Canonicalize(raceName)
|
||||
require.NoError(t, err)
|
||||
lookupKey := "lobby:race_names:canonical_lookup:" + base64URL(canonical)
|
||||
|
||||
lookupAfterReserve, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, lookupAfterReserve, `"kind":"`+ports.KindReservation+`"`)
|
||||
|
||||
eligibleUntil := now().Add(time.Hour)
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, eligibleUntil))
|
||||
|
||||
lookupAfterPending, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, lookupAfterPending, `"kind":"`+ports.KindPendingRegistration+`"`)
|
||||
|
||||
require.NoError(t, directory.Register(ctx, gameID, userID, raceName))
|
||||
|
||||
lookupAfterRegister, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, lookupAfterRegister, `"kind":"`+ports.KindRegistered+`"`)
|
||||
require.NotContains(t, lookupAfterRegister, `"game_id"`, "registered lookup omits the game id")
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryCanonicalLookupDowngradesOnReleaseCrossGame(t *testing.T) {
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
gameA = "game-keep-a"
|
||||
gameB = "game-keep-b"
|
||||
userID = "user-keep"
|
||||
raceNam = "KeepPilot"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameA, userID, raceNam))
|
||||
require.NoError(t, directory.Reserve(ctx, gameB, userID, raceNam))
|
||||
|
||||
canonical, err := directory.Canonicalize(raceNam)
|
||||
require.NoError(t, err)
|
||||
lookupKey := "lobby:race_names:canonical_lookup:" + base64URL(canonical)
|
||||
|
||||
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userID, raceNam))
|
||||
|
||||
payload, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, payload, `"kind":"`+ports.KindReservation+`"`)
|
||||
require.Contains(t, payload, `"game_id":"`+gameB+`"`)
|
||||
|
||||
require.NoError(t, directory.ReleaseReservation(ctx, gameB, userID, raceNam))
|
||||
require.False(t, server.Exists(lookupKey))
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryReleaseAllByUserLua(t *testing.T) {
|
||||
now, _ := fixedNow(t)
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
userID = "user-lua"
|
||||
otherID = "user-lua-other"
|
||||
raceName = "LuaPilot"
|
||||
otherRN = "LuaVanguard"
|
||||
gameA = "game-lua-a"
|
||||
gameB = "game-lua-b"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameA, userID, raceName))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userID, raceName, now().Add(time.Hour)))
|
||||
require.NoError(t, directory.Register(ctx, gameA, userID, raceName))
|
||||
require.NoError(t, directory.Reserve(ctx, gameB, userID, otherRN))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameB, userID, otherRN, now().Add(2*time.Hour)))
|
||||
|
||||
const isolatedRN = "LuaGoldenChain"
|
||||
require.NoError(t, directory.Reserve(ctx, gameA, otherID, isolatedRN))
|
||||
|
||||
require.NoError(t, directory.ReleaseAllByUser(ctx, userID))
|
||||
|
||||
require.False(t, server.Exists("lobby:race_names:user_registered:"+base64URL(userID)))
|
||||
require.False(t, server.Exists("lobby:race_names:user_reservations:"+base64URL(userID)))
|
||||
pendingMembers, err := server.ZMembers("lobby:race_names:pending_index")
|
||||
if err != nil {
|
||||
require.ErrorContains(t, err, "ERR no such key")
|
||||
} else {
|
||||
require.Empty(t, pendingMembers)
|
||||
}
|
||||
|
||||
otherCanonical, err := directory.Canonicalize(isolatedRN)
|
||||
require.NoError(t, err)
|
||||
require.True(t, server.Exists("lobby:race_names:canonical_lookup:"+base64URL(otherCanonical)))
|
||||
|
||||
reservations, err := directory.ListReservations(ctx, otherID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reservations, 1)
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryReleaseAllByUserIsSafeOnEmpty(t *testing.T) {
|
||||
directory, _, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, directory.ReleaseAllByUser(ctx, "unknown-user"))
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryCheckRejectsInvalidName(t *testing.T) {
|
||||
directory, _, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
|
||||
_, err := directory.Check(context.Background(), "Pilot Nova", "user-x")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ports.ErrInvalidName))
|
||||
}
|
||||
|
||||
func fixedNow(t *testing.T) (func() time.Time, func(delta time.Duration)) {
|
||||
t.Helper()
|
||||
|
||||
instant := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
var mu struct {
|
||||
value time.Time
|
||||
}
|
||||
mu.value = instant
|
||||
return func() time.Time { return mu.value },
|
||||
func(delta time.Duration) { mu.value = mu.value.Add(delta) }
|
||||
}
|
||||
|
||||
// base64URL is the package-level helper defined in gamestore_test.go;
|
||||
// race-name adapter tests reuse it via the same test package.
|
||||
var _ = base64.RawURLEncoding
|
||||
@@ -6,28 +6,23 @@ import (
|
||||
|
||||
"galaxy/lobby/internal/config"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
"galaxy/redisconn"
|
||||
|
||||
"github.com/redis/go-redis/extra/redisotel/v9"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// newRedisClient builds a Redis client wired with the configured timeouts
|
||||
// and TLS settings taken from cfg.
|
||||
// newRedisClient builds the master Redis client from cfg via the shared
|
||||
// `pkg/redisconn` helper. Replica clients are not opened in this iteration
|
||||
// per ARCHITECTURE.md §Persistence Backends; they will be wired when read
|
||||
// routing is introduced.
|
||||
func newRedisClient(cfg config.RedisConfig) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Addr,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
TLSConfig: cfg.TLSConfig(),
|
||||
DialTimeout: cfg.OperationTimeout,
|
||||
ReadTimeout: cfg.OperationTimeout,
|
||||
WriteTimeout: cfg.OperationTimeout,
|
||||
})
|
||||
return redisconn.NewMasterClient(cfg.Conn)
|
||||
}
|
||||
|
||||
// instrumentRedisClient attaches the OpenTelemetry tracing and metrics
|
||||
// instrumentation to client when telemetryRuntime is available.
|
||||
// instrumentation to client when telemetryRuntime is available. The actual
|
||||
// instrumentation lives in `pkg/redisconn` so every Galaxy service shares one
|
||||
// surface.
|
||||
func instrumentRedisClient(client *redis.Client, telemetryRuntime *telemetry.Runtime) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("instrument redis client: nil client")
|
||||
@@ -35,37 +30,14 @@ func instrumentRedisClient(client *redis.Client, telemetryRuntime *telemetry.Run
|
||||
if telemetryRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := redisotel.InstrumentTracing(
|
||||
client,
|
||||
redisotel.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
redisotel.WithDBStatement(false),
|
||||
); err != nil {
|
||||
return fmt.Errorf("instrument redis client tracing: %w", err)
|
||||
}
|
||||
if err := redisotel.InstrumentMetrics(
|
||||
client,
|
||||
redisotel.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
); err != nil {
|
||||
return fmt.Errorf("instrument redis client metrics: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return redisconn.Instrument(client,
|
||||
redisconn.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
redisconn.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
// pingRedis performs a single Redis PING bounded by cfg.OperationTimeout to
|
||||
// confirm that the configured Redis endpoint is reachable at startup.
|
||||
// pingRedis performs a single Redis PING bounded by cfg.Conn.OperationTimeout
|
||||
// to confirm that the configured Redis endpoint is reachable at startup.
|
||||
func pingRedis(ctx context.Context, cfg config.RedisConfig, client *redis.Client) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("ping redis: nil client")
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, cfg.OperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
return fmt.Errorf("ping redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return redisconn.Ping(ctx, client, cfg.Conn.OperationTimeout)
|
||||
}
|
||||
|
||||
@@ -6,20 +6,28 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/config"
|
||||
"galaxy/redisconn"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestRedisCfg(addr string) config.RedisConfig {
|
||||
return config.RedisConfig{
|
||||
Conn: redisconn.Config{
|
||||
MasterAddr: addr,
|
||||
Password: "test",
|
||||
OperationTimeout: time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingRedisSucceedsAgainstMiniredis(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
|
||||
redisCfg := config.RedisConfig{
|
||||
Addr: server.Addr(),
|
||||
OperationTimeout: time.Second,
|
||||
}
|
||||
redisCfg := newTestRedisCfg(server.Addr())
|
||||
client := newRedisClient(redisCfg)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
@@ -31,10 +39,7 @@ func TestPingRedisReturnsErrorWhenClosed(t *testing.T) {
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
|
||||
redisCfg := config.RedisConfig{
|
||||
Addr: server.Addr(),
|
||||
OperationTimeout: time.Second,
|
||||
}
|
||||
redisCfg := newTestRedisCfg(server.Addr())
|
||||
client := newRedisClient(redisCfg)
|
||||
require.NoError(t, client.Close())
|
||||
|
||||
@@ -45,7 +50,7 @@ func TestPingRedisReturnsErrorWhenClosed(t *testing.T) {
|
||||
func TestPingRedisNilClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := pingRedis(context.Background(), config.RedisConfig{OperationTimeout: time.Second}, nil)
|
||||
err := pingRedis(context.Background(), newTestRedisCfg("127.0.0.1:0"), nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "nil client")
|
||||
}
|
||||
@@ -62,10 +67,7 @@ func TestInstrumentRedisClientNilTelemetryIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := newRedisClient(config.RedisConfig{
|
||||
Addr: server.Addr(),
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
client := newRedisClient(newTestRedisCfg(server.Addr()))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
require.NoError(t, instrumentRedisClient(client, nil))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/postgres/migrations"
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/api/internalhttp"
|
||||
"galaxy/lobby/internal/api/publichttp"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
"galaxy/postgres"
|
||||
)
|
||||
|
||||
// activeGamesProbe adapts ports.GameStore to telemetry.ActiveGamesProbe by
|
||||
@@ -110,7 +112,31 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
return cleanupOnError(fmt.Errorf("new lobby runtime: %w", err))
|
||||
}
|
||||
|
||||
wiring, err := newWiring(cfg, redisClient, time.Now, logger, telemetryRuntime)
|
||||
pgPool, err := postgres.OpenPrimary(ctx, cfg.Postgres.Conn,
|
||||
postgres.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
postgres.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new lobby runtime: open postgres: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, pgPool.Close)
|
||||
unregisterPGStats, err := postgres.InstrumentDBStats(pgPool,
|
||||
postgres.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new lobby runtime: instrument postgres: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
return unregisterPGStats()
|
||||
})
|
||||
if err := postgres.Ping(ctx, pgPool, cfg.Postgres.Conn.OperationTimeout); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new lobby runtime: ping postgres: %w", err))
|
||||
}
|
||||
if err := postgres.RunMigrations(ctx, pgPool, migrations.FS(), "."); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new lobby runtime: run postgres migrations: %w", err))
|
||||
}
|
||||
|
||||
wiring, err := newWiring(cfg, redisClient, pgPool, time.Now, logger, telemetryRuntime)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new lobby runtime: wiring: %w", err))
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/api/internalhttp"
|
||||
"galaxy/lobby/internal/api/publichttp"
|
||||
"galaxy/lobby/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
rediscontainer "github.com/testcontainers/testcontainers-go/modules/redis"
|
||||
)
|
||||
|
||||
const (
|
||||
realRuntimeSmokeEnv = "LOBBY_REAL_RUNTIME_SMOKE"
|
||||
realRuntimeRedisImage = "redis:7"
|
||||
)
|
||||
|
||||
// TestRealRuntimeCompatibility boots the full Runtime against a real Redis
|
||||
// container, verifies that both HTTP listeners serve /healthz and /readyz,
|
||||
// and asserts graceful shutdown on context cancellation. The test is skipped
|
||||
// unless LOBBY_REAL_RUNTIME_SMOKE=1 because it depends on Docker.
|
||||
func TestRealRuntimeCompatibility(t *testing.T) {
|
||||
if os.Getenv(realRuntimeSmokeEnv) != "1" {
|
||||
t.Skipf("set %s=1 to run the real runtime smoke suite", realRuntimeSmokeEnv)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
redisContainer, err := rediscontainer.Run(ctx, realRuntimeRedisImage)
|
||||
require.NoError(t, err)
|
||||
testcontainers.CleanupContainer(t, redisContainer)
|
||||
|
||||
redisAddr, err := redisContainer.Endpoint(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisAddr
|
||||
cfg.UserService.BaseURL = "http://127.0.0.1:1"
|
||||
cfg.GM.BaseURL = "http://127.0.0.1:1"
|
||||
cfg.PublicHTTP.Addr = mustFreeAddr(t)
|
||||
cfg.InternalHTTP.Addr = mustFreeAddr(t)
|
||||
cfg.ShutdownTimeout = 2 * time.Second
|
||||
cfg.Telemetry.TracesExporter = "none"
|
||||
cfg.Telemetry.MetricsExporter = "none"
|
||||
|
||||
runtime, err := NewRuntime(context.Background(), cfg, testLogger())
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, runtime.Close())
|
||||
}()
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
runErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
runErrCh <- runtime.Run(runCtx)
|
||||
}()
|
||||
|
||||
client := newTestHTTPClient(t)
|
||||
|
||||
waitForRuntimeReady(t, client, cfg.PublicHTTP.Addr, publichttp.ReadyzPath)
|
||||
waitForRuntimeReady(t, client, cfg.InternalHTTP.Addr, internalhttp.ReadyzPath)
|
||||
|
||||
assertHTTPStatus(t, client, "http://"+cfg.PublicHTTP.Addr+publichttp.HealthzPath, http.StatusOK)
|
||||
assertHTTPStatus(t, client, "http://"+cfg.PublicHTTP.Addr+publichttp.ReadyzPath, http.StatusOK)
|
||||
assertHTTPStatus(t, client, "http://"+cfg.InternalHTTP.Addr+internalhttp.HealthzPath, http.StatusOK)
|
||||
assertHTTPStatus(t, client, "http://"+cfg.InternalHTTP.Addr+internalhttp.ReadyzPath, http.StatusOK)
|
||||
|
||||
cancel()
|
||||
waitForRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second)
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func newTestHTTPClient(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
|
||||
transport := &http.Transport{DisableKeepAlives: true}
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
|
||||
return &http.Client{
|
||||
Timeout: 500 * time.Millisecond,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRuntimeReady(t *testing.T, client *http.Client, addr string, path string) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
request, err := http.NewRequest(http.MethodGet, "http://"+addr+path, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
|
||||
return response.StatusCode == http.StatusOK
|
||||
}, 5*time.Second, 25*time.Millisecond, "lobby runtime did not become reachable on %s", addr)
|
||||
}
|
||||
|
||||
func waitForRunResult(t *testing.T, runErrCh <-chan error, waitTimeout time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
var err error
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case err = <-runErrCh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, waitTimeout, 10*time.Millisecond, "lobby runtime did not stop")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func assertHTTPStatus(t *testing.T, client *http.Client, target string, want int) {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
|
||||
require.Equal(t, want, response.StatusCode)
|
||||
}
|
||||
|
||||
func mustFreeAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, listener.Close())
|
||||
}()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/api/internalhttp"
|
||||
"galaxy/lobby/internal/api/publichttp"
|
||||
"galaxy/lobby/internal/config"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestConfig builds a valid Config that listens on ephemeral ports and a
|
||||
// miniredis instance provided by redisServer.
|
||||
func newTestConfig(t *testing.T, redisAddr string) config.Config {
|
||||
t.Helper()
|
||||
|
||||
reserve := func() string {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
addr := listener.Addr().String()
|
||||
require.NoError(t, listener.Close())
|
||||
return addr
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisAddr
|
||||
cfg.UserService.BaseURL = "http://127.0.0.1:1"
|
||||
cfg.GM.BaseURL = "http://127.0.0.1:1"
|
||||
cfg.PublicHTTP.Addr = reserve()
|
||||
cfg.InternalHTTP.Addr = reserve()
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestNewRuntimeValidatesContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewRuntime(nil, config.Config{}, nil) //nolint:staticcheck // test exercises the nil-context guard.
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "nil context")
|
||||
}
|
||||
|
||||
func TestNewRuntimeRejectsInvalidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewRuntime(context.Background(), config.Config{}, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "new lobby runtime")
|
||||
}
|
||||
|
||||
func TestNewRuntimeSucceedsWithMiniredis(t *testing.T) {
|
||||
redisServer := miniredis.RunT(t)
|
||||
|
||||
runtime, err := NewRuntime(context.Background(), newTestConfig(t, redisServer.Addr()), nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, runtime)
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
|
||||
assert.NotNil(t, runtime.PublicServer())
|
||||
assert.NotNil(t, runtime.InternalServer())
|
||||
}
|
||||
|
||||
func TestNewRuntimeWiresRaceNameDirectory(t *testing.T) {
|
||||
redisServer := miniredis.RunT(t)
|
||||
|
||||
runtime, err := NewRuntime(context.Background(), newTestConfig(t, redisServer.Addr()), nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
|
||||
require.NotNil(t, runtime.wiring)
|
||||
assert.NotNil(t, runtime.wiring.raceNameDirectory)
|
||||
}
|
||||
|
||||
func TestNewRuntimeFailsWhenRedisUnreachable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := newTestConfig(t, "127.0.0.1:1") // guaranteed unreachable
|
||||
cfg.Redis.OperationTimeout = 100 * time.Millisecond
|
||||
|
||||
_, err := NewRuntime(context.Background(), cfg, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "ping redis")
|
||||
}
|
||||
|
||||
func TestRuntimeCloseIsIdempotent(t *testing.T) {
|
||||
redisServer := miniredis.RunT(t)
|
||||
runtime, err := NewRuntime(context.Background(), newTestConfig(t, redisServer.Addr()), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, runtime.Close())
|
||||
require.NoError(t, runtime.Close())
|
||||
}
|
||||
|
||||
func TestRuntimeRunServesProbesAndStopsOnCancel(t *testing.T) {
|
||||
redisServer := miniredis.RunT(t)
|
||||
cfg := newTestConfig(t, redisServer.Addr())
|
||||
|
||||
runtime, err := NewRuntime(context.Background(), cfg, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- runtime.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return runtime.PublicServer().Addr() != "" && runtime.InternalServer().Addr() != ""
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
for _, probe := range []struct {
|
||||
label string
|
||||
url string
|
||||
}{
|
||||
{"public healthz", "http://" + runtime.PublicServer().Addr() + publichttp.HealthzPath},
|
||||
{"public readyz", "http://" + runtime.PublicServer().Addr() + publichttp.ReadyzPath},
|
||||
{"internal healthz", "http://" + runtime.InternalServer().Addr() + internalhttp.HealthzPath},
|
||||
{"internal readyz", "http://" + runtime.InternalServer().Addr() + internalhttp.ReadyzPath},
|
||||
} {
|
||||
resp, err := http.Get(probe.url)
|
||||
require.NoError(t, err, probe.label)
|
||||
_ = resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, probe.label)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("runtime did not stop after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunNilContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var runtime *Runtime
|
||||
require.Error(t, runtime.Run(context.Background()))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -10,6 +11,11 @@ import (
|
||||
"galaxy/lobby/internal/adapters/idgen"
|
||||
"galaxy/lobby/internal/adapters/metricsintentpub"
|
||||
"galaxy/lobby/internal/adapters/metricsracenamedir"
|
||||
pgapplicationstore "galaxy/lobby/internal/adapters/postgres/applicationstore"
|
||||
pggamestore "galaxy/lobby/internal/adapters/postgres/gamestore"
|
||||
pginvitestore "galaxy/lobby/internal/adapters/postgres/invitestore"
|
||||
pgmembershipstore "galaxy/lobby/internal/adapters/postgres/membershipstore"
|
||||
pgracenamedir "galaxy/lobby/internal/adapters/postgres/racenamedir"
|
||||
"galaxy/lobby/internal/adapters/racenameintents"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
@@ -234,6 +240,7 @@ type wiring struct {
|
||||
func newWiring(
|
||||
cfg config.Config,
|
||||
redisClient *redis.Client,
|
||||
pgPool *sql.DB,
|
||||
clock func() time.Time,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
@@ -249,29 +256,47 @@ func newWiring(
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
rawDirectory, err := buildRaceNameDirectory(cfg, redisClient, policy, clock)
|
||||
if redisClient == nil {
|
||||
return nil, errors.New("new lobby wiring: nil redis client")
|
||||
}
|
||||
if pgPool == nil {
|
||||
return nil, errors.New("new lobby wiring: nil postgres pool")
|
||||
}
|
||||
|
||||
rawDirectory, err := buildRaceNameDirectory(cfg, pgPool, policy, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
}
|
||||
directory := metricsracenamedir.New(rawDirectory, telemetryRuntime)
|
||||
|
||||
if redisClient == nil {
|
||||
return nil, errors.New("new lobby wiring: nil redis client")
|
||||
pgStoreCfg := struct {
|
||||
DB *sql.DB
|
||||
OperationTimeout time.Duration
|
||||
}{
|
||||
DB: pgPool,
|
||||
OperationTimeout: cfg.Postgres.Conn.OperationTimeout,
|
||||
}
|
||||
|
||||
gameStore, err := redisstate.NewGameStore(redisClient)
|
||||
gameStore, err := pggamestore.New(pggamestore.Config{
|
||||
DB: pgStoreCfg.DB, OperationTimeout: pgStoreCfg.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
}
|
||||
applicationStore, err := redisstate.NewApplicationStore(redisClient)
|
||||
applicationStore, err := pgapplicationstore.New(pgapplicationstore.Config{
|
||||
DB: pgStoreCfg.DB, OperationTimeout: pgStoreCfg.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
}
|
||||
inviteStore, err := redisstate.NewInviteStore(redisClient)
|
||||
inviteStore, err := pginvitestore.New(pginvitestore.Config{
|
||||
DB: pgStoreCfg.DB, OperationTimeout: pgStoreCfg.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
}
|
||||
membershipStore, err := redisstate.NewMembershipStore(redisClient)
|
||||
membershipStore, err := pgmembershipstore.New(pgmembershipstore.Config{
|
||||
DB: pgStoreCfg.DB, OperationTimeout: pgStoreCfg.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
}
|
||||
@@ -763,20 +788,21 @@ func newWiring(
|
||||
// selected by cfg.RaceNameDirectory.Backend.
|
||||
func buildRaceNameDirectory(
|
||||
cfg config.Config,
|
||||
redisClient *redis.Client,
|
||||
pgPool *sql.DB,
|
||||
policy *racename.Policy,
|
||||
clock func() time.Time,
|
||||
) (ports.RaceNameDirectory, error) {
|
||||
switch cfg.RaceNameDirectory.Backend {
|
||||
case config.RaceNameDirectoryBackendRedis:
|
||||
if redisClient == nil {
|
||||
return nil, errors.New("redis race name directory backend requires a Redis client")
|
||||
case config.RaceNameDirectoryBackendPostgres:
|
||||
if pgPool == nil {
|
||||
return nil, errors.New("postgres race name directory backend requires a Postgres pool")
|
||||
}
|
||||
return redisstate.NewRaceNameDirectory(
|
||||
redisClient,
|
||||
policy,
|
||||
redisstate.WithRaceNameDirectoryClock(clock),
|
||||
)
|
||||
return pgracenamedir.New(pgracenamedir.Config{
|
||||
DB: pgPool,
|
||||
OperationTimeout: cfg.Postgres.Conn.OperationTimeout,
|
||||
Policy: policy,
|
||||
Clock: clock,
|
||||
})
|
||||
case config.RaceNameDirectoryBackendStub:
|
||||
return racenamestub.NewDirectory(racenamestub.WithClock(clock))
|
||||
default:
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
)
|
||||
|
||||
const (
|
||||
envPrefix = "LOBBY"
|
||||
|
||||
shutdownTimeoutEnvVar = "LOBBY_SHUTDOWN_TIMEOUT"
|
||||
logLevelEnvVar = "LOBBY_LOG_LEVEL"
|
||||
|
||||
@@ -25,13 +28,6 @@ const (
|
||||
internalHTTPReadTimeoutEnvVar = "LOBBY_INTERNAL_HTTP_READ_TIMEOUT"
|
||||
internalHTTPIdleTimeoutEnvVar = "LOBBY_INTERNAL_HTTP_IDLE_TIMEOUT"
|
||||
|
||||
redisAddrEnvVar = "LOBBY_REDIS_ADDR"
|
||||
redisUsernameEnvVar = "LOBBY_REDIS_USERNAME"
|
||||
redisPasswordEnvVar = "LOBBY_REDIS_PASSWORD"
|
||||
redisDBEnvVar = "LOBBY_REDIS_DB"
|
||||
redisTLSEnabledEnvVar = "LOBBY_REDIS_TLS_ENABLED"
|
||||
redisOperationTimeoutEnvVar = "LOBBY_REDIS_OPERATION_TIMEOUT"
|
||||
|
||||
gmEventsStreamEnvVar = "LOBBY_GM_EVENTS_STREAM"
|
||||
gmEventsReadBlockTimeoutEnvVar = "LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT"
|
||||
userLifecycleStreamEnvVar = "LOBBY_USER_LIFECYCLE_STREAM"
|
||||
@@ -69,8 +65,6 @@ const (
|
||||
defaultReadHeaderTimeout = 2 * time.Second
|
||||
defaultReadTimeout = 10 * time.Second
|
||||
defaultIdleTimeout = time.Minute
|
||||
defaultRedisDB = 0
|
||||
defaultRedisOperationTimeout = 2 * time.Second
|
||||
defaultGMEventsStream = "gm:lobby_events"
|
||||
defaultGMEventsReadBlockTimeout = 2 * time.Second
|
||||
defaultUserLifecycleStream = "user:lifecycle_events"
|
||||
@@ -86,12 +80,13 @@ const (
|
||||
defaultRaceNameExpirationInterval = time.Hour
|
||||
defaultOTelServiceName = "galaxy-lobby"
|
||||
|
||||
// RaceNameDirectoryBackendRedis selects the Redis-backed Race Name
|
||||
// Directory adapter. It is the default production backend.
|
||||
RaceNameDirectoryBackendRedis = "redis"
|
||||
// RaceNameDirectoryBackendPostgres selects the PostgreSQL-backed
|
||||
// Race Name Directory adapter. It is the default production backend
|
||||
// after PG_PLAN.md §6B.
|
||||
RaceNameDirectoryBackendPostgres = "postgres"
|
||||
|
||||
// RaceNameDirectoryBackendStub selects the in-process Race Name
|
||||
// Directory stub used by unit tests that do not need Redis.
|
||||
// Directory stub used by unit tests that do not need PostgreSQL.
|
||||
RaceNameDirectoryBackendStub = "stub"
|
||||
)
|
||||
|
||||
@@ -115,6 +110,10 @@ type Config struct {
|
||||
// consumed by the runnable service skeleton and its future workers.
|
||||
Redis RedisConfig
|
||||
|
||||
// Postgres configures the PostgreSQL-backed durable store consumed via
|
||||
// `pkg/postgres`.
|
||||
Postgres PostgresConfig
|
||||
|
||||
// UserService configures the synchronous User Service eligibility client.
|
||||
UserService UserServiceConfig
|
||||
|
||||
@@ -143,7 +142,7 @@ type Config struct {
|
||||
// is wired into the runtime.
|
||||
type RaceNameDirectoryConfig struct {
|
||||
// Backend selects the Race Name Directory adapter. Accepted values
|
||||
// are RaceNameDirectoryBackendRedis and RaceNameDirectoryBackendStub.
|
||||
// are RaceNameDirectoryBackendPostgres and RaceNameDirectoryBackendStub.
|
||||
Backend string
|
||||
}
|
||||
|
||||
@@ -151,14 +150,14 @@ type RaceNameDirectoryConfig struct {
|
||||
// backend selector.
|
||||
func (cfg RaceNameDirectoryConfig) Validate() error {
|
||||
switch cfg.Backend {
|
||||
case RaceNameDirectoryBackendRedis, RaceNameDirectoryBackendStub:
|
||||
case RaceNameDirectoryBackendPostgres, RaceNameDirectoryBackendStub:
|
||||
return nil
|
||||
case "":
|
||||
return fmt.Errorf("race name directory backend must not be empty")
|
||||
default:
|
||||
return fmt.Errorf("race name directory backend %q must be one of %q or %q",
|
||||
cfg.Backend,
|
||||
RaceNameDirectoryBackendRedis,
|
||||
RaceNameDirectoryBackendPostgres,
|
||||
RaceNameDirectoryBackendStub)
|
||||
}
|
||||
}
|
||||
@@ -237,26 +236,15 @@ func (cfg InternalHTTPConfig) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// RedisConfig configures the shared Redis client and the Redis-owned
|
||||
// Streams keys consumed by the runnable service skeleton.
|
||||
// RedisConfig configures the Game Lobby Redis connection topology and the
|
||||
// Redis Stream names Lobby reads from / writes to. Per-call timeouts and
|
||||
// connection topology live inside `Conn`.
|
||||
type RedisConfig struct {
|
||||
// Addr stores the Redis network address.
|
||||
Addr string
|
||||
|
||||
// Username stores the optional Redis ACL username.
|
||||
Username string
|
||||
|
||||
// Password stores the optional Redis ACL password.
|
||||
Password string
|
||||
|
||||
// DB stores the Redis logical database index.
|
||||
DB int
|
||||
|
||||
// TLSEnabled reports whether TLS must be used for Redis connections.
|
||||
TLSEnabled bool
|
||||
|
||||
// OperationTimeout bounds one Redis round trip including the startup PING.
|
||||
OperationTimeout time.Duration
|
||||
// Conn carries the connection topology (master, replicas, password, db,
|
||||
// per-call timeout). Loaded via redisconn.LoadFromEnv("LOBBY"); rejects
|
||||
// the deprecated LOBBY_REDIS_TLS_ENABLED / LOBBY_REDIS_USERNAME env vars
|
||||
// at startup.
|
||||
Conn redisconn.Config
|
||||
|
||||
// GMEventsStream stores the Redis Streams key for Game Master runtime
|
||||
// events consumed by Lobby.
|
||||
@@ -297,27 +285,12 @@ type RedisConfig struct {
|
||||
UserLifecycleReadBlockTimeout time.Duration
|
||||
}
|
||||
|
||||
// TLSConfig returns the conservative TLS configuration used by the Redis
|
||||
// client when TLSEnabled is true.
|
||||
func (cfg RedisConfig) TLSConfig() *tls.Config {
|
||||
if !cfg.TLSEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Redis configuration.
|
||||
func (cfg RedisConfig) Validate() error {
|
||||
if err := cfg.Conn.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("redis addr must not be empty")
|
||||
case !isTCPAddr(cfg.Addr):
|
||||
return fmt.Errorf("redis addr %q must use host:port form", cfg.Addr)
|
||||
case cfg.DB < 0:
|
||||
return fmt.Errorf("redis db must not be negative")
|
||||
case cfg.OperationTimeout <= 0:
|
||||
return fmt.Errorf("redis operation timeout must be positive")
|
||||
case strings.TrimSpace(cfg.GMEventsStream) == "":
|
||||
return fmt.Errorf("redis gm events stream must not be empty")
|
||||
case cfg.GMEventsReadBlockTimeout <= 0:
|
||||
@@ -341,6 +314,19 @@ func (cfg RedisConfig) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// PostgresConfig configures the PostgreSQL-backed durable store consumed via
|
||||
// `pkg/postgres`. Topology and pool tuning live in `Conn`; loaded via
|
||||
// `postgres.LoadFromEnv("LOBBY")`.
|
||||
type PostgresConfig struct {
|
||||
// Conn carries the primary plus replica DSN topology and pool tuning.
|
||||
Conn postgres.Config
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable PostgreSQL configuration.
|
||||
func (cfg PostgresConfig) Validate() error {
|
||||
return cfg.Conn.Validate()
|
||||
}
|
||||
|
||||
// UserServiceConfig configures the synchronous User Service eligibility
|
||||
// client used by the application flow.
|
||||
type UserServiceConfig struct {
|
||||
@@ -489,8 +475,7 @@ func DefaultConfig() Config {
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
DB: defaultRedisDB,
|
||||
OperationTimeout: defaultRedisOperationTimeout,
|
||||
Conn: redisconn.DefaultConfig(),
|
||||
GMEventsStream: defaultGMEventsStream,
|
||||
GMEventsReadBlockTimeout: defaultGMEventsReadBlockTimeout,
|
||||
RuntimeStartJobsStream: defaultRuntimeStartJobsStream,
|
||||
@@ -501,6 +486,9 @@ func DefaultConfig() Config {
|
||||
UserLifecycleStream: defaultUserLifecycleStream,
|
||||
UserLifecycleReadBlockTimeout: defaultUserLifecycleReadBlockTimeout,
|
||||
},
|
||||
Postgres: PostgresConfig{
|
||||
Conn: postgres.DefaultConfig(),
|
||||
},
|
||||
UserService: UserServiceConfig{
|
||||
Timeout: defaultUserServiceTimeout,
|
||||
},
|
||||
@@ -511,7 +499,7 @@ func DefaultConfig() Config {
|
||||
Interval: defaultEnrollmentAutomationInterval,
|
||||
},
|
||||
RaceNameDirectory: RaceNameDirectoryConfig{
|
||||
Backend: RaceNameDirectoryBackendRedis,
|
||||
Backend: RaceNameDirectoryBackendPostgres,
|
||||
},
|
||||
PendingRegistration: PendingRegistrationConfig{
|
||||
Interval: defaultRaceNameExpirationInterval,
|
||||
|
||||
@@ -5,10 +5,21 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testDSN = "postgres://lobbyservice:lobbyservice@127.0.0.1:5432/galaxy?search_path=lobby&sslmode=disable"
|
||||
testRedisAddr = "127.0.0.1:6379"
|
||||
testRedisSecret = "secret"
|
||||
testUserBaseURL = "http://user.internal:8090"
|
||||
testGMBaseURL = "http://gm.internal:8091"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -18,7 +29,8 @@ func TestDefaultConfig(t *testing.T) {
|
||||
assert.Equal(t, "info", cfg.Logging.Level)
|
||||
assert.Equal(t, ":8094", cfg.PublicHTTP.Addr)
|
||||
assert.Equal(t, ":8095", cfg.InternalHTTP.Addr)
|
||||
assert.Equal(t, 2*time.Second, cfg.Redis.OperationTimeout)
|
||||
assert.Equal(t, redisconn.DefaultOperationTimeout, cfg.Redis.Conn.OperationTimeout)
|
||||
assert.Equal(t, postgres.DefaultOperationTimeout, cfg.Postgres.Conn.OperationTimeout)
|
||||
assert.Equal(t, "gm:lobby_events", cfg.Redis.GMEventsStream)
|
||||
assert.Equal(t, "runtime:start_jobs", cfg.Redis.RuntimeStartJobsStream)
|
||||
assert.Equal(t, "runtime:stop_jobs", cfg.Redis.RuntimeStopJobsStream)
|
||||
@@ -35,16 +47,20 @@ func TestDefaultConfig(t *testing.T) {
|
||||
|
||||
func TestLoadFromEnvAppliesRequiredFields(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
|
||||
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
|
||||
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
|
||||
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
|
||||
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
|
||||
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
|
||||
assert.Equal(t, "http://user.internal:8090", cfg.UserService.BaseURL)
|
||||
assert.Equal(t, "http://gm.internal:8091", cfg.GM.BaseURL)
|
||||
assert.Equal(t, testRedisAddr, cfg.Redis.Conn.MasterAddr)
|
||||
assert.Equal(t, testRedisSecret, cfg.Redis.Conn.Password)
|
||||
assert.Equal(t, testDSN, cfg.Postgres.Conn.PrimaryDSN)
|
||||
assert.Equal(t, testUserBaseURL, cfg.UserService.BaseURL)
|
||||
assert.Equal(t, testGMBaseURL, cfg.GM.BaseURL)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvMissingRequiredFields(t *testing.T) {
|
||||
@@ -52,21 +68,48 @@ func TestLoadFromEnvMissingRequiredFields(t *testing.T) {
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "redis addr must not be empty")
|
||||
require.Contains(t, err.Error(), "LOBBY_REDIS_MASTER_ADDR")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsDeprecatedRedisVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
}{
|
||||
{name: "TLS_ENABLED", envName: "LOBBY_REDIS_TLS_ENABLED"},
|
||||
{name: "USERNAME", envName: "LOBBY_REDIS_USERNAME"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
|
||||
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
|
||||
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
|
||||
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
|
||||
t.Setenv(tt.envName, "anything")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.envName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvOverrides(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
|
||||
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
|
||||
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
|
||||
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
|
||||
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
|
||||
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
|
||||
|
||||
t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "12s")
|
||||
t.Setenv("LOBBY_LOG_LEVEL", "debug")
|
||||
t.Setenv("LOBBY_PUBLIC_HTTP_ADDR", "127.0.0.1:9001")
|
||||
t.Setenv("LOBBY_INTERNAL_HTTP_ADDR", "127.0.0.1:9002")
|
||||
t.Setenv("LOBBY_REDIS_DB", "5")
|
||||
t.Setenv("LOBBY_REDIS_TLS_ENABLED", "true")
|
||||
t.Setenv("LOBBY_REDIS_OPERATION_TIMEOUT", "300ms")
|
||||
t.Setenv("LOBBY_GM_EVENTS_STREAM", "alt:gm_events")
|
||||
t.Setenv("LOBBY_NOTIFICATION_INTENTS_STREAM", "alt:intents")
|
||||
t.Setenv("LOBBY_ENROLLMENT_AUTOMATION_INTERVAL", "45s")
|
||||
@@ -80,21 +123,22 @@ func TestLoadFromEnvOverrides(t *testing.T) {
|
||||
assert.Equal(t, "debug", cfg.Logging.Level)
|
||||
assert.Equal(t, "127.0.0.1:9001", cfg.PublicHTTP.Addr)
|
||||
assert.Equal(t, "127.0.0.1:9002", cfg.InternalHTTP.Addr)
|
||||
assert.Equal(t, 5, cfg.Redis.DB)
|
||||
assert.True(t, cfg.Redis.TLSEnabled)
|
||||
assert.Equal(t, 5, cfg.Redis.Conn.DB)
|
||||
assert.Equal(t, 300*time.Millisecond, cfg.Redis.Conn.OperationTimeout)
|
||||
assert.Equal(t, "alt:gm_events", cfg.Redis.GMEventsStream)
|
||||
assert.Equal(t, "alt:intents", cfg.Redis.NotificationIntentsStream)
|
||||
assert.Equal(t, 45*time.Second, cfg.EnrollmentAutomation.Interval)
|
||||
assert.Equal(t, 15*time.Minute, cfg.PendingRegistration.Interval)
|
||||
assert.Equal(t, "galaxy-lobby-test", cfg.Telemetry.ServiceName)
|
||||
assert.NotNil(t, cfg.Redis.TLSConfig())
|
||||
}
|
||||
|
||||
func TestLoadFromEnvInvalidDuration(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
|
||||
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
|
||||
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
|
||||
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
|
||||
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
|
||||
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
|
||||
t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "not-a-duration")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
@@ -153,7 +197,8 @@ func TestRedisConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := DefaultConfig().Redis
|
||||
base.Addr = "127.0.0.1:6379"
|
||||
base.Conn.MasterAddr = testRedisAddr
|
||||
base.Conn.Password = testRedisSecret
|
||||
require.NoError(t, base.Validate())
|
||||
|
||||
tests := []struct {
|
||||
@@ -161,10 +206,10 @@ func TestRedisConfigValidate(t *testing.T) {
|
||||
mutate func(*RedisConfig)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty addr", mutate: func(cfg *RedisConfig) { cfg.Addr = "" }, wantErr: "addr must not be empty"},
|
||||
{name: "bad addr", mutate: func(cfg *RedisConfig) { cfg.Addr = "weird" }, wantErr: "must use host:port"},
|
||||
{name: "negative db", mutate: func(cfg *RedisConfig) { cfg.DB = -1 }, wantErr: "must not be negative"},
|
||||
{name: "zero op timeout", mutate: func(cfg *RedisConfig) { cfg.OperationTimeout = 0 }, wantErr: "operation timeout"},
|
||||
{name: "empty master addr", mutate: func(cfg *RedisConfig) { cfg.Conn.MasterAddr = "" }, wantErr: "master addr"},
|
||||
{name: "empty password", mutate: func(cfg *RedisConfig) { cfg.Conn.Password = "" }, wantErr: "password"},
|
||||
{name: "negative db", mutate: func(cfg *RedisConfig) { cfg.Conn.DB = -1 }, wantErr: "must not be negative"},
|
||||
{name: "zero op timeout", mutate: func(cfg *RedisConfig) { cfg.Conn.OperationTimeout = 0 }, wantErr: "operation timeout"},
|
||||
{name: "empty gm stream", mutate: func(cfg *RedisConfig) { cfg.GMEventsStream = "" }, wantErr: "gm events stream"},
|
||||
{name: "zero gm block", mutate: func(cfg *RedisConfig) { cfg.GMEventsReadBlockTimeout = 0 }, wantErr: "gm events read block timeout"},
|
||||
{name: "empty start jobs", mutate: func(cfg *RedisConfig) { cfg.RuntimeStartJobsStream = "" }, wantErr: "runtime start jobs"},
|
||||
@@ -188,6 +233,18 @@ func TestRedisConfigValidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := DefaultConfig().Postgres
|
||||
base.Conn.PrimaryDSN = testDSN
|
||||
require.NoError(t, base.Validate())
|
||||
|
||||
bad := base
|
||||
bad.Conn.PrimaryDSN = ""
|
||||
require.ErrorContains(t, bad.Validate(), "primary DSN")
|
||||
}
|
||||
|
||||
func TestUserServiceConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -255,7 +312,9 @@ func TestConfigValidateLogLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Redis.Addr = "127.0.0.1:6379"
|
||||
cfg.Redis.Conn.MasterAddr = testRedisAddr
|
||||
cfg.Redis.Conn.Password = testRedisSecret
|
||||
cfg.Postgres.Conn.PrimaryDSN = testDSN
|
||||
cfg.UserService.BaseURL = "http://u:1"
|
||||
cfg.GM.BaseURL = "http://gm:1"
|
||||
require.NoError(t, cfg.Validate())
|
||||
@@ -266,18 +325,6 @@ func TestConfigValidateLogLevel(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "slog level")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvBoolParseError(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://u:1")
|
||||
t.Setenv("LOBBY_GM_BASE_URL", "http://gm:1")
|
||||
t.Setenv("LOBBY_REDIS_TLS_ENABLED", "not-bool")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "LOBBY_REDIS_TLS_ENABLED")
|
||||
}
|
||||
|
||||
// clearAllEnv unsets every environment variable the config package reads so
|
||||
// tests can configure their expected values explicitly.
|
||||
func clearAllEnv(t *testing.T) {
|
||||
@@ -294,12 +341,19 @@ func clearAllEnv(t *testing.T) {
|
||||
internalHTTPReadHeaderTimeoutEnvVar,
|
||||
internalHTTPReadTimeoutEnvVar,
|
||||
internalHTTPIdleTimeoutEnvVar,
|
||||
redisAddrEnvVar,
|
||||
redisUsernameEnvVar,
|
||||
redisPasswordEnvVar,
|
||||
redisDBEnvVar,
|
||||
redisTLSEnabledEnvVar,
|
||||
redisOperationTimeoutEnvVar,
|
||||
"LOBBY_REDIS_MASTER_ADDR",
|
||||
"LOBBY_REDIS_REPLICA_ADDRS",
|
||||
"LOBBY_REDIS_PASSWORD",
|
||||
"LOBBY_REDIS_DB",
|
||||
"LOBBY_REDIS_OPERATION_TIMEOUT",
|
||||
"LOBBY_REDIS_TLS_ENABLED",
|
||||
"LOBBY_REDIS_USERNAME",
|
||||
"LOBBY_POSTGRES_PRIMARY_DSN",
|
||||
"LOBBY_POSTGRES_REPLICA_DSNS",
|
||||
"LOBBY_POSTGRES_OPERATION_TIMEOUT",
|
||||
"LOBBY_POSTGRES_MAX_OPEN_CONNS",
|
||||
"LOBBY_POSTGRES_MAX_IDLE_CONNS",
|
||||
"LOBBY_POSTGRES_CONN_MAX_LIFETIME",
|
||||
gmEventsStreamEnvVar,
|
||||
gmEventsReadBlockTimeoutEnvVar,
|
||||
runtimeStartJobsStreamEnvVar,
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
)
|
||||
|
||||
// LoadFromEnv builds Config from environment variables and validates the
|
||||
@@ -50,21 +53,18 @@ func LoadFromEnv() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Redis.Addr = stringEnv(redisAddrEnvVar, cfg.Redis.Addr)
|
||||
cfg.Redis.Username = stringEnv(redisUsernameEnvVar, cfg.Redis.Username)
|
||||
cfg.Redis.Password = stringEnv(redisPasswordEnvVar, cfg.Redis.Password)
|
||||
cfg.Redis.DB, err = intEnv(redisDBEnvVar, cfg.Redis.DB)
|
||||
redisConn, err := redisconn.LoadFromEnv(envPrefix)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.TLSEnabled, err = boolEnv(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.OperationTimeout, err = durationEnv(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
|
||||
cfg.Redis.Conn = redisConn
|
||||
|
||||
pgConn, err := postgres.LoadFromEnv(envPrefix)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Postgres.Conn = pgConn
|
||||
|
||||
cfg.Redis.GMEventsStream = stringEnv(gmEventsStreamEnvVar, cfg.Redis.GMEventsStream)
|
||||
cfg.Redis.GMEventsReadBlockTimeout, err = durationEnv(gmEventsReadBlockTimeoutEnvVar, cfg.Redis.GMEventsReadBlockTimeout)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Package racenamedirtest exposes the shared behavioural test suite that
|
||||
// every ports.RaceNameDirectory implementation must pass. The Redis
|
||||
// every ports.RaceNameDirectory implementation must pass. The PostgreSQL
|
||||
// adapter and the in-process stub run the same cases so both back ends
|
||||
// stay behaviourally equivalent.
|
||||
//
|
||||
// Subtests run sequentially: the PostgreSQL adapter shares one
|
||||
// testcontainers instance across the suite and relies on TruncateAll
|
||||
// between factory invocations, which would race under t.Parallel.
|
||||
package racenamedirtest
|
||||
|
||||
import (
|
||||
@@ -29,144 +33,111 @@ func Run(t *testing.T, factory Factory) {
|
||||
t.Helper()
|
||||
|
||||
t.Run("Canonicalize rejects invalid input", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCanonicalizeRejectsInvalid(t, factory)
|
||||
})
|
||||
t.Run("Canonicalize is deterministic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCanonicalizeDeterministic(t, factory)
|
||||
})
|
||||
|
||||
t.Run("Check empty directory", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCheckEmpty(t, factory)
|
||||
})
|
||||
t.Run("Check treats actor as own holder", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCheckActorNotTaken(t, factory)
|
||||
})
|
||||
t.Run("Check exposes holder and kind to other users", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCheckHolderAndKind(t, factory)
|
||||
})
|
||||
|
||||
t.Run("Reserve records new holding", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReserveRecords(t, factory)
|
||||
})
|
||||
t.Run("Reserve idempotent for same holder same game", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReserveIdempotent(t, factory)
|
||||
})
|
||||
t.Run("Reserve allows same user across games", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReserveCrossGame(t, factory)
|
||||
})
|
||||
t.Run("Reserve rejects cross-user same game", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReserveCrossUserSameGame(t, factory)
|
||||
})
|
||||
t.Run("Reserve rejects cross-user different games", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReserveCrossUserDifferentGames(t, factory)
|
||||
})
|
||||
t.Run("Reserve rejects invalid name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReserveInvalidName(t, factory)
|
||||
})
|
||||
|
||||
t.Run("ReleaseReservation missing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseReservationMissing(t, factory)
|
||||
})
|
||||
t.Run("ReleaseReservation wrong holder", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseReservationWrongHolder(t, factory)
|
||||
})
|
||||
t.Run("ReleaseReservation clears sole binding", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseReservationClears(t, factory)
|
||||
})
|
||||
t.Run("ReleaseReservation swallows invalid name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseReservationInvalidName(t, factory)
|
||||
})
|
||||
t.Run("ReleaseReservation keeps cross-game holding visible", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseReservationKeepsCrossGame(t, factory)
|
||||
})
|
||||
|
||||
t.Run("MarkPendingRegistration promotes reservation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testMarkPendingPromotes(t, factory)
|
||||
})
|
||||
t.Run("MarkPendingRegistration idempotent same eligible", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testMarkPendingIdempotent(t, factory)
|
||||
})
|
||||
t.Run("MarkPendingRegistration rejects different eligible", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testMarkPendingDifferentEligible(t, factory)
|
||||
})
|
||||
t.Run("MarkPendingRegistration rejects missing reservation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testMarkPendingMissing(t, factory)
|
||||
})
|
||||
|
||||
t.Run("ExpirePendingRegistrations empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testExpirePendingEmpty(t, factory)
|
||||
})
|
||||
t.Run("ExpirePendingRegistrations releases expired entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testExpirePendingReleasesExpired(t, factory)
|
||||
})
|
||||
t.Run("ExpirePendingRegistrations skips future entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testExpirePendingSkipsFuture(t, factory)
|
||||
})
|
||||
t.Run("ExpirePendingRegistrations idempotent replay", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testExpirePendingIdempotent(t, factory)
|
||||
})
|
||||
|
||||
t.Run("Register converts pending to registered", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testRegisterConverts(t, factory)
|
||||
})
|
||||
t.Run("Register idempotent on repeat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testRegisterIdempotent(t, factory)
|
||||
})
|
||||
t.Run("Register rejects missing pending", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testRegisterMissingPending(t, factory)
|
||||
})
|
||||
t.Run("Register rejects expired pending", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testRegisterExpiredPending(t, factory)
|
||||
})
|
||||
|
||||
t.Run("List methods partition correctly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testListsPartition(t, factory)
|
||||
})
|
||||
|
||||
t.Run("ReleaseAllByUser clears every kind", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseAllByUserClears(t, factory)
|
||||
})
|
||||
t.Run("ReleaseAllByUser leaves other users intact", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseAllByUserIsolated(t, factory)
|
||||
})
|
||||
t.Run("ReleaseAllByUser idempotent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testReleaseAllByUserIdempotent(t, factory)
|
||||
})
|
||||
|
||||
t.Run("Honors canceled context", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testContextCancellation(t, factory)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user