254 lines
7.3 KiB
Go
254 lines
7.3 KiB
Go
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
|
|
}
|