Files
galaxy-game/lobby/internal/adapters/postgres/gamestore/store_test.go
T
2026-04-26 20:34:39 +02:00

339 lines
10 KiB
Go

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)
}