feat: game lobby service
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
package gamestub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newDraftRecord(t *testing.T, id common.GameID, createdAt time.Time) game.Game {
|
||||
t.Helper()
|
||||
|
||||
record, err := game.New(game.NewGameInput{
|
||||
GameID: id,
|
||||
GameName: "Test Game",
|
||||
GameType: game.GameTypePublic,
|
||||
OwnerUserID: "",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: createdAt.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
Now: createdAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func TestStoreSaveGetRoundtrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
record := newDraftRecord(t, "game-alpha", time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
loaded, err := store.Get(ctx, "game-alpha")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.GameID, loaded.GameID)
|
||||
require.Equal(t, record.Status, loaded.Status)
|
||||
require.Equal(t, record.UpdatedAt.UTC(), loaded.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestStoreGetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
|
||||
_, err := store.Get(context.Background(), "game-missing")
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestStoreGetByStatusOrderedByCreatedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
earlier := time.Date(2026, 4, 24, 9, 0, 0, 0, time.UTC)
|
||||
later := earlier.Add(30 * time.Minute)
|
||||
|
||||
a := newDraftRecord(t, "game-a", earlier)
|
||||
b := newDraftRecord(t, "game-b", later)
|
||||
require.NoError(t, store.Save(ctx, b))
|
||||
require.NoError(t, store.Save(ctx, a))
|
||||
|
||||
records, err := store.GetByStatus(ctx, game.StatusDraft)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 2)
|
||||
require.Equal(t, common.GameID("game-a"), records[0].GameID)
|
||||
require.Equal(t, common.GameID("game-b"), records[1].GameID)
|
||||
}
|
||||
|
||||
func TestStoreCountByStatusReturnsAllStatusBuckets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
createdAt := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Save(ctx, newDraftRecord(t, "game-a", createdAt)))
|
||||
require.NoError(t, store.Save(ctx, newDraftRecord(t, "game-b", createdAt)))
|
||||
|
||||
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, 0, counts[game.StatusRunning])
|
||||
}
|
||||
|
||||
func TestStoreUpdateStatusHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
record := newDraftRecord(t, "game-open", created)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := created.Add(time.Hour)
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-open",
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: at,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := store.Get(ctx, "game-open")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, game.StatusEnrollmentOpen, loaded.Status)
|
||||
require.Equal(t, at.UTC(), loaded.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestStoreUpdateStatusInvalidTransition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
record := newDraftRecord(t, "game-invalid", time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-invalid",
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusRunning,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, game.ErrInvalidTransition)
|
||||
}
|
||||
|
||||
func TestStoreUpdateStatusCASMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
record := newDraftRecord(t, "game-cas", created)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-cas",
|
||||
ExpectedFrom: game.StatusEnrollmentOpen,
|
||||
To: game.StatusReadyToStart,
|
||||
Trigger: game.TriggerManual,
|
||||
At: created.Add(time.Hour),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
}
|
||||
|
||||
func TestStoreUpdateStatusMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
|
||||
err := store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
GameID: "game-nope",
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestStoreUpdateRuntimeSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
record := newDraftRecord(t, "game-snap", created)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
|
||||
GameID: "game-snap",
|
||||
Snapshot: game.RuntimeSnapshot{
|
||||
CurrentTurn: 7,
|
||||
RuntimeStatus: "alive",
|
||||
EngineHealthSummary: "ok",
|
||||
},
|
||||
At: created.Add(2 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := store.Get(ctx, "game-snap")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 7, loaded.RuntimeSnapshot.CurrentTurn)
|
||||
require.Equal(t, "alive", loaded.RuntimeSnapshot.RuntimeStatus)
|
||||
require.Equal(t, game.StatusDraft, loaded.Status, "snapshot update must not alter status")
|
||||
}
|
||||
|
||||
func TestStoreValidateInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{GameID: ""})
|
||||
require.Error(t, err)
|
||||
|
||||
err = store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{GameID: ""})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = store.GetByStatus(ctx, game.Status("ghost"))
|
||||
require.Error(t, err)
|
||||
|
||||
require.True(t, errors.Is(game.ErrNotFound, game.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestStoreUpdateStatusSetsStartedAtAndFinishedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore()
|
||||
ctx := context.Background()
|
||||
|
||||
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
record := newDraftRecord(t, "game-timeline", created)
|
||||
record.Status = game.StatusStarting
|
||||
record.UpdatedAt = created.Add(time.Hour)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
runningAt := created.Add(2 * time.Hour)
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-timeline",
|
||||
ExpectedFrom: game.StatusStarting,
|
||||
To: game.StatusRunning,
|
||||
Trigger: game.TriggerRuntimeEvent,
|
||||
At: runningAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := store.Get(ctx, "game-timeline")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded.StartedAt)
|
||||
require.Equal(t, runningAt.UTC(), loaded.StartedAt.UTC())
|
||||
require.Nil(t, loaded.FinishedAt)
|
||||
|
||||
finishAt := runningAt.Add(5 * time.Hour)
|
||||
err = store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-timeline",
|
||||
ExpectedFrom: game.StatusRunning,
|
||||
To: game.StatusFinished,
|
||||
Trigger: game.TriggerRuntimeEvent,
|
||||
At: finishAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err = store.Get(ctx, "game-timeline")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded.FinishedAt)
|
||||
require.Equal(t, finishAt.UTC(), loaded.FinishedAt.UTC())
|
||||
require.Equal(t, runningAt.UTC(), loaded.StartedAt.UTC(), "StartedAt must be preserved")
|
||||
}
|
||||
Reference in New Issue
Block a user