feat: game lobby service
This commit is contained in:
@@ -0,0 +1,773 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user