// 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)