feat: game lobby service
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
)
|
||||
|
||||
// ApplicationStore stores application records and their secondary indexes.
|
||||
// Adapters are responsible for maintaining the per-game set, per-user set,
|
||||
// and the single-active lookup key together with the record.
|
||||
type ApplicationStore interface {
|
||||
// Save persists a new submitted application record. The adapter must
|
||||
// enforce the single-active constraint — only one non-rejected
|
||||
// application per (applicant_user_id, game_id) pair may exist at a
|
||||
// time — and return application.ErrConflict if the constraint is
|
||||
// violated. Save rejects records whose status is not submitted.
|
||||
Save(ctx context.Context, record application.Application) error
|
||||
|
||||
// Get returns the record identified by applicationID. It returns
|
||||
// application.ErrNotFound when no record exists.
|
||||
Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error)
|
||||
|
||||
// GetByGame returns every application attached to gameID. The order
|
||||
// is adapter-defined; callers may reorder as needed.
|
||||
GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error)
|
||||
|
||||
// GetByUser returns every application submitted by applicantUserID.
|
||||
// The order is adapter-defined; callers may reorder as needed.
|
||||
GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error)
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap
|
||||
// fashion. The adapter must first call application.Transition to
|
||||
// reject invalid pairs without touching the store; on success it must
|
||||
// verify that the current status equals input.ExpectedFrom, update
|
||||
// the primary record, and clear the single-active lookup key when
|
||||
// transitioning to rejected. Adapters set DecidedAt to input.At.
|
||||
UpdateStatus(ctx context.Context, input UpdateApplicationStatusInput) error
|
||||
}
|
||||
|
||||
// UpdateApplicationStatusInput stores the arguments required to apply one
|
||||
// status transition through an ApplicationStore.
|
||||
type UpdateApplicationStatusInput struct {
|
||||
// ApplicationID identifies the record to mutate.
|
||||
ApplicationID common.ApplicationID
|
||||
|
||||
// ExpectedFrom stores the status the caller believes the record
|
||||
// currently has. A mismatch results in application.ErrConflict.
|
||||
ExpectedFrom application.Status
|
||||
|
||||
// To stores the destination status.
|
||||
To application.Status
|
||||
|
||||
// At stores the wall-clock used for DecidedAt.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a structurally valid status
|
||||
// transition request.
|
||||
func (input UpdateApplicationStatusInput) Validate() error {
|
||||
if err := input.ApplicationID.Validate(); err != nil {
|
||||
return fmt.Errorf("update application status: application id: %w", err)
|
||||
}
|
||||
if !input.ExpectedFrom.IsKnown() {
|
||||
return fmt.Errorf(
|
||||
"update application status: expected from status %q is unsupported",
|
||||
input.ExpectedFrom,
|
||||
)
|
||||
}
|
||||
if !input.To.IsKnown() {
|
||||
return fmt.Errorf(
|
||||
"update application status: to status %q is unsupported",
|
||||
input.To,
|
||||
)
|
||||
}
|
||||
if input.At.IsZero() {
|
||||
return fmt.Errorf("update application status: at must not be zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizedApplicantUserID trims surrounding whitespace so adapter
|
||||
// keyspace lookups match the form the domain persists.
|
||||
func NormalizedApplicantUserID(userID string) string {
|
||||
return strings.TrimSpace(userID)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
)
|
||||
|
||||
// EvaluationGuardStore stores the per-game «already evaluated» marker used
|
||||
// by capability evaluation to make replayed `game_finished` events
|
||||
// idempotent. reads the marker before any RND mutation: a marker
|
||||
// observed before mutation means the prior pass committed in full and the
|
||||
// current pass can return without side effects. A marker absent before
|
||||
// mutation means either a first attempt or a retry of an interrupted prior
|
||||
// attempt; in both cases the evaluator re-runs the idempotent mutations
|
||||
// and writes the marker only after every mutation, the stats cleanup, and
|
||||
// the post-commit work succeed.
|
||||
//
|
||||
// The two methods exist to express that read-then-write order: IsEvaluated
|
||||
// is a non-mutating check used early in the pass, and MarkEvaluated commits
|
||||
// the marker once the pass is fully complete. Implementations must persist
|
||||
// the marker durably; an in-process implementation is acceptable only for
|
||||
// tests because process restarts must observe the same marker state.
|
||||
type EvaluationGuardStore interface {
|
||||
// IsEvaluated reports whether gameID is already marked. The method
|
||||
// must not mutate state and must distinguish a missing marker from a
|
||||
// transport error.
|
||||
IsEvaluated(ctx context.Context, gameID common.GameID) (bool, error)
|
||||
|
||||
// MarkEvaluated records gameID as evaluated. Calling MarkEvaluated
|
||||
// twice for the same gameID is safe; the second call leaves the
|
||||
// marker untouched.
|
||||
MarkEvaluated(ctx context.Context, gameID common.GameID) error
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package ports defines the stable interfaces that connect Game Lobby
|
||||
// Service use cases to external state and external services.
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
)
|
||||
|
||||
// GameStore stores game records and their secondary indexes. Adapters are
|
||||
// responsible for maintaining the status index together with the record.
|
||||
type GameStore interface {
|
||||
// Save upserts record. It is used for draft creation and for
|
||||
// field-only edits. The adapter must rewrite the status secondary
|
||||
// index when the status field changes. Save does not apply the
|
||||
// domain transition gate; callers that intend a status transition
|
||||
// must use UpdateStatus instead.
|
||||
Save(ctx context.Context, record game.Game) error
|
||||
|
||||
// Get returns the record identified by gameID. It returns
|
||||
// game.ErrNotFound when no record exists.
|
||||
Get(ctx context.Context, gameID common.GameID) (game.Game, error)
|
||||
|
||||
// GetByStatus returns every record currently indexed under status.
|
||||
// The slice is ordered by the created-at score ascending; callers
|
||||
// may reorder as needed.
|
||||
GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error)
|
||||
|
||||
// CountByStatus returns the number of game records indexed under
|
||||
// each known status. The map carries one entry per game.Status from
|
||||
// game.AllStatuses, with zero counts for empty buckets. Telemetry
|
||||
// uses the result to emit the `lobby.active_games` observable gauge
|
||||
// without scanning record payloads.
|
||||
CountByStatus(ctx context.Context) (map[game.Status]int, error)
|
||||
|
||||
// GetByOwner returns every record whose OwnerUserID equals userID.
|
||||
// The order is adapter-defined; callers may reorder as needed. The
|
||||
// secondary index is maintained alongside the per-status index;
|
||||
// cascade-release callers consume it without touching the
|
||||
// status listings.
|
||||
GetByOwner(ctx context.Context, userID string) ([]game.Game, error)
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap
|
||||
// fashion. The adapter must first call game.Transition to reject
|
||||
// invalid triplets without touching the store; on success it must
|
||||
// verify that the current status equals input.ExpectedFrom, update
|
||||
// the primary record, and rewrite the status secondary index.
|
||||
// Adapters set StartedAt when transitioning to running and
|
||||
// FinishedAt when transitioning to finished.
|
||||
UpdateStatus(ctx context.Context, input UpdateStatusInput) error
|
||||
|
||||
// UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot
|
||||
// fields on the record identified by input.GameID. It does not
|
||||
// mutate the status field or the status secondary index.
|
||||
UpdateRuntimeSnapshot(ctx context.Context, input UpdateRuntimeSnapshotInput) error
|
||||
|
||||
// UpdateRuntimeBinding overwrites the runtime binding metadata on the
|
||||
// record identified by input.GameID. It does not mutate the status
|
||||
// field or the status secondary index. The binding must satisfy the
|
||||
// domain invariants (see game.RuntimeBinding.Validate). The adapter
|
||||
// returns game.ErrNotFound when no record exists.
|
||||
UpdateRuntimeBinding(ctx context.Context, input UpdateRuntimeBindingInput) error
|
||||
}
|
||||
|
||||
// UpdateStatusInput stores the arguments required to apply one status
|
||||
// transition through a GameStore.
|
||||
type UpdateStatusInput struct {
|
||||
// GameID identifies the record to mutate.
|
||||
GameID common.GameID
|
||||
|
||||
// ExpectedFrom stores the status the caller believes the record
|
||||
// currently has. A mismatch results in game.ErrConflict.
|
||||
ExpectedFrom game.Status
|
||||
|
||||
// To stores the destination status.
|
||||
To game.Status
|
||||
|
||||
// Trigger stores the transition trigger used by the domain gate.
|
||||
Trigger game.Trigger
|
||||
|
||||
// At stores the wall-clock used for UpdatedAt, and for StartedAt or
|
||||
// FinishedAt when the destination status requires it.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a structurally valid status
|
||||
// transition request.
|
||||
func (input UpdateStatusInput) Validate() error {
|
||||
if err := input.GameID.Validate(); err != nil {
|
||||
return fmt.Errorf("update status: game id: %w", err)
|
||||
}
|
||||
if !input.ExpectedFrom.IsKnown() {
|
||||
return fmt.Errorf("update status: expected from status %q is unsupported", input.ExpectedFrom)
|
||||
}
|
||||
if !input.To.IsKnown() {
|
||||
return fmt.Errorf("update status: to status %q is unsupported", input.To)
|
||||
}
|
||||
if !input.Trigger.IsKnown() {
|
||||
return fmt.Errorf("update status: trigger %q is unsupported", input.Trigger)
|
||||
}
|
||||
if input.At.IsZero() {
|
||||
return fmt.Errorf("update status: at must not be zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRuntimeSnapshotInput stores the arguments required to update the
|
||||
// denormalized runtime snapshot on one game record.
|
||||
type UpdateRuntimeSnapshotInput struct {
|
||||
// GameID identifies the record to mutate.
|
||||
GameID common.GameID
|
||||
|
||||
// Snapshot stores the new snapshot values to persist.
|
||||
Snapshot game.RuntimeSnapshot
|
||||
|
||||
// At stores the wall-clock used for UpdatedAt.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a structurally valid runtime
|
||||
// snapshot update request.
|
||||
func (input UpdateRuntimeSnapshotInput) Validate() error {
|
||||
if err := input.GameID.Validate(); err != nil {
|
||||
return fmt.Errorf("update runtime snapshot: game id: %w", err)
|
||||
}
|
||||
if input.Snapshot.CurrentTurn < 0 {
|
||||
return fmt.Errorf("update runtime snapshot: current turn must not be negative")
|
||||
}
|
||||
if input.At.IsZero() {
|
||||
return fmt.Errorf("update runtime snapshot: at must not be zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRuntimeBindingInput stores the arguments required to persist
|
||||
// runtime binding metadata on one game record.
|
||||
type UpdateRuntimeBindingInput struct {
|
||||
// GameID identifies the record to mutate.
|
||||
GameID common.GameID
|
||||
|
||||
// Binding stores the runtime binding values to persist. The adapter
|
||||
// validates the binding before writing.
|
||||
Binding game.RuntimeBinding
|
||||
|
||||
// At stores the wall-clock used for UpdatedAt.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a structurally valid runtime
|
||||
// binding update request.
|
||||
func (input UpdateRuntimeBindingInput) Validate() error {
|
||||
if err := input.GameID.Validate(); err != nil {
|
||||
return fmt.Errorf("update runtime binding: game id: %w", err)
|
||||
}
|
||||
if err := input.Binding.Validate(); err != nil {
|
||||
return fmt.Errorf("update runtime binding: %w", err)
|
||||
}
|
||||
if input.At.IsZero() {
|
||||
return fmt.Errorf("update runtime binding: at must not be zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
)
|
||||
|
||||
// PlayerObservedStats stores one decoded `player_turn_stats` element from a
|
||||
// Game Master event. The fields mirror the wire format documented in
|
||||
// lobby/README.md §Runtime Snapshot. Counts are non-negative cumulative
|
||||
// totals reported by the engine for the named user at the time the event
|
||||
// was emitted.
|
||||
type PlayerObservedStats struct {
|
||||
// UserID identifies the platform user the stats line belongs to.
|
||||
UserID string
|
||||
|
||||
// Planets stores the cumulative number of planets controlled by the
|
||||
// user at observation time.
|
||||
Planets int64
|
||||
|
||||
// Population stores the cumulative population owned by the user at
|
||||
// observation time.
|
||||
Population int64
|
||||
|
||||
// ShipsBuilt stores the cumulative ships-built counter for the user
|
||||
// at observation time.
|
||||
ShipsBuilt int64
|
||||
}
|
||||
|
||||
// Validate reports whether stats contain the structural invariants required
|
||||
// by GameTurnStatsStore methods.
|
||||
func (stats PlayerObservedStats) Validate() error {
|
||||
if strings.TrimSpace(stats.UserID) == "" {
|
||||
return fmt.Errorf("player turn stats: user id must not be empty")
|
||||
}
|
||||
if stats.Planets < 0 {
|
||||
return fmt.Errorf("player turn stats: planets must not be negative")
|
||||
}
|
||||
if stats.Population < 0 {
|
||||
return fmt.Errorf("player turn stats: population must not be negative")
|
||||
}
|
||||
if stats.ShipsBuilt < 0 {
|
||||
return fmt.Errorf("player turn stats: ships built must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlayerInitialStats is an alias for PlayerObservedStats. Game Lobby
|
||||
// Service freezes the first observation per user as that user's initial
|
||||
// values; the wire format is identical, only the persistence semantics
|
||||
// differ.
|
||||
type PlayerInitialStats = PlayerObservedStats
|
||||
|
||||
// PlayerStatsAggregate stores the per-user aggregate maintained by
|
||||
// GameTurnStatsStore for one game. Initial fields are frozen at the first
|
||||
// SaveInitial call for the user; max fields are updated by per-component
|
||||
// maximum on each UpdateMax call.
|
||||
type PlayerStatsAggregate struct {
|
||||
// UserID identifies the platform user the aggregate belongs to.
|
||||
UserID string
|
||||
|
||||
// InitialPlanets stores the user's planets count from the first
|
||||
// observation, frozen for the lifetime of the game.
|
||||
InitialPlanets int64
|
||||
|
||||
// InitialPopulation stores the user's population from the first
|
||||
// observation, frozen for the lifetime of the game.
|
||||
InitialPopulation int64
|
||||
|
||||
// InitialShipsBuilt stores the user's ships-built counter from the
|
||||
// first observation, frozen for the lifetime of the game.
|
||||
InitialShipsBuilt int64
|
||||
|
||||
// MaxPlanets stores the running maximum planets count observed for
|
||||
// the user across the lifetime of the game.
|
||||
MaxPlanets int64
|
||||
|
||||
// MaxPopulation stores the running maximum population observed for
|
||||
// the user across the lifetime of the game.
|
||||
MaxPopulation int64
|
||||
|
||||
// MaxShipsBuilt stores the running maximum ships-built counter
|
||||
// observed for the user across the lifetime of the game.
|
||||
MaxShipsBuilt int64
|
||||
}
|
||||
|
||||
// GameTurnStatsAggregate stores every PlayerStatsAggregate for one game.
|
||||
// The slice is empty when no SaveInitial call has succeeded yet.
|
||||
type GameTurnStatsAggregate struct {
|
||||
// GameID identifies the game the aggregate belongs to.
|
||||
GameID common.GameID
|
||||
|
||||
// Players stores the per-user aggregates ordered by UserID ascending.
|
||||
Players []PlayerStatsAggregate
|
||||
}
|
||||
|
||||
// GameTurnStatsStore stores per-game per-user initial and running-maximum
|
||||
// stats derived from Game Master `runtime_snapshot_update` events. The
|
||||
// aggregate is read once by capability evaluation at game finish and then
|
||||
// deleted (see lobby/README.md §Runtime Snapshot).
|
||||
//
|
||||
// Adapters must keep the initial fields frozen against later SaveInitial
|
||||
// calls and must keep the max fields monotonically non-decreasing on every
|
||||
// UpdateMax call. All methods are safe to call concurrently for the same
|
||||
// gameID.
|
||||
type GameTurnStatsStore interface {
|
||||
// SaveInitial freezes the initial fields for every user in stats.
|
||||
// Subsequent calls for the same (gameID, user_id) tuple do not
|
||||
// overwrite the stored initial values; they return nil so workers
|
||||
// may call SaveInitial on every received event without bookkeeping.
|
||||
// The first call for a user also primes the max fields with the same
|
||||
// values so a single observation is reflected by both the initial
|
||||
// and the running maximum.
|
||||
SaveInitial(ctx context.Context, gameID common.GameID, stats []PlayerInitialStats) error
|
||||
|
||||
// UpdateMax updates the max fields for every user in stats by
|
||||
// per-component maximum. Existing aggregate entries below the new
|
||||
// observation are raised; existing entries at or above the new
|
||||
// observation are left unchanged. Calling UpdateMax for a user that
|
||||
// has no aggregate entry yet creates one whose initial fields and
|
||||
// max fields both equal the observation, so callers may safely call
|
||||
// UpdateMax without first calling SaveInitial.
|
||||
UpdateMax(ctx context.Context, gameID common.GameID, stats []PlayerObservedStats) error
|
||||
|
||||
// Load returns the GameTurnStatsAggregate stored for gameID. The
|
||||
// aggregate is empty (Players length zero) when no observation has
|
||||
// been recorded yet; the GameID is always populated. The returned
|
||||
// Players slice is ordered by UserID ascending so capability
|
||||
// evaluation produces deterministic side-effect order on replay.
|
||||
Load(ctx context.Context, gameID common.GameID) (GameTurnStatsAggregate, error)
|
||||
|
||||
// Delete removes every aggregate entry for gameID. The call is a
|
||||
// no-op when no entries exist; no error is returned in that case.
|
||||
Delete(ctx context.Context, gameID common.GameID) error
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
)
|
||||
|
||||
// GapActivationStore records the moment a game's gap window opens —
|
||||
// when approved_count first reaches max_players. The enrollment
|
||||
// automation worker reads the timestamp to decide when start_gap_hours
|
||||
// have elapsed; approveapplication and redeeminvite are the writers.
|
||||
type GapActivationStore interface {
|
||||
// MarkActivated records at as the gap window activation time for
|
||||
// gameID iff no prior activation exists. A second call for the same
|
||||
// game is a silent no-op so retries after partial failures stay
|
||||
// safe. Implementations use SETNX semantics under the hood.
|
||||
MarkActivated(ctx context.Context, gameID common.GameID, at time.Time) error
|
||||
|
||||
// Get returns the previously recorded gap-window activation time for
|
||||
// gameID. The second return value is false when no activation has
|
||||
// been recorded yet; in that case the time.Time is the zero value.
|
||||
// An error is returned only for technical failures.
|
||||
Get(ctx context.Context, gameID common.GameID) (time.Time, bool, error)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
)
|
||||
|
||||
// ErrGMUnavailable signals that a Game Master call could not be
|
||||
// completed because the upstream service was unreachable, returned an
|
||||
// error response, or timed out. treats every non-success
|
||||
// outcome of GMClient.RegisterGame uniformly: the start flow transitions
|
||||
// to `paused` and an admin notification is published.
|
||||
var ErrGMUnavailable = errors.New("game master unavailable")
|
||||
|
||||
// GMClient executes synchronous calls to Game Master. introduced
|
||||
// the registration call; added the liveness probe used by the
|
||||
// voluntary resume flow.
|
||||
type GMClient interface {
|
||||
// RegisterGame registers a running game with Game Master after a
|
||||
// successful container start and runtime binding persistence. A
|
||||
// non-nil error is returned for any non-success outcome (transport
|
||||
// error, timeout, non-2xx response). Implementations wrap such
|
||||
// failures with ErrGMUnavailable so callers can branch with
|
||||
// errors.Is.
|
||||
RegisterGame(ctx context.Context, request RegisterGameRequest) error
|
||||
|
||||
// Ping performs a synchronous liveness probe against Game Master.
|
||||
// Implementations return nil when GM is reachable and healthy and
|
||||
// wrap every other outcome (transport error, timeout, non-2xx
|
||||
// response) with ErrGMUnavailable so callers can
|
||||
// branch with errors.Is.
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// RegisterGameRequest stores the parameters required to register one
|
||||
// running game with Game Master. The shape mirrors the JSON body sent
|
||||
// by the HTTP adapter and is independent of the GM-side schema so the
|
||||
// adapter and the consumer can reason about it without leaking
|
||||
// transport details.
|
||||
type RegisterGameRequest struct {
|
||||
// GameID identifies the running game.
|
||||
GameID common.GameID
|
||||
|
||||
// ContainerID identifies the engine container assigned by Runtime
|
||||
// Manager.
|
||||
ContainerID string
|
||||
|
||||
// EngineEndpoint stores the network address Game Master uses to
|
||||
// reach the engine container.
|
||||
EngineEndpoint string
|
||||
|
||||
// TargetEngineVersion stores the semver of the engine version that
|
||||
// was launched (copied from the game record at registration time).
|
||||
TargetEngineVersion string
|
||||
|
||||
// TurnSchedule stores the cron expression that drives turn
|
||||
// generation (copied from the game record at registration time).
|
||||
TurnSchedule string
|
||||
}
|
||||
|
||||
// Validate reports whether request stores the structurally valid
|
||||
// arguments required for Game Master registration.
|
||||
func (request RegisterGameRequest) Validate() error {
|
||||
if err := request.GameID.Validate(); err != nil {
|
||||
return fmt.Errorf("register game: game id: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(request.ContainerID) == "" {
|
||||
return fmt.Errorf("register game: container id must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(request.EngineEndpoint) == "" {
|
||||
return fmt.Errorf("register game: engine endpoint must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(request.TargetEngineVersion) == "" {
|
||||
return fmt.Errorf("register game: target engine version must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(request.TurnSchedule) == "" {
|
||||
return fmt.Errorf("register game: turn schedule must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ports
|
||||
|
||||
import "galaxy/lobby/internal/domain/common"
|
||||
|
||||
// IDGenerator creates new stable identifiers for Game Lobby records.
|
||||
//
|
||||
// introduced NewGameID. added NewApplicationID and
|
||||
// NewMembershipID. extends the interface with NewInviteID.
|
||||
type IDGenerator interface {
|
||||
// NewGameID returns one newly generated opaque game identifier. The
|
||||
// returned value carries the frozen `game-*` prefix and passes
|
||||
// common.GameID.Validate.
|
||||
NewGameID() (common.GameID, error)
|
||||
|
||||
// NewApplicationID returns one newly generated opaque application
|
||||
// identifier. The returned value carries the frozen `application-*`
|
||||
// prefix and passes common.ApplicationID.Validate.
|
||||
NewApplicationID() (common.ApplicationID, error)
|
||||
|
||||
// NewInviteID returns one newly generated opaque invite identifier.
|
||||
// The returned value carries the frozen `invite-*` prefix and passes
|
||||
// common.InviteID.Validate.
|
||||
NewInviteID() (common.InviteID, error)
|
||||
|
||||
// NewMembershipID returns one newly generated opaque membership
|
||||
// identifier. The returned value carries the frozen `membership-*`
|
||||
// prefix and passes common.MembershipID.Validate.
|
||||
NewMembershipID() (common.MembershipID, error)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/notificationintent"
|
||||
)
|
||||
|
||||
// IntentPublisher is the lobby-facing producer port for normalized
|
||||
// notification intents. The production adapter is a
|
||||
// *notificationintent.Publisher which already satisfies this interface;
|
||||
// service tests use an in-process stub that records every Publish call.
|
||||
//
|
||||
// A failed Publish call is a notification degradation per
|
||||
// lobby/README.md §Notification Contracts and must not roll back already
|
||||
// committed business state. Callers log the error and proceed.
|
||||
type IntentPublisher interface {
|
||||
// Publish normalizes intent and appends it to the configured Redis
|
||||
// Stream, returning the stream entry id on success. Validation
|
||||
// failures and transport errors are returned verbatim.
|
||||
Publish(ctx context.Context, intent notificationintent.Intent) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
)
|
||||
|
||||
// InviteStore stores invite records and their secondary indexes. Adapters
|
||||
// are responsible for maintaining the per-game set and per-invitee set
|
||||
// together with the record.
|
||||
type InviteStore interface {
|
||||
// Save persists a new created invite record. Save rejects records
|
||||
// whose status is not created and is create-only: re-saving an
|
||||
// existing invite id returns invite.ErrConflict.
|
||||
Save(ctx context.Context, record invite.Invite) error
|
||||
|
||||
// Get returns the record identified by inviteID. It returns
|
||||
// invite.ErrNotFound when no record exists.
|
||||
Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error)
|
||||
|
||||
// GetByGame returns every invite attached to gameID. The order is
|
||||
// adapter-defined; callers may reorder as needed.
|
||||
GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error)
|
||||
|
||||
// GetByUser returns every invite addressed to inviteeUserID. The
|
||||
// order is adapter-defined; callers may reorder as needed.
|
||||
GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error)
|
||||
|
||||
// GetByInviter returns every invite created by inviterUserID. The
|
||||
// order is adapter-defined; callers may reorder as needed. The
|
||||
// secondary index is maintained alongside the per-game and per-user
|
||||
// indexes; cascade-release callers read it without touching
|
||||
// the per-game listings.
|
||||
GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error)
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap
|
||||
// fashion. The adapter must first call invite.Transition to reject
|
||||
// invalid pairs without touching the store; on success it must
|
||||
// verify that the current status equals input.ExpectedFrom, update
|
||||
// the primary record, set DecidedAt to input.At, and apply
|
||||
// input.RaceName when transitioning to redeemed. input.RaceName must
|
||||
// be non-empty when To is redeemed and empty otherwise.
|
||||
UpdateStatus(ctx context.Context, input UpdateInviteStatusInput) error
|
||||
}
|
||||
|
||||
// UpdateInviteStatusInput stores the arguments required to apply one
|
||||
// status transition through an InviteStore.
|
||||
type UpdateInviteStatusInput struct {
|
||||
// InviteID identifies the record to mutate.
|
||||
InviteID common.InviteID
|
||||
|
||||
// ExpectedFrom stores the status the caller believes the record
|
||||
// currently has. A mismatch results in invite.ErrConflict.
|
||||
ExpectedFrom invite.Status
|
||||
|
||||
// To stores the destination status.
|
||||
To invite.Status
|
||||
|
||||
// At stores the wall-clock used for DecidedAt.
|
||||
At time.Time
|
||||
|
||||
// RaceName carries the invitee's confirmed in-game name. It is
|
||||
// required when To is redeemed and must be empty otherwise.
|
||||
RaceName string
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a structurally valid status
|
||||
// transition request.
|
||||
func (input UpdateInviteStatusInput) Validate() error {
|
||||
if err := input.InviteID.Validate(); err != nil {
|
||||
return fmt.Errorf("update invite status: invite id: %w", err)
|
||||
}
|
||||
if !input.ExpectedFrom.IsKnown() {
|
||||
return fmt.Errorf(
|
||||
"update invite status: expected from status %q is unsupported",
|
||||
input.ExpectedFrom,
|
||||
)
|
||||
}
|
||||
if !input.To.IsKnown() {
|
||||
return fmt.Errorf(
|
||||
"update invite status: to status %q is unsupported",
|
||||
input.To,
|
||||
)
|
||||
}
|
||||
if input.At.IsZero() {
|
||||
return fmt.Errorf("update invite status: at must not be zero")
|
||||
}
|
||||
if input.To == invite.StatusRedeemed {
|
||||
if strings.TrimSpace(input.RaceName) == "" {
|
||||
return fmt.Errorf(
|
||||
"update invite status: race name must not be empty when redeeming",
|
||||
)
|
||||
}
|
||||
} else if input.RaceName != "" {
|
||||
return fmt.Errorf(
|
||||
"update invite status: race name must be empty when transitioning to %q",
|
||||
input.To,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
)
|
||||
|
||||
// MembershipStore stores membership records and their secondary indexes.
|
||||
// Adapters are responsible for maintaining the per-game set and per-user
|
||||
// set together with the record.
|
||||
type MembershipStore interface {
|
||||
// Save persists a new active membership record. Save rejects records
|
||||
// whose status is not active and is create-only: re-saving an
|
||||
// existing membership id returns membership.ErrConflict.
|
||||
Save(ctx context.Context, record membership.Membership) error
|
||||
|
||||
// Get returns the record identified by membershipID. It returns
|
||||
// membership.ErrNotFound when no record exists.
|
||||
Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error)
|
||||
|
||||
// GetByGame returns every membership attached to gameID. The order
|
||||
// is adapter-defined; callers may reorder as needed.
|
||||
GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error)
|
||||
|
||||
// GetByUser returns every membership held by userID. The order is
|
||||
// adapter-defined; callers may reorder as needed.
|
||||
GetByUser(ctx context.Context, userID string) ([]membership.Membership, error)
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap
|
||||
// fashion. The adapter must first call membership.Transition to
|
||||
// reject invalid pairs without touching the store; on success it
|
||||
// must verify that the current status equals input.ExpectedFrom,
|
||||
// update the primary record, and set RemovedAt to input.At when
|
||||
// transitioning out of active.
|
||||
UpdateStatus(ctx context.Context, input UpdateMembershipStatusInput) error
|
||||
|
||||
// Delete removes the membership record identified by membershipID
|
||||
// from the primary store and from the per-game and per-user
|
||||
// secondary index sets in one operation. Delete is the pre-start
|
||||
// path of removemember; the post-start path uses
|
||||
// UpdateStatus(active → removed). Delete returns
|
||||
// membership.ErrNotFound when no record exists for the id.
|
||||
Delete(ctx context.Context, membershipID common.MembershipID) error
|
||||
}
|
||||
|
||||
// UpdateMembershipStatusInput stores the arguments required to apply one
|
||||
// status transition through a MembershipStore.
|
||||
type UpdateMembershipStatusInput struct {
|
||||
// MembershipID identifies the record to mutate.
|
||||
MembershipID common.MembershipID
|
||||
|
||||
// ExpectedFrom stores the status the caller believes the record
|
||||
// currently has. A mismatch results in membership.ErrConflict.
|
||||
ExpectedFrom membership.Status
|
||||
|
||||
// To stores the destination status.
|
||||
To membership.Status
|
||||
|
||||
// At stores the wall-clock used for RemovedAt.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a structurally valid status
|
||||
// transition request.
|
||||
func (input UpdateMembershipStatusInput) Validate() error {
|
||||
if err := input.MembershipID.Validate(); err != nil {
|
||||
return fmt.Errorf("update membership status: membership id: %w", err)
|
||||
}
|
||||
if !input.ExpectedFrom.IsKnown() {
|
||||
return fmt.Errorf(
|
||||
"update membership status: expected from status %q is unsupported",
|
||||
input.ExpectedFrom,
|
||||
)
|
||||
}
|
||||
if !input.To.IsKnown() {
|
||||
return fmt.Errorf(
|
||||
"update membership status: to status %q is unsupported",
|
||||
input.To,
|
||||
)
|
||||
}
|
||||
if input.At.IsZero() {
|
||||
return fmt.Errorf("update membership status: at must not be zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind classifiers for RaceNameDirectory bindings.
|
||||
const (
|
||||
// KindRegistered identifies a permanent user-owned race name in the
|
||||
// Race Name Directory.
|
||||
KindRegistered = "registered"
|
||||
|
||||
// KindReservation identifies a per-game race name binding created at
|
||||
// application approval or invite redeem.
|
||||
KindReservation = "reservation"
|
||||
|
||||
// KindPendingRegistration identifies a reservation that survived a
|
||||
// capable game finish and is now waiting for lobby.race_name.register
|
||||
// within its 30-day window.
|
||||
KindPendingRegistration = "pending_registration"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by RaceNameDirectory implementations. Callers
|
||||
// map them to stable lobby error codes (name_taken, invalid_request,
|
||||
// race_name_pending_window_expired, race_name_registration_quota_exceeded).
|
||||
var (
|
||||
// ErrNameTaken is returned when raceName is already held by a different
|
||||
// user as registered, active reservation, or pending_registration on
|
||||
// the same canonical key.
|
||||
ErrNameTaken = errors.New("race name is already taken")
|
||||
|
||||
// ErrInvalidName is returned when raceName fails character-set
|
||||
// validation, when the canonical key cannot be derived, or when a
|
||||
// subsequent MarkPendingRegistration is invoked with a different
|
||||
// eligibleUntil than the one already stored.
|
||||
ErrInvalidName = errors.New("race name is invalid")
|
||||
|
||||
// ErrPendingMissing is returned by Register when no pending
|
||||
// registration exists for the supplied (game_id, user_id, race_name)
|
||||
// tuple.
|
||||
ErrPendingMissing = errors.New("pending race-name registration missing")
|
||||
|
||||
// ErrPendingExpired is returned by Register when the pending
|
||||
// registration exists but its eligible_until has passed.
|
||||
ErrPendingExpired = errors.New("pending race-name registration expired")
|
||||
|
||||
// ErrQuotaExceeded is reserved for callers that enforce the
|
||||
// max_registered_race_names limit before invoking Register; directory
|
||||
// implementations do not raise it directly.
|
||||
ErrQuotaExceeded = errors.New("race name registration quota exceeded")
|
||||
)
|
||||
|
||||
// Availability reports whether raceName is taken for the acting user and,
|
||||
// when taken, who holds it and under which kind of binding.
|
||||
type Availability struct {
|
||||
// Taken is true when any binding on the canonical key belongs to a
|
||||
// user different from the actor.
|
||||
Taken bool
|
||||
|
||||
// HolderUserID carries the owning user id of the strongest existing
|
||||
// binding on the canonical key, or the empty string when no binding
|
||||
// exists.
|
||||
HolderUserID string
|
||||
|
||||
// Kind identifies the strongest existing binding on the canonical key
|
||||
// as one of KindRegistered, KindPendingRegistration, KindReservation,
|
||||
// or the empty string when no binding exists.
|
||||
Kind string
|
||||
}
|
||||
|
||||
// RegisteredName describes one registered race name owned by a user.
|
||||
type RegisteredName struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// SourceGameID is the game whose capable finish produced the pending
|
||||
// entry that was converted into this registration.
|
||||
SourceGameID string
|
||||
|
||||
// RegisteredAtMs is the Unix-milliseconds timestamp of the successful
|
||||
// Register call.
|
||||
RegisteredAtMs int64
|
||||
}
|
||||
|
||||
// Reservation describes one active per-game race name reservation owned by
|
||||
// a user. Reservations with status pending_registration are returned by
|
||||
// ListPendingRegistrations instead.
|
||||
type Reservation struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// GameID is the game hosting the reservation.
|
||||
GameID string
|
||||
|
||||
// ReservedAtMs is the Unix-milliseconds timestamp of the first
|
||||
// successful Reserve call for the tuple.
|
||||
ReservedAtMs int64
|
||||
}
|
||||
|
||||
// PendingRegistration describes one reservation that was promoted to
|
||||
// pending_registration at a capable game finish.
|
||||
type PendingRegistration struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// GameID is the source game whose capable finish produced the pending
|
||||
// entry.
|
||||
GameID string
|
||||
|
||||
// ReservedAtMs is the Unix-milliseconds timestamp of the original
|
||||
// Reserve call; it is preserved across the pending-registration
|
||||
// promotion.
|
||||
ReservedAtMs int64
|
||||
|
||||
// EligibleUntilMs is the Unix-milliseconds deadline for an eligible
|
||||
// Register call. After this moment ExpirePendingRegistrations releases
|
||||
// the entry.
|
||||
EligibleUntilMs int64
|
||||
}
|
||||
|
||||
// ExpiredPending describes one pending registration released by a single
|
||||
// ExpirePendingRegistrations pass. The slice lets callers emit telemetry
|
||||
// and cascade release side effects without a second round-trip.
|
||||
type ExpiredPending struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// GameID is the source game that produced the pending entry.
|
||||
GameID string
|
||||
|
||||
// UserID is the holder of the released pending entry.
|
||||
UserID string
|
||||
|
||||
// EligibleUntilMs is the Unix-milliseconds deadline that lapsed.
|
||||
EligibleUntilMs int64
|
||||
}
|
||||
|
||||
// RaceNameDirectory is the platform source of truth for in-game race_name
|
||||
// values across three levels of state: registered (permanent, one per
|
||||
// user), reservation (per-game holding), and pending_registration (30-day
|
||||
// post-capable-finish window). It owns canonical-key derivation through
|
||||
// the lobby/internal/domain/racename policy and arbitrates platform-wide
|
||||
// uniqueness so that a name considered taken for one user remains
|
||||
// exclusively bound to that user across registered, active reservations,
|
||||
// and pending registrations sharing the same canonical key.
|
||||
//
|
||||
// One user may hold the same canonical key concurrently across multiple
|
||||
// active games; cross-user conflicts surface as ErrNameTaken.
|
||||
type RaceNameDirectory interface {
|
||||
// Canonicalize normalizes raceName through the lobby RND policy
|
||||
// (character-set validation, Unicode case fold, frozen confusable-pair
|
||||
// map). Invalid raceName values surface as ErrInvalidName.
|
||||
Canonicalize(raceName string) (canonical string, err error)
|
||||
|
||||
// Check reports whether raceName is taken for actorUserID on its
|
||||
// canonical key. Taken is false when no binding exists or when the
|
||||
// existing binding is owned by actorUserID; HolderUserID and Kind
|
||||
// carry the existing binding's metadata regardless of the Taken value.
|
||||
// A concurrent Reserve may race against a Check result, so service
|
||||
// code that needs atomicity must rely on Reserve returning
|
||||
// ErrNameTaken rather than pre-checking.
|
||||
Check(ctx context.Context, raceName, actorUserID string) (Availability, error)
|
||||
|
||||
// Reserve claims raceName for (gameID, userID). A second call by the
|
||||
// same holder for the same tuple is idempotent and returns nil.
|
||||
// ErrNameTaken is returned when any registered, reservation, or
|
||||
// pending_registration binding on the canonical key is owned by a
|
||||
// different user (in any game).
|
||||
Reserve(ctx context.Context, gameID, userID, raceName string) error
|
||||
|
||||
// ReleaseReservation removes the reservation held by userID for
|
||||
// raceName in gameID. It is a no-op when no reservation exists for
|
||||
// the tuple, when the reservation is held by a different user, and
|
||||
// when raceName fails validation; none of these cases surface an
|
||||
// error. Defensive release paths rely on these semantics.
|
||||
ReleaseReservation(ctx context.Context, gameID, userID, raceName string) error
|
||||
|
||||
// MarkPendingRegistration promotes the reservation stored for
|
||||
// (gameID, userID) on raceName's canonical key to
|
||||
// pending_registration status with the supplied eligibleUntil. A
|
||||
// second call with the same eligibleUntil is a no-op; a call with a
|
||||
// different eligibleUntil returns ErrInvalidName. Callers must
|
||||
// ensure the underlying reservation exists.
|
||||
MarkPendingRegistration(
|
||||
ctx context.Context,
|
||||
gameID, userID, raceName string,
|
||||
eligibleUntil time.Time,
|
||||
) error
|
||||
|
||||
// ExpirePendingRegistrations releases every pending registration whose
|
||||
// eligibleUntil is at or before now and returns the released entries
|
||||
// in a single slice so callers can emit metrics or notifications.
|
||||
// Running twice over the same state returns an empty slice the second
|
||||
// time.
|
||||
ExpirePendingRegistrations(ctx context.Context, now time.Time) ([]ExpiredPending, error)
|
||||
|
||||
// Register converts the pending registration identified by (gameID,
|
||||
// userID) on raceName's canonical key into a registered race name.
|
||||
// Missing pending returns ErrPendingMissing; expired pending returns
|
||||
// ErrPendingExpired. A second call after a successful registration
|
||||
// for the same tuple is idempotent and returns nil. Quota enforcement
|
||||
// is the caller's responsibility.
|
||||
Register(ctx context.Context, gameID, userID, raceName string) error
|
||||
|
||||
// ListRegistered returns every registered race name owned by userID.
|
||||
// The ordering is implementation-defined.
|
||||
ListRegistered(ctx context.Context, userID string) ([]RegisteredName, error)
|
||||
|
||||
// ListPendingRegistrations returns every pending registration owned by
|
||||
// userID. The ordering is implementation-defined.
|
||||
ListPendingRegistrations(ctx context.Context, userID string) ([]PendingRegistration, error)
|
||||
|
||||
// ListReservations returns every active reservation owned by userID
|
||||
// whose status has not yet been promoted to pending_registration. The
|
||||
// ordering is implementation-defined.
|
||||
ListReservations(ctx context.Context, userID string) ([]Reservation, error)
|
||||
|
||||
// ReleaseAllByUser atomically clears every registered, reservation,
|
||||
// and pending_registration binding owned by userID. It is invoked by
|
||||
// the user lifecycle consumer on permanent_blocked and deleted events.
|
||||
// Running twice over the same state is safe and produces no
|
||||
// additional side effects.
|
||||
ReleaseAllByUser(ctx context.Context, userID string) error
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ports
|
||||
|
||||
import "context"
|
||||
|
||||
// RuntimeManager publishes runtime jobs to Runtime Manager via Redis
|
||||
// Streams. introduces start and stop jobs; future stages may
|
||||
// extend the surface.
|
||||
//
|
||||
// The interface is intentionally narrow: callers pass only the game id.
|
||||
// Runtime Manager fetches additional context (target engine version,
|
||||
// turn schedule, etc.) through Lobby's internal HTTP API when it picks
|
||||
// up the job.
|
||||
type RuntimeManager interface {
|
||||
// PublishStartJob enqueues one start job for gameID. Implementations
|
||||
// must produce one event in the configured runtime start jobs stream
|
||||
// per call. A zero-error return means the event is durably accepted
|
||||
// into the stream (Redis XADD succeeded); it does not imply that the
|
||||
// container has started.
|
||||
PublishStartJob(ctx context.Context, gameID string) error
|
||||
|
||||
// PublishStopJob enqueues one stop job for gameID. Implementations
|
||||
// must produce one event in the configured runtime stop jobs stream
|
||||
// per call. The same durability semantics as PublishStartJob apply.
|
||||
PublishStopJob(ctx context.Context, gameID string) error
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StreamLagProbe reports how far the persisted consumer offset trails the
|
||||
// stream tail. It feeds the three `lobby.*.oldest_unprocessed_age_ms`
|
||||
// observable gauges defined in `lobby/README.md` §Observability.
|
||||
type StreamLagProbe interface {
|
||||
// OldestUnprocessedAge returns the age of the first stream entry that
|
||||
// follows savedOffset on stream. The boolean return reports whether
|
||||
// the implementation could compute an age at all: it is false when
|
||||
// the stream is empty, when no entries follow savedOffset, or when
|
||||
// the stream key is absent. An empty savedOffset is interpreted as
|
||||
// "no progress yet"; the implementation falls back to the stream
|
||||
// head in that case.
|
||||
OldestUnprocessedAge(ctx context.Context, stream, savedOffset string) (time.Duration, bool, error)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ports
|
||||
|
||||
import "context"
|
||||
|
||||
// StreamOffsetStore persists the last successfully processed Redis
|
||||
// Stream entry id per stream. Workers call Load on startup to resume
|
||||
// from the persisted offset and Save after every successful message
|
||||
// handling so the next iteration advances past the just-processed
|
||||
// entry. Lobby uses one logical store with the per-stream key encoded
|
||||
// inside the implementation.
|
||||
type StreamOffsetStore interface {
|
||||
// Load returns the last processed entry id for stream when one is
|
||||
// stored. The boolean return reports whether a value was present;
|
||||
// implementations must not return an error for a missing key.
|
||||
Load(ctx context.Context, stream string) (entryID string, found bool, err error)
|
||||
|
||||
// Save stores entryID as the new last processed offset for stream.
|
||||
// Implementations overwrite any previous value unconditionally.
|
||||
Save(ctx context.Context, stream, entryID string) error
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserLifecycleEventType identifies one supported user-lifecycle event
|
||||
// kind propagated from User Service to Game Lobby through the
|
||||
// `user:lifecycle_events` Redis Stream.
|
||||
type UserLifecycleEventType string
|
||||
|
||||
const (
|
||||
// UserLifecycleEventTypePermanentBlocked identifies the post-commit
|
||||
// event emitted when `SanctionCodePermanentBlock` becomes active on
|
||||
// an account.
|
||||
UserLifecycleEventTypePermanentBlocked UserLifecycleEventType = "user.lifecycle.permanent_blocked"
|
||||
|
||||
// UserLifecycleEventTypeDeleted identifies the post-commit event
|
||||
// emitted when `DeleteUser` soft-deletes an account.
|
||||
UserLifecycleEventTypeDeleted UserLifecycleEventType = "user.lifecycle.deleted"
|
||||
)
|
||||
|
||||
// String returns the wire value for eventType.
|
||||
func (eventType UserLifecycleEventType) String() string {
|
||||
return string(eventType)
|
||||
}
|
||||
|
||||
// IsKnown reports whether eventType belongs to the frozen vocabulary.
|
||||
func (eventType UserLifecycleEventType) IsKnown() bool {
|
||||
switch eventType {
|
||||
case UserLifecycleEventTypePermanentBlocked, UserLifecycleEventTypeDeleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UserLifecycleEvent stores the decoded shape of one entry from the
|
||||
// `user:lifecycle_events` Redis Stream.
|
||||
type UserLifecycleEvent struct {
|
||||
// EntryID stores the Redis Streams entry id (`<ms>-<seq>` form). The
|
||||
// consumer uses it as part of notification idempotency keys so a
|
||||
// retried cascade publishes deterministically the same intent.
|
||||
EntryID string
|
||||
|
||||
// EventType stores the frozen lifecycle event discriminator.
|
||||
EventType UserLifecycleEventType
|
||||
|
||||
// UserID identifies the regular user whose lifecycle state changed.
|
||||
UserID string
|
||||
|
||||
// OccurredAt stores the committed mutation timestamp emitted by User
|
||||
// Service.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source. always
|
||||
// emits `admin_internal_api`.
|
||||
Source string
|
||||
|
||||
// ActorType stores the audit actor type (e.g. `admin_user`,
|
||||
// `system`).
|
||||
ActorType string
|
||||
|
||||
// ActorID stores the optional audit actor identifier. It is empty
|
||||
// when the upstream event carries no actor id.
|
||||
ActorID string
|
||||
|
||||
// ReasonCode stores the committed `reason_code` emitted by User
|
||||
// Service.
|
||||
ReasonCode string
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier
|
||||
// propagated from the upstream request context.
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// Validate reports whether event satisfies the structural invariants
|
||||
// required for cascade processing.
|
||||
func (event UserLifecycleEvent) Validate() error {
|
||||
if strings.TrimSpace(event.EntryID) == "" {
|
||||
return fmt.Errorf("user lifecycle event entry id must not be empty")
|
||||
}
|
||||
if !event.EventType.IsKnown() {
|
||||
return fmt.Errorf("user lifecycle event type %q is unsupported", event.EventType)
|
||||
}
|
||||
if strings.TrimSpace(event.UserID) == "" {
|
||||
return fmt.Errorf("user lifecycle event user id must not be empty")
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
return fmt.Errorf("user lifecycle event occurred at must not be zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserLifecycleHandler processes one decoded lifecycle event. Returning
|
||||
// nil advances the stream offset; returning a non-nil error holds the
|
||||
// offset on the current entry so the consumer retries on the next loop
|
||||
// iteration.
|
||||
type UserLifecycleHandler func(ctx context.Context, event UserLifecycleEvent) error
|
||||
|
||||
// UserLifecycleConsumer drives the read loop over the
|
||||
// `user:lifecycle_events` Redis Stream. The Redis adapter satisfies the
|
||||
// interface in production; in-process stubs satisfy it in tests so the
|
||||
// cascade worker can be exercised without spinning up Redis.
|
||||
type UserLifecycleConsumer interface {
|
||||
// OnEvent installs handler as the sole dispatcher for decoded events.
|
||||
// A second call replaces the previous handler.
|
||||
OnEvent(handler UserLifecycleHandler)
|
||||
|
||||
// Run drives the consumer loop until ctx is cancelled. The
|
||||
// implementation is expected to be re-entrant only within a single
|
||||
// goroutine.
|
||||
Run(ctx context.Context) error
|
||||
|
||||
// Shutdown releases consumer-owned resources after Run has returned.
|
||||
// The consumer must support being closed without an active Run loop.
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrUserServiceUnavailable is returned by UserService implementations when
|
||||
// the upstream User Service is unreachable, returns an unexpected status,
|
||||
// or violates the contract in a way that callers must surface as
|
||||
// service_unavailable. Service-layer code wraps it with the
|
||||
// shared.ErrServiceUnavailable sentinel before bubbling up to transport.
|
||||
var ErrUserServiceUnavailable = errors.New("user service unavailable")
|
||||
|
||||
// Eligibility stores the lobby-relevant subset of the User Service
|
||||
// eligibility snapshot. only consumes Exists and CanJoinGame; the
|
||||
// remaining fields are pre-plumbed for (race name registration)
|
||||
// so that adding consumers later does not churn the port surface.
|
||||
type Eligibility struct {
|
||||
// Exists reports whether userID currently identifies a stored user.
|
||||
// false maps to subject_not_found at the service layer.
|
||||
Exists bool
|
||||
|
||||
// CanLogin reports whether the user may currently log in.
|
||||
CanLogin bool
|
||||
|
||||
// CanCreatePrivateGame reports whether the user may currently create
|
||||
// a private game.
|
||||
CanCreatePrivateGame bool
|
||||
|
||||
// CanManagePrivateGame reports whether the user may currently manage
|
||||
// a private game they own.
|
||||
CanManagePrivateGame bool
|
||||
|
||||
// CanJoinGame reports whether the user may currently join a game
|
||||
// (submit application or redeem invite). false surfaces as
|
||||
// eligibility_denied for the
|
||||
CanJoinGame bool
|
||||
|
||||
// CanUpdateProfile reports whether the user may currently update
|
||||
// self-service profile and settings fields.
|
||||
CanUpdateProfile bool
|
||||
|
||||
// PermanentBlocked reports whether the user carries an active
|
||||
// permanent_block sanction. rejects the
|
||||
// `lobby.race_name.register` call with `shared.ErrForbidden` ⇒
|
||||
// HTTP `403 forbidden`; the dedicated `race_name_permanent_blocked`
|
||||
// envelope code is reserved for the cascade flows. Stage
|
||||
// 11 currently surfaces it indirectly through CanJoinGame=false
|
||||
// (collapses every can_* marker to false on
|
||||
// permanent_block).
|
||||
PermanentBlocked bool
|
||||
|
||||
// MaxRegisteredRaceNames stores the snapshot value for the
|
||||
// max_registered_race_names limit. A value of 0 denotes unlimited
|
||||
// per the lifetime tariff. The race-name registration service
|
||||
// enforces the quota; this port only carries the value through.
|
||||
MaxRegisteredRaceNames int
|
||||
}
|
||||
|
||||
// UserService is the synchronous lobby-facing User Service eligibility
|
||||
// reader. The application flow consumes it via a single
|
||||
// GetEligibility call before accepting an applicant.
|
||||
type UserService interface {
|
||||
// GetEligibility returns the lobby-relevant eligibility snapshot for
|
||||
// userID. It returns Eligibility{Exists:false} for unknown users
|
||||
// without an error so callers can distinguish missing users from
|
||||
// transport failures. Transport, decode, and unexpected status code
|
||||
// failures wrap ErrUserServiceUnavailable.
|
||||
GetEligibility(ctx context.Context, userID string) (Eligibility, error)
|
||||
}
|
||||
Reference in New Issue
Block a user