feat: use postgres
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user