package pendingregistration_test import ( "context" "errors" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/racenamestub" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/worker/pendingregistration" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( gameA = "game-A" gameB = "game-B" userA = "user-A" userB = "user-B" raceNameA = "PilotNova" raceNameB = "Vanguard" ) func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type controlledClock struct{ instant time.Time } func (clock *controlledClock) now() time.Time { return clock.instant } func (clock *controlledClock) advance(d time.Duration) { clock.instant = clock.instant.Add(d) } func newDirectory(t *testing.T, clock *controlledClock) *racenamestub.Directory { t.Helper() directory, err := racenamestub.NewDirectory(racenamestub.WithClock(clock.now)) require.NoError(t, err) return directory } func newWorker( t *testing.T, directory ports.RaceNameDirectory, clock func() time.Time, ) *pendingregistration.Worker { t.Helper() worker, err := pendingregistration.NewWorker(pendingregistration.Dependencies{ Directory: directory, Interval: time.Hour, Clock: clock, Logger: silentLogger(), }) require.NoError(t, err) return worker } func reserveAndPend( t *testing.T, directory ports.RaceNameDirectory, gameID, userID, raceName string, eligibleUntil time.Time, ) { t.Helper() ctx := context.Background() require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName)) require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, eligibleUntil)) } func TestNewWorkerRejectsNilDirectory(t *testing.T) { t.Parallel() _, err := pendingregistration.NewWorker(pendingregistration.Dependencies{ Interval: time.Hour, }) require.Error(t, err) } func TestNewWorkerRejectsNonPositiveInterval(t *testing.T) { t.Parallel() directory, err := racenamestub.NewDirectory() require.NoError(t, err) _, err = pendingregistration.NewWorker(pendingregistration.Dependencies{ Directory: directory, Interval: 0, }) require.Error(t, err) } func TestTickReleasesExpiredEntries(t *testing.T) { t.Parallel() clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)} directory := newDirectory(t, clock) eligibleUntil := clock.instant.Add(time.Hour) reserveAndPend(t, directory, gameA, userA, raceNameA, eligibleUntil) clock.advance(2 * time.Hour) worker := newWorker(t, directory, clock.now) worker.Tick(context.Background()) pending, err := directory.ListPendingRegistrations(context.Background(), userA) require.NoError(t, err) assert.Empty(t, pending) availability, err := directory.Check(context.Background(), raceNameA, userB) require.NoError(t, err) assert.False(t, availability.Taken) } func TestTickAtBoundaryReleasesEntry(t *testing.T) { t.Parallel() clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)} directory := newDirectory(t, clock) eligibleUntil := clock.instant.Add(time.Hour) reserveAndPend(t, directory, gameA, userA, raceNameA, eligibleUntil) clock.instant = eligibleUntil worker := newWorker(t, directory, clock.now) worker.Tick(context.Background()) pending, err := directory.ListPendingRegistrations(context.Background(), userA) require.NoError(t, err) assert.Empty(t, pending) } func TestTickKeepsFutureEntries(t *testing.T) { t.Parallel() clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)} directory := newDirectory(t, clock) eligibleUntil := clock.instant.Add(time.Hour) reserveAndPend(t, directory, gameA, userA, raceNameA, eligibleUntil) worker := newWorker(t, directory, clock.now) worker.Tick(context.Background()) pending, err := directory.ListPendingRegistrations(context.Background(), userA) require.NoError(t, err) require.Len(t, pending, 1) assert.Equal(t, raceNameA, pending[0].RaceName) } func TestTickReleasesMixedAgeEntries(t *testing.T) { t.Parallel() clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)} directory := newDirectory(t, clock) expiredUntil := clock.instant.Add(time.Hour) freshUntil := clock.instant.Add(48 * time.Hour) reserveAndPend(t, directory, gameA, userA, raceNameA, expiredUntil) reserveAndPend(t, directory, gameB, userB, raceNameB, freshUntil) clock.advance(2 * time.Hour) worker := newWorker(t, directory, clock.now) worker.Tick(context.Background()) pendingA, err := directory.ListPendingRegistrations(context.Background(), userA) require.NoError(t, err) assert.Empty(t, pendingA) pendingB, err := directory.ListPendingRegistrations(context.Background(), userB) require.NoError(t, err) require.Len(t, pendingB, 1) assert.Equal(t, raceNameB, pendingB[0].RaceName) } func TestTickIsIdempotent(t *testing.T) { t.Parallel() clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)} directory := newDirectory(t, clock) reserveAndPend(t, directory, gameA, userA, raceNameA, clock.instant.Add(time.Hour)) clock.advance(2 * time.Hour) worker := newWorker(t, directory, clock.now) worker.Tick(context.Background()) worker.Tick(context.Background()) pending, err := directory.ListPendingRegistrations(context.Background(), userA) require.NoError(t, err) assert.Empty(t, pending) } func TestTickReservedEntriesUntouched(t *testing.T) { t.Parallel() clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)} directory := newDirectory(t, clock) require.NoError(t, directory.Reserve(context.Background(), gameA, userA, raceNameA)) clock.advance(48 * time.Hour) worker := newWorker(t, directory, clock.now) worker.Tick(context.Background()) reservations, err := directory.ListReservations(context.Background(), userA) require.NoError(t, err) require.Len(t, reservations, 1) assert.Equal(t, raceNameA, reservations[0].RaceName) } func TestTickAbsorbsDirectoryError(t *testing.T) { t.Parallel() directory := failingDirectory{err: errors.New("redis unavailable")} worker, err := pendingregistration.NewWorker(pendingregistration.Dependencies{ Directory: directory, Interval: time.Hour, Clock: func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }, Logger: silentLogger(), }) require.NoError(t, err) worker.Tick(context.Background()) } func TestRunStopsOnContextCancel(t *testing.T) { t.Parallel() clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)} directory := newDirectory(t, clock) worker := newWorker(t, directory, clock.now) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- worker.Run(ctx) }() cancel() select { case err := <-done: require.ErrorIs(t, err, context.Canceled) case <-time.After(time.Second): t.Fatal("worker did not stop after context cancel") } require.NoError(t, worker.Shutdown(context.Background())) } // failingDirectory is a stand-in that surfaces a fixed error from // ExpirePendingRegistrations so the worker's failure path can be // exercised without spinning up the full Redis adapter. type failingDirectory struct { ports.RaceNameDirectory err error } func (directory failingDirectory) ExpirePendingRegistrations( context.Context, time.Time, ) ([]ports.ExpiredPending, error) { return nil, directory.err }