774 lines
25 KiB
Go
774 lines
25 KiB
Go
// Package racenamedirtest exposes the shared behavioural test suite that
|
|
// every ports.RaceNameDirectory implementation must pass. The Redis
|
|
// adapter and the in-process stub run the same cases so both back ends
|
|
// stay behaviourally equivalent.
|
|
package racenamedirtest
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/ports"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Factory constructs a fresh ports.RaceNameDirectory for one test case.
|
|
// Implementations honour the supplied clock so tests can frame
|
|
// reserved_at_ms and registered_at_ms deterministically.
|
|
type Factory func(now func() time.Time) ports.RaceNameDirectory
|
|
|
|
// Run executes the shared behavioural suite against factory. Call it
|
|
// from each adapter's _test.go file alongside any adapter-specific
|
|
// assertions.
|
|
func Run(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
|
|
t.Run("Canonicalize rejects invalid input", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCanonicalizeRejectsInvalid(t, factory)
|
|
})
|
|
t.Run("Canonicalize is deterministic", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCanonicalizeDeterministic(t, factory)
|
|
})
|
|
|
|
t.Run("Check empty directory", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCheckEmpty(t, factory)
|
|
})
|
|
t.Run("Check treats actor as own holder", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCheckActorNotTaken(t, factory)
|
|
})
|
|
t.Run("Check exposes holder and kind to other users", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCheckHolderAndKind(t, factory)
|
|
})
|
|
|
|
t.Run("Reserve records new holding", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReserveRecords(t, factory)
|
|
})
|
|
t.Run("Reserve idempotent for same holder same game", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReserveIdempotent(t, factory)
|
|
})
|
|
t.Run("Reserve allows same user across games", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReserveCrossGame(t, factory)
|
|
})
|
|
t.Run("Reserve rejects cross-user same game", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReserveCrossUserSameGame(t, factory)
|
|
})
|
|
t.Run("Reserve rejects cross-user different games", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReserveCrossUserDifferentGames(t, factory)
|
|
})
|
|
t.Run("Reserve rejects invalid name", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReserveInvalidName(t, factory)
|
|
})
|
|
|
|
t.Run("ReleaseReservation missing", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseReservationMissing(t, factory)
|
|
})
|
|
t.Run("ReleaseReservation wrong holder", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseReservationWrongHolder(t, factory)
|
|
})
|
|
t.Run("ReleaseReservation clears sole binding", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseReservationClears(t, factory)
|
|
})
|
|
t.Run("ReleaseReservation swallows invalid name", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseReservationInvalidName(t, factory)
|
|
})
|
|
t.Run("ReleaseReservation keeps cross-game holding visible", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseReservationKeepsCrossGame(t, factory)
|
|
})
|
|
|
|
t.Run("MarkPendingRegistration promotes reservation", func(t *testing.T) {
|
|
t.Parallel()
|
|
testMarkPendingPromotes(t, factory)
|
|
})
|
|
t.Run("MarkPendingRegistration idempotent same eligible", func(t *testing.T) {
|
|
t.Parallel()
|
|
testMarkPendingIdempotent(t, factory)
|
|
})
|
|
t.Run("MarkPendingRegistration rejects different eligible", func(t *testing.T) {
|
|
t.Parallel()
|
|
testMarkPendingDifferentEligible(t, factory)
|
|
})
|
|
t.Run("MarkPendingRegistration rejects missing reservation", func(t *testing.T) {
|
|
t.Parallel()
|
|
testMarkPendingMissing(t, factory)
|
|
})
|
|
|
|
t.Run("ExpirePendingRegistrations empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
testExpirePendingEmpty(t, factory)
|
|
})
|
|
t.Run("ExpirePendingRegistrations releases expired entries", func(t *testing.T) {
|
|
t.Parallel()
|
|
testExpirePendingReleasesExpired(t, factory)
|
|
})
|
|
t.Run("ExpirePendingRegistrations skips future entries", func(t *testing.T) {
|
|
t.Parallel()
|
|
testExpirePendingSkipsFuture(t, factory)
|
|
})
|
|
t.Run("ExpirePendingRegistrations idempotent replay", func(t *testing.T) {
|
|
t.Parallel()
|
|
testExpirePendingIdempotent(t, factory)
|
|
})
|
|
|
|
t.Run("Register converts pending to registered", func(t *testing.T) {
|
|
t.Parallel()
|
|
testRegisterConverts(t, factory)
|
|
})
|
|
t.Run("Register idempotent on repeat", func(t *testing.T) {
|
|
t.Parallel()
|
|
testRegisterIdempotent(t, factory)
|
|
})
|
|
t.Run("Register rejects missing pending", func(t *testing.T) {
|
|
t.Parallel()
|
|
testRegisterMissingPending(t, factory)
|
|
})
|
|
t.Run("Register rejects expired pending", func(t *testing.T) {
|
|
t.Parallel()
|
|
testRegisterExpiredPending(t, factory)
|
|
})
|
|
|
|
t.Run("List methods partition correctly", func(t *testing.T) {
|
|
t.Parallel()
|
|
testListsPartition(t, factory)
|
|
})
|
|
|
|
t.Run("ReleaseAllByUser clears every kind", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseAllByUserClears(t, factory)
|
|
})
|
|
t.Run("ReleaseAllByUser leaves other users intact", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseAllByUserIsolated(t, factory)
|
|
})
|
|
t.Run("ReleaseAllByUser idempotent", func(t *testing.T) {
|
|
t.Parallel()
|
|
testReleaseAllByUserIdempotent(t, factory)
|
|
})
|
|
|
|
t.Run("Honors canceled context", func(t *testing.T) {
|
|
t.Parallel()
|
|
testContextCancellation(t, factory)
|
|
})
|
|
}
|
|
|
|
const (
|
|
raceNameA = "PilotNova"
|
|
raceNameB = "Vanguard"
|
|
gameA = "game-A"
|
|
gameB = "game-B"
|
|
userA = "user-A"
|
|
userB = "user-B"
|
|
)
|
|
|
|
// fixedClock returns a deterministic clock starting at instant and
|
|
// advanceable via the returned callbacks.
|
|
func fixedClock(instant time.Time) (now func() time.Time, advance func(delta time.Duration)) {
|
|
current := instant
|
|
now = func() time.Time { return current }
|
|
advance = func(delta time.Duration) { current = current.Add(delta) }
|
|
return now, advance
|
|
}
|
|
|
|
// baseTime pins a stable reference instant for deterministic timestamps
|
|
// across subtests.
|
|
func baseTime() time.Time {
|
|
return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func testCanonicalizeRejectsInvalid(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
|
|
for _, input := range []string{"", " ", "Pilot Nova", "-Pilot", "Pilot-"} {
|
|
_, err := directory.Canonicalize(input)
|
|
require.Error(t, err, "input %q must be rejected", input)
|
|
require.ErrorIs(t, err, ports.ErrInvalidName, "input %q should expose ErrInvalidName", input)
|
|
}
|
|
}
|
|
|
|
func testCanonicalizeDeterministic(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
|
|
first, err := directory.Canonicalize("PilotNova")
|
|
require.NoError(t, err)
|
|
second, err := directory.Canonicalize("pilotnova")
|
|
require.NoError(t, err)
|
|
require.Equal(t, first, second, "case variants must produce identical canonical keys")
|
|
|
|
confusable, err := directory.Canonicalize("P1l0tN0va")
|
|
require.NoError(t, err)
|
|
require.Equal(t, first, confusable, "anti-fraud confusables must collapse to the same canonical key")
|
|
}
|
|
|
|
func testCheckEmpty(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
|
|
availability, err := directory.Check(context.Background(), raceNameA, userA)
|
|
require.NoError(t, err)
|
|
assert.False(t, availability.Taken)
|
|
assert.Empty(t, availability.HolderUserID)
|
|
assert.Empty(t, availability.Kind)
|
|
}
|
|
|
|
func testCheckActorNotTaken(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
|
|
availability, err := directory.Check(ctx, raceNameA, userA)
|
|
require.NoError(t, err)
|
|
assert.False(t, availability.Taken)
|
|
assert.Equal(t, userA, availability.HolderUserID)
|
|
assert.Equal(t, ports.KindReservation, availability.Kind)
|
|
}
|
|
|
|
func testCheckHolderAndKind(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
|
|
availability, err := directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.True(t, availability.Taken)
|
|
assert.Equal(t, userA, availability.HolderUserID)
|
|
assert.Equal(t, ports.KindReservation, availability.Kind)
|
|
|
|
eligibleUntil := now().Add(30 * 24 * time.Hour)
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
|
|
|
|
availability, err = directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ports.KindPendingRegistration, availability.Kind)
|
|
|
|
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
|
|
|
|
availability, err = directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ports.KindRegistered, availability.Kind)
|
|
}
|
|
|
|
func testReserveRecords(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, reservations, 1)
|
|
assert.Equal(t, gameA, reservations[0].GameID)
|
|
assert.Equal(t, raceNameA, reservations[0].RaceName)
|
|
assert.Equal(t, now().UTC().UnixMilli(), reservations[0].ReservedAtMs)
|
|
}
|
|
|
|
func testReserveIdempotent(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, reservations, 1)
|
|
}
|
|
|
|
func testReserveCrossGame(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameA))
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, reservations, 2)
|
|
|
|
sort.Slice(reservations, func(i, j int) bool { return reservations[i].GameID < reservations[j].GameID })
|
|
assert.Equal(t, gameA, reservations[0].GameID)
|
|
assert.Equal(t, gameB, reservations[1].GameID)
|
|
}
|
|
|
|
func testReserveCrossUserSameGame(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
|
|
err := directory.Reserve(ctx, gameA, userB, raceNameA)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrNameTaken)
|
|
}
|
|
|
|
func testReserveCrossUserDifferentGames(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
|
|
err := directory.Reserve(ctx, gameB, userB, raceNameA)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrNameTaken)
|
|
}
|
|
|
|
func testReserveInvalidName(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
err := directory.Reserve(ctx, gameA, userA, " ")
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrInvalidName)
|
|
}
|
|
|
|
func testReleaseReservationMissing(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, raceNameA))
|
|
}
|
|
|
|
func testReleaseReservationWrongHolder(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userB, raceNameA))
|
|
|
|
availability, err := directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.True(t, availability.Taken)
|
|
assert.Equal(t, userA, availability.HolderUserID)
|
|
}
|
|
|
|
func testReleaseReservationClears(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, raceNameA))
|
|
|
|
availability, err := directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.False(t, availability.Taken)
|
|
assert.Empty(t, availability.HolderUserID)
|
|
assert.Empty(t, availability.Kind)
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, reservations)
|
|
}
|
|
|
|
func testReleaseReservationInvalidName(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, ""))
|
|
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, "Pilot Nova"))
|
|
}
|
|
|
|
func testReleaseReservationKeepsCrossGame(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameA))
|
|
|
|
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, raceNameA))
|
|
|
|
availability, err := directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.True(t, availability.Taken, "other users still blocked by the remaining game B reservation")
|
|
assert.Equal(t, userA, availability.HolderUserID)
|
|
assert.Equal(t, ports.KindReservation, availability.Kind)
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, reservations, 1)
|
|
assert.Equal(t, gameB, reservations[0].GameID)
|
|
}
|
|
|
|
func testMarkPendingPromotes(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
|
|
eligibleUntil := now().Add(30 * 24 * time.Hour)
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
|
|
|
|
pending, err := directory.ListPendingRegistrations(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, pending, 1)
|
|
assert.Equal(t, gameA, pending[0].GameID)
|
|
assert.Equal(t, eligibleUntil.UTC().UnixMilli(), pending[0].EligibleUntilMs)
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, reservations, "pending entries must not appear in ListReservations")
|
|
}
|
|
|
|
func testMarkPendingIdempotent(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
eligibleUntil := now().Add(24 * time.Hour)
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
|
|
|
|
pending, err := directory.ListPendingRegistrations(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, pending, 1)
|
|
}
|
|
|
|
func testMarkPendingDifferentEligible(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
eligibleUntil := now().Add(24 * time.Hour)
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
|
|
|
|
err := directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil.Add(time.Hour))
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrInvalidName)
|
|
}
|
|
|
|
func testMarkPendingMissing(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
err := directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour))
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func testExpirePendingEmpty(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
|
|
expired, err := directory.ExpirePendingRegistrations(context.Background(), time.Now())
|
|
require.NoError(t, err)
|
|
assert.Empty(t, expired)
|
|
}
|
|
|
|
func testExpirePendingReleasesExpired(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, advance := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
eligibleUntil := now().Add(time.Hour)
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
|
|
|
|
advance(2 * time.Hour)
|
|
expired, err := directory.ExpirePendingRegistrations(ctx, now())
|
|
require.NoError(t, err)
|
|
require.Len(t, expired, 1)
|
|
assert.Equal(t, userA, expired[0].UserID)
|
|
assert.Equal(t, gameA, expired[0].GameID)
|
|
assert.Equal(t, raceNameA, expired[0].RaceName)
|
|
assert.Equal(t, eligibleUntil.UTC().UnixMilli(), expired[0].EligibleUntilMs)
|
|
|
|
pending, err := directory.ListPendingRegistrations(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, pending)
|
|
|
|
availability, err := directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.False(t, availability.Taken)
|
|
}
|
|
|
|
func testExpirePendingSkipsFuture(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
eligibleUntil := now().Add(time.Hour)
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
|
|
|
|
expired, err := directory.ExpirePendingRegistrations(ctx, now())
|
|
require.NoError(t, err)
|
|
assert.Empty(t, expired)
|
|
}
|
|
|
|
func testExpirePendingIdempotent(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, advance := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
|
|
|
|
advance(2 * time.Hour)
|
|
expiredFirst, err := directory.ExpirePendingRegistrations(ctx, now())
|
|
require.NoError(t, err)
|
|
require.Len(t, expiredFirst, 1)
|
|
|
|
expiredSecond, err := directory.ExpirePendingRegistrations(ctx, now())
|
|
require.NoError(t, err)
|
|
assert.Empty(t, expiredSecond)
|
|
}
|
|
|
|
func testRegisterConverts(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
|
|
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
|
|
|
|
registered, err := directory.ListRegistered(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, registered, 1)
|
|
assert.Equal(t, gameA, registered[0].SourceGameID)
|
|
assert.Equal(t, raceNameA, registered[0].RaceName)
|
|
assert.Equal(t, now().UTC().UnixMilli(), registered[0].RegisteredAtMs)
|
|
|
|
pending, err := directory.ListPendingRegistrations(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, pending)
|
|
}
|
|
|
|
func testRegisterIdempotent(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
|
|
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
|
|
|
|
registered, err := directory.ListRegistered(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, registered, 1)
|
|
}
|
|
|
|
func testRegisterMissingPending(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
err := directory.Register(ctx, gameA, userA, raceNameA)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrPendingMissing)
|
|
|
|
// Reservation without pending is also missing pending.
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
err = directory.Register(ctx, gameA, userA, raceNameA)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrPendingMissing)
|
|
}
|
|
|
|
func testRegisterExpiredPending(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, advance := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
|
|
|
|
advance(2 * time.Hour)
|
|
err := directory.Register(ctx, gameA, userA, raceNameA)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrPendingExpired)
|
|
}
|
|
|
|
func testListsPartition(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
// userA: reserved in gameA (raceNameA), pending in gameB (raceNameB), registered raceNameA after convert.
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
|
|
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameB))
|
|
|
|
registered, err := directory.ListRegistered(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, registered, 1)
|
|
assert.Equal(t, raceNameA, registered[0].RaceName)
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Len(t, reservations, 1)
|
|
assert.Equal(t, raceNameB, reservations[0].RaceName)
|
|
assert.Equal(t, gameB, reservations[0].GameID)
|
|
|
|
pending, err := directory.ListPendingRegistrations(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, pending, "pending entry was converted to registered")
|
|
}
|
|
|
|
func testReleaseAllByUserClears(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
|
|
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameB))
|
|
require.NoError(t, directory.MarkPendingRegistration(ctx, gameB, userA, raceNameB, now().Add(2*time.Hour)))
|
|
|
|
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
|
|
|
|
registered, err := directory.ListRegistered(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, registered)
|
|
|
|
reservations, err := directory.ListReservations(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, reservations)
|
|
|
|
pending, err := directory.ListPendingRegistrations(ctx, userA)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, pending)
|
|
|
|
availabilityA, err := directory.Check(ctx, raceNameA, userB)
|
|
require.NoError(t, err)
|
|
assert.False(t, availabilityA.Taken)
|
|
|
|
availabilityB, err := directory.Check(ctx, raceNameB, userB)
|
|
require.NoError(t, err)
|
|
assert.False(t, availabilityB.Taken)
|
|
}
|
|
|
|
func testReleaseAllByUserIsolated(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
now, _ := fixedClock(baseTime())
|
|
directory := factory(now)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.Reserve(ctx, gameB, userB, raceNameB))
|
|
|
|
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
|
|
|
|
availability, err := directory.Check(ctx, raceNameB, userA)
|
|
require.NoError(t, err)
|
|
assert.True(t, availability.Taken)
|
|
assert.Equal(t, userB, availability.HolderUserID)
|
|
|
|
reservationsB, err := directory.ListReservations(ctx, userB)
|
|
require.NoError(t, err)
|
|
require.Len(t, reservationsB, 1)
|
|
}
|
|
|
|
func testReleaseAllByUserIdempotent(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
|
|
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
|
|
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
|
|
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
|
|
}
|
|
|
|
func testContextCancellation(t *testing.T, factory Factory) {
|
|
t.Helper()
|
|
directory := factory(nil)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
err := directory.Reserve(ctx, gameA, userA, raceNameA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled), "Reserve must surface context.Canceled, got %v", err)
|
|
|
|
_, err = directory.Check(ctx, raceNameA, userA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
err = directory.ReleaseReservation(ctx, gameA, userA, raceNameA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
err = directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, time.Now())
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
_, err = directory.ExpirePendingRegistrations(ctx, time.Now())
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
err = directory.Register(ctx, gameA, userA, raceNameA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
_, err = directory.ListRegistered(ctx, userA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
_, err = directory.ListReservations(ctx, userA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
_, err = directory.ListPendingRegistrations(ctx, userA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
err = directory.ReleaseAllByUser(ctx, userA)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
}
|