303 lines
9.5 KiB
Go
303 lines
9.5 KiB
Go
package listmyracenames_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/adapters/gamestub"
|
|
"galaxy/lobby/internal/adapters/racenamestub"
|
|
"galaxy/lobby/internal/domain/common"
|
|
"galaxy/lobby/internal/domain/game"
|
|
"galaxy/lobby/internal/ports"
|
|
"galaxy/lobby/internal/service/listmyracenames"
|
|
"galaxy/lobby/internal/service/shared"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// silentLogger discards all log output to keep test stdout clean.
|
|
func silentLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// fixture wires the listmyracenames service with the in-process
|
|
// race-name directory stub and the in-process game store.
|
|
type fixture struct {
|
|
now time.Time
|
|
directory *racenamestub.Directory
|
|
games *gamestub.Store
|
|
service *listmyracenames.Service
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
|
require.NoError(t, err)
|
|
games := gamestub.NewStore()
|
|
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
|
Directory: directory,
|
|
Games: games,
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
return &fixture{now: now, directory: directory, games: games, service: svc}
|
|
}
|
|
|
|
// seedGame stores one game record so reservation lookups have a record
|
|
// to read. Private games receive a synthetic owner so domain
|
|
// validation accepts them.
|
|
func (f *fixture) seedGame(t *testing.T, id common.GameID, gameType game.GameType, status game.Status) {
|
|
t.Helper()
|
|
|
|
owner := ""
|
|
if gameType == game.GameTypePrivate {
|
|
owner = "owner-" + id.String()
|
|
}
|
|
record, err := game.New(game.NewGameInput{
|
|
GameID: id,
|
|
GameName: "Seed " + id.String(),
|
|
GameType: gameType,
|
|
OwnerUserID: owner,
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 4,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: f.now.Add(24 * time.Hour),
|
|
TurnSchedule: "0 */6 * * *",
|
|
TargetEngineVersion: "1.0.0",
|
|
Now: f.now,
|
|
})
|
|
require.NoError(t, err)
|
|
if status != game.StatusDraft {
|
|
record.Status = status
|
|
}
|
|
require.NoError(t, f.games.Save(context.Background(), record))
|
|
}
|
|
|
|
func (f *fixture) reserve(t *testing.T, gameID, userID, raceName string) {
|
|
t.Helper()
|
|
require.NoError(t, f.directory.Reserve(context.Background(), gameID, userID, raceName))
|
|
}
|
|
|
|
func (f *fixture) markPending(t *testing.T, gameID, userID, raceName string, eligibleUntil time.Time) {
|
|
t.Helper()
|
|
require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), gameID, userID, raceName, eligibleUntil))
|
|
}
|
|
|
|
func (f *fixture) register(t *testing.T, gameID, userID, raceName string) {
|
|
t.Helper()
|
|
require.NoError(t, f.directory.Register(context.Background(), gameID, userID, raceName))
|
|
}
|
|
|
|
func TestHandleHappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newFixture(t)
|
|
const userID = "user-1"
|
|
|
|
// One registered name converted from a finished game.
|
|
f.seedGame(t, "game-finished", game.GameTypePublic, game.StatusFinished)
|
|
f.reserve(t, "game-finished", userID, "Andromeda")
|
|
f.markPending(t, "game-finished", userID, "Andromeda", f.now.Add(7*24*time.Hour))
|
|
f.register(t, "game-finished", userID, "Andromeda")
|
|
|
|
// One pending registration awaiting the
|
|
f.seedGame(t, "game-pending", game.GameTypePublic, game.StatusFinished)
|
|
f.reserve(t, "game-pending", userID, "Vega")
|
|
f.markPending(t, "game-pending", userID, "Vega", f.now.Add(24*time.Hour))
|
|
|
|
// Two active reservations across a running and an enrollment_open game.
|
|
f.seedGame(t, "game-running", game.GameTypePrivate, game.StatusRunning)
|
|
f.reserve(t, "game-running", userID, "Orion")
|
|
f.seedGame(t, "game-open", game.GameTypePublic, game.StatusEnrollmentOpen)
|
|
f.reserve(t, "game-open", userID, "Cygnus")
|
|
|
|
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
|
Actor: shared.NewUserActor(userID),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, out.Registered, 1)
|
|
assert.Equal(t, "Andromeda", out.Registered[0].RaceName)
|
|
assert.Equal(t, "game-finished", out.Registered[0].SourceGameID)
|
|
assert.Equal(t, f.now.UnixMilli(), out.Registered[0].RegisteredAtMs)
|
|
assert.NotEmpty(t, out.Registered[0].CanonicalKey)
|
|
|
|
require.Len(t, out.Pending, 1)
|
|
assert.Equal(t, "Vega", out.Pending[0].RaceName)
|
|
assert.Equal(t, "game-pending", out.Pending[0].SourceGameID)
|
|
assert.Equal(t, f.now.Add(24*time.Hour).UnixMilli(), out.Pending[0].EligibleUntilMs)
|
|
assert.Equal(t, f.now.UnixMilli(), out.Pending[0].ReservedAtMs)
|
|
|
|
require.Len(t, out.Reservations, 2)
|
|
gameStatusByID := map[string]string{}
|
|
for _, r := range out.Reservations {
|
|
gameStatusByID[r.GameID] = r.GameStatus
|
|
}
|
|
assert.Equal(t, string(game.StatusRunning), gameStatusByID["game-running"])
|
|
assert.Equal(t, string(game.StatusEnrollmentOpen), gameStatusByID["game-open"])
|
|
}
|
|
|
|
func TestHandleEmptyView(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newFixture(t)
|
|
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
|
Actor: shared.NewUserActor("user-empty"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, out.Registered)
|
|
assert.NotNil(t, out.Pending)
|
|
assert.NotNil(t, out.Reservations)
|
|
assert.Empty(t, out.Registered)
|
|
assert.Empty(t, out.Pending)
|
|
assert.Empty(t, out.Reservations)
|
|
}
|
|
|
|
func TestHandleAdminActorIsForbidden(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newFixture(t)
|
|
_, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
|
Actor: shared.NewAdminActor(),
|
|
})
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, shared.ErrForbidden)
|
|
}
|
|
|
|
func TestHandleInvalidActorIsRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newFixture(t)
|
|
_, err := f.service.Handle(context.Background(), listmyracenames.Input{Actor: shared.Actor{}})
|
|
require.Error(t, err)
|
|
assert.NotErrorIs(t, err, shared.ErrForbidden)
|
|
}
|
|
|
|
func TestHandleVisibilityIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newFixture(t)
|
|
f.seedGame(t, "game-shared", game.GameTypePublic, game.StatusEnrollmentOpen)
|
|
f.reserve(t, "game-shared", "user-owner", "Polaris")
|
|
|
|
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
|
Actor: shared.NewUserActor("user-other"),
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, out.Reservations)
|
|
assert.Empty(t, out.Pending)
|
|
assert.Empty(t, out.Registered)
|
|
}
|
|
|
|
func TestHandleReservationOnMissingGame(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newFixture(t)
|
|
f.reserve(t, "game-vanished", "user-1", "Hydra")
|
|
|
|
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
|
Actor: shared.NewUserActor("user-1"),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, out.Reservations, 1)
|
|
assert.Equal(t, "game-vanished", out.Reservations[0].GameID)
|
|
assert.Empty(t, out.Reservations[0].GameStatus)
|
|
}
|
|
|
|
func TestHandleSortByTimestamp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const userID = "user-sort"
|
|
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
clock := now
|
|
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return clock }))
|
|
require.NoError(t, err)
|
|
games := gamestub.NewStore()
|
|
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
|
Directory: directory,
|
|
Games: games,
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
seed := func(id common.GameID) {
|
|
record, err := game.New(game.NewGameInput{
|
|
GameID: id,
|
|
GameName: "Seed " + id.String(),
|
|
GameType: game.GameTypePublic,
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 4,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
|
TurnSchedule: "0 */6 * * *",
|
|
TargetEngineVersion: "1.0.0",
|
|
Now: now,
|
|
})
|
|
require.NoError(t, err)
|
|
record.Status = game.StatusEnrollmentOpen
|
|
require.NoError(t, games.Save(context.Background(), record))
|
|
}
|
|
seed("game-a")
|
|
seed("game-b")
|
|
seed("game-c")
|
|
|
|
// Reserve three names against an advancing clock so reserved_at_ms
|
|
// is strictly increasing.
|
|
clock = now
|
|
require.NoError(t, directory.Reserve(context.Background(), "game-a", userID, "Lyra"))
|
|
clock = now.Add(time.Minute)
|
|
require.NoError(t, directory.Reserve(context.Background(), "game-b", userID, "Sirius"))
|
|
clock = now.Add(2 * time.Minute)
|
|
require.NoError(t, directory.Reserve(context.Background(), "game-c", userID, "Pavo"))
|
|
|
|
out, err := svc.Handle(context.Background(), listmyracenames.Input{
|
|
Actor: shared.NewUserActor(userID),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, out.Reservations, 3)
|
|
for index := 1; index < len(out.Reservations); index++ {
|
|
prev := out.Reservations[index-1]
|
|
curr := out.Reservations[index]
|
|
if prev.ReservedAtMs == curr.ReservedAtMs {
|
|
assert.LessOrEqual(t, prev.CanonicalKey, curr.CanonicalKey)
|
|
continue
|
|
}
|
|
assert.Less(t, prev.ReservedAtMs, curr.ReservedAtMs)
|
|
}
|
|
assert.Equal(t, "game-a", out.Reservations[0].GameID)
|
|
assert.Equal(t, "game-b", out.Reservations[1].GameID)
|
|
assert.Equal(t, "game-c", out.Reservations[2].GameID)
|
|
}
|
|
|
|
// TestNewServiceRejectsMissingDeps guards against constructor misuse.
|
|
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
directory, err := racenamestub.NewDirectory()
|
|
require.NoError(t, err)
|
|
games := gamestub.NewStore()
|
|
|
|
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
|
|
Games: games,
|
|
})
|
|
require.Error(t, err)
|
|
|
|
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
|
|
Directory: directory,
|
|
})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// Sanity guard so a future port refactor that drops the user-keyed
|
|
// indexes immediately breaks the test build instead of silently
|
|
// regressing the no-full-scan invariant.
|
|
var _ ports.RaceNameDirectory = (*racenamestub.Directory)(nil)
|