599 lines
17 KiB
Go
599 lines
17 KiB
Go
// Package racenamestub provides the in-process implementation of the
|
|
// ports.RaceNameDirectory contract used by unit tests that do not need
|
|
// a Redis dependency. The stub enforces the full two-tier Race Name
|
|
// Directory invariants (registered, reservation, pending_registration)
|
|
// across the lifetime of one process, and is interchangeable with the
|
|
// Redis adapter under the same shared behavioural test suite.
|
|
package racenamestub
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/domain/racename"
|
|
"galaxy/lobby/internal/ports"
|
|
)
|
|
|
|
// Directory is the in-memory implementation of ports.RaceNameDirectory.
|
|
// The zero value is not usable; callers must construct instances with
|
|
// NewDirectory so the underlying data structures and policy are ready.
|
|
type Directory struct {
|
|
mu sync.Mutex
|
|
policy *racename.Policy
|
|
nowFn func() time.Time
|
|
registered map[racename.CanonicalKey]*registeredEntry
|
|
entries map[racename.CanonicalKey]*canonicalEntry
|
|
}
|
|
|
|
// Option tunes Directory construction. Options are evaluated in order.
|
|
type Option func(*Directory)
|
|
|
|
// WithClock overrides the default time.Now clock used to stamp
|
|
// reserved_at_ms and registered_at_ms. It is intended for deterministic
|
|
// tests.
|
|
func WithClock(nowFn func() time.Time) Option {
|
|
return func(directory *Directory) {
|
|
if nowFn != nil {
|
|
directory.nowFn = nowFn
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewDirectory constructs an empty in-memory Race Name Directory backed
|
|
// by its own freshly allocated racename.Policy. Returned instances are
|
|
// safe for concurrent use.
|
|
func NewDirectory(opts ...Option) (*Directory, error) {
|
|
policy, err := racename.NewPolicy()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new racename stub directory: %w", err)
|
|
}
|
|
|
|
directory := &Directory{
|
|
policy: policy,
|
|
nowFn: time.Now,
|
|
registered: make(map[racename.CanonicalKey]*registeredEntry),
|
|
entries: make(map[racename.CanonicalKey]*canonicalEntry),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(directory)
|
|
}
|
|
|
|
return directory, nil
|
|
}
|
|
|
|
// registeredEntry models one registered name owned by exactly one user.
|
|
type registeredEntry struct {
|
|
userID string
|
|
raceName string
|
|
sourceGameID string
|
|
registeredAtMs int64
|
|
}
|
|
|
|
// canonicalEntry groups the per-game reservations (including
|
|
// pending_registration ones) owned by the sole user bound to one
|
|
// canonical key.
|
|
type canonicalEntry struct {
|
|
holderUserID string
|
|
reservations map[string]*reservationEntry
|
|
}
|
|
|
|
// reservationEntry models one per-game reservation.
|
|
type reservationEntry struct {
|
|
raceName string
|
|
reservedAtMs int64
|
|
status string
|
|
eligibleUntilMs int64
|
|
hasEligibleUntil bool
|
|
}
|
|
|
|
const (
|
|
statusReserved = "reserved"
|
|
statusPending = "pending_registration"
|
|
)
|
|
|
|
// Canonicalize delegates to the racename policy and returns the
|
|
// canonical key as a plain string. Validation failures surface
|
|
// ports.ErrInvalidName for compatibility with the Redis adapter.
|
|
func (directory *Directory) Canonicalize(raceName string) (string, error) {
|
|
if directory == nil {
|
|
return "", errors.New("canonicalize race name: nil directory")
|
|
}
|
|
canonical, err := directory.policy.Canonicalize(raceName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("canonicalize race name: %w", ports.ErrInvalidName)
|
|
}
|
|
return canonical.String(), nil
|
|
}
|
|
|
|
// Check reports whether raceName is taken for actorUserID.
|
|
func (directory *Directory) Check(
|
|
ctx context.Context,
|
|
raceName, actorUserID string,
|
|
) (ports.Availability, error) {
|
|
if directory == nil {
|
|
return ports.Availability{}, errors.New("check race name: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "check race name"); err != nil {
|
|
return ports.Availability{}, err
|
|
}
|
|
actor, err := normalizeNonEmpty(actorUserID, "check race name", "actor user id")
|
|
if err != nil {
|
|
return ports.Availability{}, err
|
|
}
|
|
canonical, err := directory.policy.Canonicalize(raceName)
|
|
if err != nil {
|
|
return ports.Availability{}, fmt.Errorf("check race name: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
if registered, ok := directory.registered[canonical]; ok {
|
|
return ports.Availability{
|
|
Taken: registered.userID != actor,
|
|
HolderUserID: registered.userID,
|
|
Kind: ports.KindRegistered,
|
|
}, nil
|
|
}
|
|
entry, ok := directory.entries[canonical]
|
|
if !ok {
|
|
return ports.Availability{}, nil
|
|
}
|
|
kind := kindFromReservations(entry.reservations)
|
|
return ports.Availability{
|
|
Taken: entry.holderUserID != actor,
|
|
HolderUserID: entry.holderUserID,
|
|
Kind: kind,
|
|
}, nil
|
|
}
|
|
|
|
// Reserve claims raceName for (gameID, userID) per the port contract.
|
|
func (directory *Directory) Reserve(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("reserve race name: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "reserve race name"); err != nil {
|
|
return err
|
|
}
|
|
game, err := normalizeNonEmpty(gameID, "reserve race name", "game id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "reserve race name", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
displayName, err := racename.ValidateName(raceName)
|
|
if err != nil {
|
|
return fmt.Errorf("reserve race name: %w", ports.ErrInvalidName)
|
|
}
|
|
canonical, err := directory.policy.Canonical(displayName)
|
|
if err != nil {
|
|
return fmt.Errorf("reserve race name: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
if registered, ok := directory.registered[canonical]; ok && registered.userID != user {
|
|
return ports.ErrNameTaken
|
|
}
|
|
entry, ok := directory.entries[canonical]
|
|
if ok && entry.holderUserID != user {
|
|
return ports.ErrNameTaken
|
|
}
|
|
if !ok {
|
|
entry = &canonicalEntry{
|
|
holderUserID: user,
|
|
reservations: make(map[string]*reservationEntry),
|
|
}
|
|
directory.entries[canonical] = entry
|
|
}
|
|
if _, exists := entry.reservations[game]; exists {
|
|
return nil
|
|
}
|
|
entry.reservations[game] = &reservationEntry{
|
|
raceName: displayName,
|
|
reservedAtMs: directory.nowFn().UTC().UnixMilli(),
|
|
status: statusReserved,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReleaseReservation is a defensive no-op in the three cases described
|
|
// by the port contract.
|
|
func (directory *Directory) ReleaseReservation(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("release race name reservation: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "release race name reservation"); err != nil {
|
|
return err
|
|
}
|
|
game, err := normalizeNonEmpty(gameID, "release race name reservation", "game id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "release race name reservation", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
canonical, err := directory.policy.Canonicalize(raceName)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
entry, ok := directory.entries[canonical]
|
|
if !ok || entry.holderUserID != user {
|
|
return nil
|
|
}
|
|
if _, exists := entry.reservations[game]; !exists {
|
|
return nil
|
|
}
|
|
delete(entry.reservations, game)
|
|
if len(entry.reservations) == 0 {
|
|
delete(directory.entries, canonical)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkPendingRegistration promotes the reservation held for (gameID,
|
|
// userID) on raceName's canonical key to pending_registration status.
|
|
func (directory *Directory) MarkPendingRegistration(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
eligibleUntil time.Time,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("mark pending race name registration: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "mark pending race name registration"); err != nil {
|
|
return err
|
|
}
|
|
game, err := normalizeNonEmpty(gameID, "mark pending race name registration", "game id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "mark pending race name registration", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if eligibleUntil.IsZero() {
|
|
return fmt.Errorf("mark pending race name registration: eligible until must be set")
|
|
}
|
|
displayName, err := racename.ValidateName(raceName)
|
|
if err != nil {
|
|
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
|
|
}
|
|
canonical, err := directory.policy.Canonical(displayName)
|
|
if err != nil {
|
|
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
entry, ok := directory.entries[canonical]
|
|
if !ok || entry.holderUserID != user {
|
|
return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user)
|
|
}
|
|
reservation, ok := entry.reservations[game]
|
|
if !ok {
|
|
return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user)
|
|
}
|
|
eligibleUntilMs := eligibleUntil.UTC().UnixMilli()
|
|
if reservation.status == statusPending {
|
|
if !reservation.hasEligibleUntil || reservation.eligibleUntilMs != eligibleUntilMs {
|
|
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
|
|
}
|
|
return nil
|
|
}
|
|
reservation.status = statusPending
|
|
reservation.eligibleUntilMs = eligibleUntilMs
|
|
reservation.hasEligibleUntil = true
|
|
reservation.raceName = displayName
|
|
return nil
|
|
}
|
|
|
|
// ExpirePendingRegistrations releases every pending entry whose
|
|
// eligibleUntil is at or before now and returns the freed entries.
|
|
func (directory *Directory) ExpirePendingRegistrations(
|
|
ctx context.Context,
|
|
now time.Time,
|
|
) ([]ports.ExpiredPending, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("expire pending race name registrations: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "expire pending race name registrations"); err != nil {
|
|
return nil, err
|
|
}
|
|
cutoff := now.UTC().UnixMilli()
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
var expired []ports.ExpiredPending
|
|
for canonical, entry := range directory.entries {
|
|
for game, reservation := range entry.reservations {
|
|
if reservation.status != statusPending || !reservation.hasEligibleUntil {
|
|
continue
|
|
}
|
|
if reservation.eligibleUntilMs > cutoff {
|
|
continue
|
|
}
|
|
expired = append(expired, ports.ExpiredPending{
|
|
CanonicalKey: canonical.String(),
|
|
RaceName: reservation.raceName,
|
|
GameID: game,
|
|
UserID: entry.holderUserID,
|
|
EligibleUntilMs: reservation.eligibleUntilMs,
|
|
})
|
|
delete(entry.reservations, game)
|
|
}
|
|
if len(entry.reservations) == 0 {
|
|
delete(directory.entries, canonical)
|
|
}
|
|
}
|
|
return expired, nil
|
|
}
|
|
|
|
// Register converts the pending entry for (gameID, userID) on
|
|
// raceName's canonical key into a registered race name.
|
|
func (directory *Directory) Register(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("register race name: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "register race name"); err != nil {
|
|
return err
|
|
}
|
|
game, err := normalizeNonEmpty(gameID, "register race name", "game id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "register race name", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
displayName, err := racename.ValidateName(raceName)
|
|
if err != nil {
|
|
return fmt.Errorf("register race name: %w", ports.ErrInvalidName)
|
|
}
|
|
canonical, err := directory.policy.Canonical(displayName)
|
|
if err != nil {
|
|
return fmt.Errorf("register race name: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
if existing, ok := directory.registered[canonical]; ok {
|
|
if existing.userID == user {
|
|
return nil
|
|
}
|
|
return ports.ErrNameTaken
|
|
}
|
|
entry, ok := directory.entries[canonical]
|
|
if !ok || entry.holderUserID != user {
|
|
return ports.ErrPendingMissing
|
|
}
|
|
pending, ok := entry.reservations[game]
|
|
if !ok || pending.status != statusPending {
|
|
return ports.ErrPendingMissing
|
|
}
|
|
if !pending.hasEligibleUntil || pending.eligibleUntilMs <= directory.nowFn().UTC().UnixMilli() {
|
|
return ports.ErrPendingExpired
|
|
}
|
|
|
|
directory.registered[canonical] = ®isteredEntry{
|
|
userID: user,
|
|
raceName: displayName,
|
|
sourceGameID: game,
|
|
registeredAtMs: directory.nowFn().UTC().UnixMilli(),
|
|
}
|
|
delete(entry.reservations, game)
|
|
if len(entry.reservations) == 0 {
|
|
delete(directory.entries, canonical)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListRegistered returns every registered race name owned by userID.
|
|
func (directory *Directory) ListRegistered(
|
|
ctx context.Context,
|
|
userID string,
|
|
) ([]ports.RegisteredName, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("list registered race names: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "list registered race names"); err != nil {
|
|
return nil, err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "list registered race names", "user id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
var results []ports.RegisteredName
|
|
for canonical, registered := range directory.registered {
|
|
if registered.userID != user {
|
|
continue
|
|
}
|
|
results = append(results, ports.RegisteredName{
|
|
CanonicalKey: canonical.String(),
|
|
RaceName: registered.raceName,
|
|
SourceGameID: registered.sourceGameID,
|
|
RegisteredAtMs: registered.registeredAtMs,
|
|
})
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// ListPendingRegistrations returns every pending registration owned by
|
|
// userID.
|
|
func (directory *Directory) ListPendingRegistrations(
|
|
ctx context.Context,
|
|
userID string,
|
|
) ([]ports.PendingRegistration, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("list pending race name registrations: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "list pending race name registrations"); err != nil {
|
|
return nil, err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "list pending race name registrations", "user id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
var results []ports.PendingRegistration
|
|
for canonical, entry := range directory.entries {
|
|
if entry.holderUserID != user {
|
|
continue
|
|
}
|
|
for game, reservation := range entry.reservations {
|
|
if reservation.status != statusPending {
|
|
continue
|
|
}
|
|
results = append(results, ports.PendingRegistration{
|
|
CanonicalKey: canonical.String(),
|
|
RaceName: reservation.raceName,
|
|
GameID: game,
|
|
ReservedAtMs: reservation.reservedAtMs,
|
|
EligibleUntilMs: reservation.eligibleUntilMs,
|
|
})
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// ListReservations returns every active reservation owned by userID
|
|
// whose status has not yet been promoted to pending_registration.
|
|
func (directory *Directory) ListReservations(
|
|
ctx context.Context,
|
|
userID string,
|
|
) ([]ports.Reservation, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("list race name reservations: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "list race name reservations"); err != nil {
|
|
return nil, err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "list race name reservations", "user id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
var results []ports.Reservation
|
|
for canonical, entry := range directory.entries {
|
|
if entry.holderUserID != user {
|
|
continue
|
|
}
|
|
for game, reservation := range entry.reservations {
|
|
if reservation.status != statusReserved {
|
|
continue
|
|
}
|
|
results = append(results, ports.Reservation{
|
|
CanonicalKey: canonical.String(),
|
|
RaceName: reservation.raceName,
|
|
GameID: game,
|
|
ReservedAtMs: reservation.reservedAtMs,
|
|
})
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// ReleaseAllByUser clears every binding owned by userID atomically
|
|
// under the directory mutex.
|
|
func (directory *Directory) ReleaseAllByUser(
|
|
ctx context.Context,
|
|
userID string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("release all race names by user: nil directory")
|
|
}
|
|
if err := checkContext(ctx, "release all race names by user"); err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "release all race names by user", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
directory.mu.Lock()
|
|
defer directory.mu.Unlock()
|
|
|
|
for canonical, registered := range directory.registered {
|
|
if registered.userID == user {
|
|
delete(directory.registered, canonical)
|
|
}
|
|
}
|
|
for canonical, entry := range directory.entries {
|
|
if entry.holderUserID == user {
|
|
delete(directory.entries, canonical)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// kindFromReservations returns the strongest ports.Kind constant for a
|
|
// canonicalEntry's reservation set (pending_registration beats
|
|
// reservation).
|
|
func kindFromReservations(reservations map[string]*reservationEntry) string {
|
|
for _, reservation := range reservations {
|
|
if reservation.status == statusPending {
|
|
return ports.KindPendingRegistration
|
|
}
|
|
}
|
|
return ports.KindReservation
|
|
}
|
|
|
|
// checkContext rejects nil or already-canceled contexts so the stub
|
|
// surfaces cancellation identically to the Redis adapter.
|
|
func checkContext(ctx context.Context, operation string) error {
|
|
if ctx == nil {
|
|
return fmt.Errorf("%s: nil context", operation)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return fmt.Errorf("%s: %w", operation, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeNonEmpty trims value and rejects empty results with a
|
|
// descriptive error including operation and field names.
|
|
func normalizeNonEmpty(value, operation, field string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("%s: %s must not be empty", operation, field)
|
|
}
|
|
return trimmed, nil
|
|
}
|
|
|
|
// Ensure *Directory satisfies the port interface at compile time.
|
|
var _ ports.RaceNameDirectory = (*Directory)(nil)
|