339 lines
10 KiB
Go
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)
|
|
}
|