feat: runtime manager
This commit is contained in:
+8
-1
@@ -3,8 +3,15 @@
|
||||
# The `jet` target regenerates the go-jet/v2 query-builder code under
|
||||
# internal/adapters/postgres/jet/ against a transient PostgreSQL container
|
||||
# brought up by cmd/jetgen. Generated code is committed.
|
||||
#
|
||||
# The `mocks` target regenerates the gomock-driven mocks via the
|
||||
# //go:generate directives that live next to the interfaces they cover
|
||||
# under internal/ports/. Generated code is committed.
|
||||
|
||||
.PHONY: jet
|
||||
.PHONY: jet mocks
|
||||
|
||||
jet:
|
||||
go run ./cmd/jetgen
|
||||
|
||||
mocks:
|
||||
go generate ./internal/ports/...
|
||||
|
||||
@@ -1441,3 +1441,12 @@ The implementation is complete only when all of the following hold:
|
||||
generator
|
||||
- `go test ./... -race` passes for the lobby module, the user module, the
|
||||
`pkg/notificationintent` module, and the integration module
|
||||
|
||||
## Note: Runtime Manager Envelope Evolution
|
||||
|
||||
Subsequent changes to the `runtime:start_jobs` and `runtime:stop_jobs`
|
||||
envelopes — specifically the addition of `image_ref` to the start envelope
|
||||
and the addition of the `reason` enum to the stop envelope — are owned by
|
||||
the Runtime Manager implementation plan, not by this document. See
|
||||
[`../rtmanager/PLAN.md`](../rtmanager/PLAN.md) §«Stage 06. Lobby publisher
|
||||
refactor». No new stages are added here for that work.
|
||||
|
||||
+119
-7
@@ -344,7 +344,7 @@ On success:
|
||||
|
||||
### Application state machine
|
||||
|
||||
```
|
||||
```text
|
||||
submitted → approved
|
||||
submitted → rejected
|
||||
```
|
||||
@@ -453,7 +453,7 @@ with payload: `game_id`, `game_name`, `invitee_user_id`, `invitee_name`.
|
||||
|
||||
### Invite state machine
|
||||
|
||||
```
|
||||
```text
|
||||
created → redeemed
|
||||
created → declined
|
||||
created → revoked
|
||||
@@ -591,9 +591,11 @@ Sentinel errors: `ErrNameTaken`, `ErrInvalidName`, `ErrPendingMissing`,
|
||||
`pg_advisory_xact_lock(hashtextextended(canonical_key, 0))`. See
|
||||
`docs/postgres-migration.md` §6B for the full schema and decision
|
||||
record.
|
||||
- **Stub** (`lobby/internal/adapters/racenamestub/directory.go`) — in-process
|
||||
implementation for unit tests that do not need PostgreSQL. Chosen by
|
||||
`LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub`.
|
||||
- **In-memory** (`lobby/internal/adapters/racenameinmem/directory.go`) —
|
||||
in-process implementation used by unit tests that do not need
|
||||
PostgreSQL and by deployments that select the in-memory backend with
|
||||
`LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub` (the config token name is
|
||||
preserved for backward compatibility).
|
||||
|
||||
A future dedicated `Race Name Service` replaces the adapter without changing
|
||||
the domain or service layer.
|
||||
@@ -737,7 +739,7 @@ sequenceDiagram
|
||||
|
||||
- If the container starts but `Lobby` cannot persist the runtime binding metadata,
|
||||
the start is a full failure: `Lobby` must issue a stop job to `Runtime Manager`
|
||||
before setting `start_failed`.
|
||||
with `reason=orphan_cleanup` before setting `start_failed`.
|
||||
- If metadata is persisted but `Game Master` is unavailable, the game must be
|
||||
placed in `paused`, not in `start_failed`. The container is alive; only the
|
||||
platform tracking is incomplete.
|
||||
@@ -745,6 +747,96 @@ sequenceDiagram
|
||||
- Concurrent start attempts for the same game must be serialized; the second
|
||||
attempt must fail if the first already moved the game to `starting`.
|
||||
|
||||
### Runtime Manager envelopes
|
||||
|
||||
`Lobby` is the producer for both `runtime:start_jobs` and `runtime:stop_jobs`.
|
||||
The `Lobby ↔ Runtime Manager` transport stays asynchronous indefinitely; there
|
||||
is no synchronous Lobby→RTM REST call in v1 or planned for v2.
|
||||
|
||||
`runtime:start_jobs` envelope:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `game_id` | string | Lobby `game_id`. |
|
||||
| `image_ref` | string | Docker reference resolved from `target_engine_version` via `LOBBY_ENGINE_IMAGE_TEMPLATE`. |
|
||||
| `requested_at_ms` | int64 | UTC milliseconds; diagnostics only. |
|
||||
|
||||
`runtime:stop_jobs` envelope:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `game_id` | string | |
|
||||
| `reason` | enum | `orphan_cleanup`, `cancelled`, `finished`, `admin_request`, `timeout`. |
|
||||
| `requested_at_ms` | int64 | UTC milliseconds. |
|
||||
|
||||
`reason` semantics (Lobby producer side):
|
||||
|
||||
- `orphan_cleanup` — used by Lobby's runtime-job-result consumer to release a
|
||||
container whose metadata persistence failed after a successful container
|
||||
start.
|
||||
- `cancelled` — used by the user-lifecycle cascade and by explicit cancel paths
|
||||
for in-flight games.
|
||||
- `finished` — reserved; not produced by Lobby in v1 because `game_finished`
|
||||
is engine-driven and stop jobs after finish are an Admin/GM concern.
|
||||
- `admin_request` — reserved for future admin-initiated stop paths through
|
||||
Lobby; not produced in v1.
|
||||
- `timeout` — reserved for future enrollment-timeout-driven stop paths; not
|
||||
produced in v1.
|
||||
|
||||
### Design rationale: StopReason placement
|
||||
|
||||
The `StopReason` enum is declared in
|
||||
`lobby/internal/ports/runtimemanager.go` alongside the `RuntimeManager`
|
||||
interface that consumes it. The enum is publisher-side protocol: it
|
||||
mirrors the AsyncAPI discriminator on `runtime:stop_jobs`, has no
|
||||
behaviour beyond `Validate`, and co-locating it with the interface keeps
|
||||
the AsyncAPI ↔ Go mapping visible in one file.
|
||||
|
||||
Alternatives considered and rejected:
|
||||
|
||||
- a dedicated `lobby/internal/domain/runtimejob` package — manufactures
|
||||
a domain layer for a single string enum that exists only to be
|
||||
serialised onto a Redis Stream;
|
||||
- placing the enum in the publisher adapter package
|
||||
(`lobby/internal/adapters/runtimemanager`) — the callers (start-game
|
||||
service, runtime-job-result worker, user-lifecycle worker) live
|
||||
outside that package and would have to depend on a concrete adapter
|
||||
for an enum value.
|
||||
|
||||
### Design rationale: `engineimage.Resolver` validates the template at construction
|
||||
|
||||
`engineimage.Resolver` stores the validated template; the per-game
|
||||
`Resolve(version)` call is therefore a pure string substitution that
|
||||
cannot fail except on an empty `version`.
|
||||
|
||||
`LOBBY_ENGINE_IMAGE_TEMPLATE` is loaded at startup. A malformed value
|
||||
(missing `{engine_version}` placeholder, empty string) is an
|
||||
operational misconfiguration that fails fast before any traffic arrives
|
||||
— not on the first start-game request hours later. The synchronous
|
||||
start handler then incurs no per-call template-shape recheck.
|
||||
|
||||
A stateless free function `engineimage.Resolve(template, version)` was
|
||||
rejected: the only useful checkpoint for the template literal is at
|
||||
startup; a free function would either re-validate on every call (waste)
|
||||
or skip validation (regression).
|
||||
|
||||
The resolver only guards against an empty/whitespace `version`. Semver
|
||||
validation lives in `lobby/internal/domain/game/model.go:validateSemver`
|
||||
and runs at game-record construction time. Re-running it inside the
|
||||
resolver would either duplicate the rule (drift risk) or import the
|
||||
validator across package boundaries for no behavioural gain. Keeping the
|
||||
resolver narrow leaves it reusable from a future producer (for example
|
||||
`Game Master`, when it takes over `image_ref` resolution) without
|
||||
dragging Lobby's domain rules along.
|
||||
|
||||
The defensive `return start game: resolve image ref: %w` in
|
||||
`startgame.Service.Handle` is a guard against a future invariant
|
||||
violation; it is not exercised by the service-level test suite because
|
||||
the only resolver-failure mode (empty `version`) requires bypassing
|
||||
`game.Validate`, which `gameinmem.Save` always runs. Adding test
|
||||
scaffolding to skip validation would teach the test suite a back door
|
||||
that the production code path does not have.
|
||||
|
||||
## Paused State
|
||||
|
||||
`Lobby.paused` is a platform-level pause, distinct from `Game Master` runtime
|
||||
@@ -1135,6 +1227,14 @@ Stream names:
|
||||
- `LOBBY_RUNTIME_JOB_RESULTS_READ_BLOCK_TIMEOUT` with default `2s`
|
||||
- `LOBBY_NOTIFICATION_INTENTS_STREAM` with default `notification:intents`
|
||||
|
||||
Runtime Manager integration:
|
||||
|
||||
- `LOBBY_ENGINE_IMAGE_TEMPLATE` with default `galaxy/game:{engine_version}` —
|
||||
Go-style template applied to a game's `target_engine_version` to resolve
|
||||
the Docker `image_ref` published on `runtime:start_jobs`. The template
|
||||
must contain the literal placeholder `{engine_version}`; Lobby fails
|
||||
fast at startup otherwise.
|
||||
|
||||
Upstream clients:
|
||||
|
||||
- `LOBBY_USER_SERVICE_TIMEOUT` with default `1s`
|
||||
@@ -1264,6 +1364,18 @@ Key operations emit structured logs with these stable field names where applicab
|
||||
|
||||
## Verification
|
||||
|
||||
Test doubles split between two styles. Wide-surface ports with no
|
||||
production state (`RuntimeManager`, `IntentPublisher`, `GMClient`,
|
||||
`UserService`) use `gomock`-generated mocks under
|
||||
`internal/adapters/mocks/`; regenerate with `make -C lobby mocks`.
|
||||
Stateful behavioural fakes that mirror the production adapter
|
||||
contract (`gameinmem`, `applicationinmem`, `inviteinmem`,
|
||||
`membershipinmem`, `gameturnstatsinmem`, `racenameinmem`,
|
||||
`evaluationguardinmem`, `gapactivationinmem`, `streamoffsetinmem`)
|
||||
live as in-memory adapters under `internal/adapters/<name>inmem/`
|
||||
and stay hand-rolled because tests rely on their CAS, status-transition,
|
||||
and invariant-tracking behaviour.
|
||||
|
||||
Focused service-local coverage verifies:
|
||||
|
||||
- configuration loading and validation for all env var groups
|
||||
@@ -1274,7 +1386,7 @@ Focused service-local coverage verifies:
|
||||
- application flow: submit (eligibility check, race name check), approve, reject
|
||||
- invite flow: create, redeem (auto-membership), decline, revoke, expire on enrollment close
|
||||
- membership model: activate, remove, block with correct before/after-start semantics
|
||||
- Race Name Directory (redis + stub adapters against the same suite):
|
||||
- Race Name Directory (PostgreSQL + in-memory adapters against the same suite):
|
||||
canonicalization + confusable-pair policy, `Reserve`/`ReleaseReservation`
|
||||
per-game semantics, `MarkPendingRegistration`/`ExpirePendingRegistrations`
|
||||
window, `Register` idempotency + quota, `ReleaseAllByUser` cascade
|
||||
|
||||
@@ -35,8 +35,11 @@ Before starting the process, confirm:
|
||||
- `LOBBY_USER_LIFECYCLE_STREAM` (default `user:lifecycle_events`)
|
||||
- `LOBBY_NOTIFICATION_INTENTS_STREAM` (default `notification:intents`)
|
||||
- `LOBBY_RACE_NAME_DIRECTORY_BACKEND` is `postgres` for production
|
||||
(the default after PG_PLAN.md §6B); the `stub` value is only for
|
||||
unit tests that do not need a real PostgreSQL.
|
||||
(the default after PG_PLAN.md §6B); the `stub` value selects the
|
||||
in-memory adapter at `lobby/internal/adapters/racenameinmem/`,
|
||||
intended for unit tests and small local deployments without
|
||||
PostgreSQL. The config token name is kept as `stub` for backward
|
||||
compatibility.
|
||||
|
||||
At startup the process opens the PostgreSQL pool, applies migrations,
|
||||
pings PostgreSQL, then opens the Redis client and pings Redis. Startup
|
||||
|
||||
@@ -161,8 +161,11 @@ The groups below summarize the structure:
|
||||
- `Game Lobby` owns platform game state. Game Master may cache snapshots but
|
||||
is not the source of truth.
|
||||
- The Race Name Directory ships a PostgreSQL adapter (default after
|
||||
PG_PLAN.md §6B) and an in-process stub. The stub is intended for unit
|
||||
tests and is selected via `LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub`.
|
||||
PG_PLAN.md §6B) and an in-process implementation in
|
||||
`lobby/internal/adapters/racenameinmem/`. The in-memory backend is
|
||||
intended for unit tests and small local deployments and is selected
|
||||
via `LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub` (the config token name
|
||||
is preserved for backward compatibility).
|
||||
- A `permanent_block` or `deleted` event from User Service fans out
|
||||
asynchronously through the `user:lifecycle_events` consumer; in-flight
|
||||
games owned by the affected user receive a stop-job and transition to
|
||||
|
||||
@@ -27,6 +27,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
@@ -326,6 +326,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package applicationstub provides an in-memory ports.ApplicationStore
|
||||
// Package applicationinmem provides an in-memory ports.ApplicationStore
|
||||
// implementation for service-level tests. The stub mirrors the
|
||||
// behavioural contract of the Redis adapter in redisstate: it enforces
|
||||
// application.Transition for status updates, the single-active
|
||||
@@ -8,7 +8,7 @@
|
||||
// Production code never wires this stub; it is test-only but exposed as
|
||||
// a regular (non _test.go) package so other service test packages can
|
||||
// import it.
|
||||
package applicationstub
|
||||
package applicationinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Package evaluationguardstub provides an in-memory
|
||||
// Package evaluationguardinmem provides an in-memory
|
||||
// ports.EvaluationGuardStore used by service-level capability evaluation
|
||||
// tests. Production code never wires this stub.
|
||||
package evaluationguardstub
|
||||
package evaluationguardinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+9
-9
@@ -1,13 +1,13 @@
|
||||
// Package gamestub provides an in-memory ports.GameStore implementation for
|
||||
// service-level tests. The stub mirrors the behavioural contract of the
|
||||
// Redis-backed adapter in redisstate: it enforces game.Transition for status
|
||||
// updates, the ExpectedFrom CAS check, and the StartedAt/FinishedAt side
|
||||
// effects of the canonical status transitions.
|
||||
// Package gameinmem provides an in-memory ports.GameStore implementation
|
||||
// for service-level tests. It mirrors the behavioural contract of the
|
||||
// Redis-backed adapter in redisstate: it enforces game.Transition for
|
||||
// status updates, the ExpectedFrom CAS check, and the
|
||||
// StartedAt/FinishedAt side effects of the canonical status transitions.
|
||||
//
|
||||
// Production code never wires this stub; it is test-only but exposed as a
|
||||
// regular (non _test.go) package so other service test packages can import
|
||||
// it.
|
||||
package gamestub
|
||||
// Production code never wires this adapter; it is test-only but exposed
|
||||
// as a regular (non _test.go) package so other service test packages can
|
||||
// import it.
|
||||
package gameinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gamestub
|
||||
package gameinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package gameturnstatsstub provides an in-memory ports.GameTurnStatsStore
|
||||
// Package gameturnstatsinmem provides an in-memory ports.GameTurnStatsStore
|
||||
// implementation for service-level tests. The stub mirrors the behavioural
|
||||
// contract of the Redis adapter in redisstate: SaveInitial freezes the
|
||||
// initial fields on the first call per user, UpdateMax keeps the max fields
|
||||
@@ -8,7 +8,7 @@
|
||||
// Production code never wires this stub; it is test-only but exposed as a
|
||||
// regular (non _test.go) package so downstream service test packages can
|
||||
// import it.
|
||||
package gameturnstatsstub
|
||||
package gameturnstatsinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
// Package gapactivationstub provides an in-memory
|
||||
// Package gapactivationinmem provides an in-memory
|
||||
// ports.GapActivationStore implementation for service-level tests. The
|
||||
// stub records every MarkActivated call and offers WasActivated /
|
||||
// ActivatedAt accessors so test bodies can assert the gap-window trigger
|
||||
// fired exactly once.
|
||||
package gapactivationstub
|
||||
package gapactivationinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,89 +0,0 @@
|
||||
// Package gmclientstub provides an in-process ports.GMClient
|
||||
// implementation used by service-level and worker-level tests that do
|
||||
// not need to spin up an httptest server. The stub records every
|
||||
// register call and every liveness probe, and supports independent
|
||||
// error injection for each method so and paths can
|
||||
// be exercised separately.
|
||||
//
|
||||
// Production code never wires this stub.
|
||||
package gmclientstub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Client is a concurrency-safe in-memory ports.GMClient.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
pingErr error
|
||||
requests []ports.RegisterGameRequest
|
||||
pingCalls int
|
||||
}
|
||||
|
||||
// NewClient constructs an empty Client.
|
||||
func NewClient() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
// SetError makes the next RegisterGame calls return err. Passing nil
|
||||
// clears the override.
|
||||
func (client *Client) SetError(err error) {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
client.err = err
|
||||
}
|
||||
|
||||
// SetPingError makes the next Ping calls return err. Passing nil
|
||||
// clears the override. RegisterGame is unaffected.
|
||||
func (client *Client) SetPingError(err error) {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
client.pingErr = err
|
||||
}
|
||||
|
||||
// Requests returns the ordered slice of register requests received.
|
||||
func (client *Client) Requests() []ports.RegisterGameRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return append([]ports.RegisterGameRequest(nil), client.requests...)
|
||||
}
|
||||
|
||||
// PingCalls returns the number of Ping invocations observed so far.
|
||||
func (client *Client) PingCalls() int {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return client.pingCalls
|
||||
}
|
||||
|
||||
// RegisterGame records the request and returns the configured error.
|
||||
func (client *Client) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
|
||||
if ctx == nil {
|
||||
return errors.New("register game: nil context")
|
||||
}
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
if client.err != nil {
|
||||
return client.err
|
||||
}
|
||||
client.requests = append(client.requests, request)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping increments the call counter and returns the configured error.
|
||||
func (client *Client) Ping(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("ping: nil context")
|
||||
}
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
client.pingCalls++
|
||||
return client.pingErr
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.GMClient = (*Client)(nil)
|
||||
@@ -1,79 +0,0 @@
|
||||
// Package intentpubstub provides an in-process
|
||||
// ports.IntentPublisher implementation for service-level tests. The
|
||||
// stub records every Publish call and lets tests inject failures to
|
||||
// verify that publication errors do not roll back already-committed
|
||||
// business state.
|
||||
package intentpubstub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/notificationintent"
|
||||
)
|
||||
|
||||
// Publisher is a concurrency-safe in-memory implementation of
|
||||
// ports.IntentPublisher. The zero value is not usable; call NewPublisher
|
||||
// to construct.
|
||||
type Publisher struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
nextID int
|
||||
err error
|
||||
}
|
||||
|
||||
// NewPublisher constructs an empty Publisher ready for use.
|
||||
func NewPublisher() *Publisher {
|
||||
return &Publisher{}
|
||||
}
|
||||
|
||||
// SetError preloads err to be returned by every Publish call. Pass nil
|
||||
// to reset.
|
||||
func (publisher *Publisher) SetError(err error) {
|
||||
if publisher == nil {
|
||||
return
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
publisher.err = err
|
||||
}
|
||||
|
||||
// Publish records intent and returns a synthetic stream entry id.
|
||||
func (publisher *Publisher) Publish(ctx context.Context, intent notificationintent.Intent) (string, error) {
|
||||
if publisher == nil {
|
||||
return "", errors.New("publish notification intent: nil publisher")
|
||||
}
|
||||
if ctx == nil {
|
||||
return "", errors.New("publish notification intent: nil context")
|
||||
}
|
||||
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
|
||||
if publisher.err != nil {
|
||||
return "", publisher.err
|
||||
}
|
||||
|
||||
publisher.nextID++
|
||||
publisher.published = append(publisher.published, intent)
|
||||
return strconv.Itoa(publisher.nextID), nil
|
||||
}
|
||||
|
||||
// Published returns a snapshot of every Publish-accepted intent in the
|
||||
// order it was received.
|
||||
func (publisher *Publisher) Published() []notificationintent.Intent {
|
||||
if publisher == nil {
|
||||
return nil
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
out := make([]notificationintent.Intent, len(publisher.published))
|
||||
copy(out, publisher.published)
|
||||
return out
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.IntentPublisher = (*Publisher)(nil)
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package invitestub provides an in-memory ports.InviteStore implementation
|
||||
// Package inviteinmem provides an in-memory ports.InviteStore implementation
|
||||
// for service-level tests. The stub mirrors the behavioural contract of the
|
||||
// Redis adapter in redisstate: Save is create-only, UpdateStatus enforces
|
||||
// invite.Transition and the ExpectedFrom CAS guard, and the index reads
|
||||
@@ -6,7 +6,7 @@
|
||||
//
|
||||
// Production code never wires this stub; it is test-only but exposed as a
|
||||
// regular (non _test.go) package so other service test packages can import it.
|
||||
package invitestub
|
||||
package inviteinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package membershipstub provides an in-memory ports.MembershipStore
|
||||
// Package membershipinmem provides an in-memory ports.MembershipStore
|
||||
// implementation for service-level tests. The stub mirrors the
|
||||
// behavioural contract of the Redis adapter in redisstate: Save is
|
||||
// create-only, UpdateStatus enforces membership.Transition and the
|
||||
@@ -8,7 +8,7 @@
|
||||
// Production code never wires this stub; it is test-only but exposed as
|
||||
// a regular (non _test.go) package so other service test packages can
|
||||
// import it.
|
||||
package membershipstub
|
||||
package membershipinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/metricsracenamedir"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
|
||||
@@ -28,7 +28,7 @@ func newRuntime(t *testing.T) (*telemetry.Runtime, sdkmetric.Reader) {
|
||||
|
||||
func newInner(t *testing.T) ports.RaceNameDirectory {
|
||||
t.Helper()
|
||||
stub, err := racenamestub.NewDirectory()
|
||||
stub, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
return stub
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: GMClient)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_gmclient.go -package=mocks galaxy/lobby/internal/ports GMClient
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
ports "galaxy/lobby/internal/ports"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockGMClient is a mock of GMClient interface.
|
||||
type MockGMClient struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockGMClientMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockGMClientMockRecorder is the mock recorder for MockGMClient.
|
||||
type MockGMClientMockRecorder struct {
|
||||
mock *MockGMClient
|
||||
}
|
||||
|
||||
// NewMockGMClient creates a new mock instance.
|
||||
func NewMockGMClient(ctrl *gomock.Controller) *MockGMClient {
|
||||
mock := &MockGMClient{ctrl: ctrl}
|
||||
mock.recorder = &MockGMClientMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockGMClient) EXPECT() *MockGMClientMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Ping mocks base method.
|
||||
func (m *MockGMClient) Ping(ctx context.Context) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Ping", ctx)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Ping indicates an expected call of Ping.
|
||||
func (mr *MockGMClientMockRecorder) Ping(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockGMClient)(nil).Ping), ctx)
|
||||
}
|
||||
|
||||
// RegisterGame mocks base method.
|
||||
func (m *MockGMClient) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RegisterGame", ctx, request)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// RegisterGame indicates an expected call of RegisterGame.
|
||||
func (mr *MockGMClientMockRecorder) RegisterGame(ctx, request any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterGame", reflect.TypeOf((*MockGMClient)(nil).RegisterGame), ctx, request)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: IntentPublisher)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_intentpublisher.go -package=mocks galaxy/lobby/internal/ports IntentPublisher
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
notificationintent "galaxy/notificationintent"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockIntentPublisher is a mock of IntentPublisher interface.
|
||||
type MockIntentPublisher struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockIntentPublisherMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockIntentPublisherMockRecorder is the mock recorder for MockIntentPublisher.
|
||||
type MockIntentPublisherMockRecorder struct {
|
||||
mock *MockIntentPublisher
|
||||
}
|
||||
|
||||
// NewMockIntentPublisher creates a new mock instance.
|
||||
func NewMockIntentPublisher(ctrl *gomock.Controller) *MockIntentPublisher {
|
||||
mock := &MockIntentPublisher{ctrl: ctrl}
|
||||
mock.recorder = &MockIntentPublisherMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockIntentPublisher) EXPECT() *MockIntentPublisherMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Publish mocks base method.
|
||||
func (m *MockIntentPublisher) Publish(ctx context.Context, intent notificationintent.Intent) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Publish", ctx, intent)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Publish indicates an expected call of Publish.
|
||||
func (mr *MockIntentPublisherMockRecorder) Publish(ctx, intent any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Publish", reflect.TypeOf((*MockIntentPublisher)(nil).Publish), ctx, intent)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: RuntimeManager)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_runtimemanager.go -package=mocks galaxy/lobby/internal/ports RuntimeManager
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
ports "galaxy/lobby/internal/ports"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRuntimeManager is a mock of RuntimeManager interface.
|
||||
type MockRuntimeManager struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRuntimeManagerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRuntimeManagerMockRecorder is the mock recorder for MockRuntimeManager.
|
||||
type MockRuntimeManagerMockRecorder struct {
|
||||
mock *MockRuntimeManager
|
||||
}
|
||||
|
||||
// NewMockRuntimeManager creates a new mock instance.
|
||||
func NewMockRuntimeManager(ctrl *gomock.Controller) *MockRuntimeManager {
|
||||
mock := &MockRuntimeManager{ctrl: ctrl}
|
||||
mock.recorder = &MockRuntimeManagerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRuntimeManager) EXPECT() *MockRuntimeManagerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// PublishStartJob mocks base method.
|
||||
func (m *MockRuntimeManager) PublishStartJob(ctx context.Context, gameID, imageRef string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PublishStartJob", ctx, gameID, imageRef)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PublishStartJob indicates an expected call of PublishStartJob.
|
||||
func (mr *MockRuntimeManagerMockRecorder) PublishStartJob(ctx, gameID, imageRef any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishStartJob", reflect.TypeOf((*MockRuntimeManager)(nil).PublishStartJob), ctx, gameID, imageRef)
|
||||
}
|
||||
|
||||
// PublishStopJob mocks base method.
|
||||
func (m *MockRuntimeManager) PublishStopJob(ctx context.Context, gameID string, reason ports.StopReason) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PublishStopJob", ctx, gameID, reason)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PublishStopJob indicates an expected call of PublishStopJob.
|
||||
func (mr *MockRuntimeManagerMockRecorder) PublishStopJob(ctx, gameID, reason any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishStopJob", reflect.TypeOf((*MockRuntimeManager)(nil).PublishStopJob), ctx, gameID, reason)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: UserService)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_userservice.go -package=mocks galaxy/lobby/internal/ports UserService
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
ports "galaxy/lobby/internal/ports"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockUserService is a mock of UserService interface.
|
||||
type MockUserService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockUserServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockUserServiceMockRecorder is the mock recorder for MockUserService.
|
||||
type MockUserServiceMockRecorder struct {
|
||||
mock *MockUserService
|
||||
}
|
||||
|
||||
// NewMockUserService creates a new mock instance.
|
||||
func NewMockUserService(ctrl *gomock.Controller) *MockUserService {
|
||||
mock := &MockUserService{ctrl: ctrl}
|
||||
mock.recorder = &MockUserServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockUserService) EXPECT() *MockUserServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetEligibility mocks base method.
|
||||
func (m *MockUserService) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetEligibility", ctx, userID)
|
||||
ret0, _ := ret[0].(ports.Eligibility)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetEligibility indicates an expected call of GetEligibility.
|
||||
func (mr *MockUserServiceMockRecorder) GetEligibility(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEligibility", reflect.TypeOf((*MockUserService)(nil).GetEligibility), ctx, userID)
|
||||
}
|
||||
+10
-7
@@ -1,10 +1,13 @@
|
||||
// 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
|
||||
// Package racenameinmem provides the in-process implementation of the
|
||||
// ports.RaceNameDirectory contract. It is used both by unit tests that
|
||||
// do not need a Redis dependency and by deployments that select the
|
||||
// in-memory backend via LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub. It
|
||||
// enforces the full two-tier Race Name Directory invariants
|
||||
// (registered, reservation, pending_registration) across the lifetime
|
||||
// of one process, and is interchangeable with the PostgreSQL adapter
|
||||
// under the shared behavioural test suite at
|
||||
// galaxy/lobby/internal/ports/racenamedirtest.
|
||||
package racenameinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
package racenamestub_test
|
||||
package racenameinmem_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/ports/racenamedirtest"
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
|
||||
func TestDirectoryContract(t *testing.T) {
|
||||
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
|
||||
var opts []racenamestub.Option
|
||||
var opts []racenameinmem.Option
|
||||
if now != nil {
|
||||
opts = append(opts, racenamestub.WithClock(now))
|
||||
opts = append(opts, racenameinmem.WithClock(now))
|
||||
}
|
||||
directory, err := racenamestub.NewDirectory(opts...)
|
||||
directory, err := racenameinmem.NewDirectory(opts...)
|
||||
require.NoError(t, err)
|
||||
return directory
|
||||
})
|
||||
@@ -37,7 +37,7 @@ func TestReserveConcurrentUniquenessInvariant(t *testing.T) {
|
||||
const gameID = "game-concurrency"
|
||||
|
||||
ctx := context.Background()
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
var (
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameintents"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/service/capabilityevaluation"
|
||||
@@ -14,13 +14,26 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func captureIntents(t *testing.T) (*mocks.MockIntentPublisher, *[]notificationintent.Intent) {
|
||||
t.Helper()
|
||||
publisher := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
var captured []notificationintent.Intent
|
||||
publisher.EXPECT().Publish(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
captured = append(captured, intent)
|
||||
return "1", nil
|
||||
}).AnyTimes()
|
||||
return publisher, &captured
|
||||
}
|
||||
|
||||
func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := intentpubstub.NewPublisher()
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
|
||||
mock, captured := captureIntents(t)
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
|
||||
require.NoError(t, err)
|
||||
|
||||
finishedAt := time.UnixMilli(1775121700000).UTC()
|
||||
@@ -34,9 +47,8 @@ func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
|
||||
FinishedAt: finishedAt,
|
||||
}))
|
||||
|
||||
published := stub.Published()
|
||||
require.Len(t, published, 1)
|
||||
intent := published[0]
|
||||
require.Len(t, *captured, 1)
|
||||
intent := (*captured)[0]
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationEligible, intent.NotificationType)
|
||||
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
|
||||
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
|
||||
@@ -53,8 +65,8 @@ func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
|
||||
func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := intentpubstub.NewPublisher()
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
|
||||
mock, captured := captureIntents(t)
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
|
||||
require.NoError(t, err)
|
||||
|
||||
finishedAt := time.UnixMilli(1775121700000).UTC()
|
||||
@@ -67,9 +79,8 @@ func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
|
||||
Reason: capabilityevaluation.ReasonCapabilityNotMet,
|
||||
}))
|
||||
|
||||
published := stub.Published()
|
||||
require.Len(t, published, 1)
|
||||
intent := published[0]
|
||||
require.Len(t, *captured, 1)
|
||||
intent := (*captured)[0]
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationDenied, intent.NotificationType)
|
||||
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
|
||||
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
|
||||
@@ -86,9 +97,10 @@ func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
|
||||
func TestPublisherSurfacesPublisherError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := intentpubstub.NewPublisher()
|
||||
stub.SetError(errors.New("transport unavailable"))
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
|
||||
mock := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
mock.EXPECT().Publish(gomock.Any(), gomock.Any()).
|
||||
Return("", errors.New("transport unavailable")).Times(1)
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
|
||||
require.NoError(t, err)
|
||||
|
||||
finishedAt := time.UnixMilli(1775121700000).UTC()
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
// The two streams are intentionally separate: each one carries a single
|
||||
// command kind, which keeps the consumer-side logic in Runtime Manager
|
||||
// simple and avoids a `kind` discriminator inside the message body.
|
||||
//
|
||||
// Envelope shape per `rtmanager/api/runtime-jobs-asyncapi.yaml`:
|
||||
//
|
||||
// - `runtime:start_jobs` — `{game_id, image_ref, requested_at_ms}`,
|
||||
// - `runtime:stop_jobs` — `{game_id, reason, requested_at_ms}`.
|
||||
//
|
||||
// The producer-supplied `image_ref` is resolved by the caller from the
|
||||
// game's `target_engine_version` and the configured engine-image
|
||||
// template; Runtime Manager never resolves engine versions itself.
|
||||
package runtimemanager
|
||||
|
||||
import (
|
||||
@@ -75,20 +84,45 @@ func NewPublisher(cfg Config) (*Publisher, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublishStartJob appends one start-job event for gameID to the
|
||||
// configured start-jobs stream.
|
||||
func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID string) error {
|
||||
return publisher.publish(ctx, "publish start job", publisher.startJobsStream, gameID)
|
||||
// PublishStartJob appends one start-job event for gameID with the
|
||||
// resolved imageRef to the configured start-jobs stream.
|
||||
func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID, imageRef string) error {
|
||||
const op = "publish start job"
|
||||
if err := publisher.checkCommon(op, ctx, gameID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(imageRef) == "" {
|
||||
return fmt.Errorf("%s: image ref must not be empty", op)
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"game_id": gameID,
|
||||
"image_ref": imageRef,
|
||||
"requested_at_ms": publisher.clock().UTC().UnixMilli(),
|
||||
}
|
||||
return publisher.xadd(ctx, op, publisher.startJobsStream, values)
|
||||
}
|
||||
|
||||
// PublishStopJob appends one stop-job event for gameID to the configured
|
||||
// stop-jobs stream. In Lobby publishes stop jobs only from the
|
||||
// orphan-container path inside the runtimejobresult worker.
|
||||
func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string) error {
|
||||
return publisher.publish(ctx, "publish stop job", publisher.stopJobsStream, gameID)
|
||||
// PublishStopJob appends one stop-job event for gameID classified by
|
||||
// reason to the configured stop-jobs stream.
|
||||
func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string, reason ports.StopReason) error {
|
||||
const op = "publish stop job"
|
||||
if err := publisher.checkCommon(op, ctx, gameID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reason.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"game_id": gameID,
|
||||
"reason": reason.String(),
|
||||
"requested_at_ms": publisher.clock().UTC().UnixMilli(),
|
||||
}
|
||||
return publisher.xadd(ctx, op, publisher.stopJobsStream, values)
|
||||
}
|
||||
|
||||
func (publisher *Publisher) publish(ctx context.Context, op, stream, gameID string) error {
|
||||
func (publisher *Publisher) checkCommon(op string, ctx context.Context, gameID string) error {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return fmt.Errorf("%s: nil publisher", op)
|
||||
}
|
||||
@@ -98,11 +132,10 @@ func (publisher *Publisher) publish(ctx context.Context, op, stream, gameID stri
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return fmt.Errorf("%s: game id must not be empty", op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"game_id": gameID,
|
||||
"requested_at_ms": publisher.clock().UTC().UnixMilli(),
|
||||
}
|
||||
func (publisher *Publisher) xadd(ctx context.Context, op, stream string, values map[string]any) error {
|
||||
if _, err := publisher.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: stream,
|
||||
Values: values,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/runtimemanager"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -60,12 +61,13 @@ func TestPublishStartJobAppendsToStartStream(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
publisher, _, client := newTestPublisher(t, func() time.Time { return now })
|
||||
|
||||
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1"))
|
||||
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1", "galaxy/game:v1.0.0"))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:start_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "game-1", entries[0].Values["game_id"])
|
||||
assert.Equal(t, "galaxy/game:v1.0.0", entries[0].Values["image_ref"])
|
||||
assert.Equal(t, strconv.FormatInt(now.UnixMilli(), 10), entries[0].Values["requested_at_ms"])
|
||||
|
||||
stop, err := client.XLen(context.Background(), "runtime:stop_jobs").Result()
|
||||
@@ -73,16 +75,29 @@ func TestPublishStartJobAppendsToStartStream(t *testing.T) {
|
||||
assert.Equal(t, int64(0), stop, "stop stream must remain empty")
|
||||
}
|
||||
|
||||
func TestPublisherStartJobIncludesImageRef(t *testing.T) {
|
||||
publisher, _, client := newTestPublisher(t, nil)
|
||||
|
||||
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1", "registry.example.com/galaxy/game:v1.4.7"))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:start_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "registry.example.com/galaxy/game:v1.4.7", entries[0].Values["image_ref"],
|
||||
"image_ref field must be present in the start envelope")
|
||||
}
|
||||
|
||||
func TestPublishStopJobAppendsToStopStream(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC)
|
||||
publisher, _, client := newTestPublisher(t, func() time.Time { return now })
|
||||
|
||||
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2"))
|
||||
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2", ports.StopReasonOrphanCleanup))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:stop_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "game-2", entries[0].Values["game_id"])
|
||||
assert.Equal(t, "orphan_cleanup", entries[0].Values["reason"])
|
||||
assert.Equal(t, strconv.FormatInt(now.UnixMilli(), 10), entries[0].Values["requested_at_ms"])
|
||||
|
||||
startLen, err := client.XLen(context.Background(), "runtime:start_jobs").Result()
|
||||
@@ -90,18 +105,44 @@ func TestPublishStopJobAppendsToStopStream(t *testing.T) {
|
||||
assert.Equal(t, int64(0), startLen, "start stream must remain empty")
|
||||
}
|
||||
|
||||
func TestPublisherStopJobIncludesReason(t *testing.T) {
|
||||
publisher, _, client := newTestPublisher(t, nil)
|
||||
|
||||
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2", ports.StopReasonCancelled))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:stop_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "cancelled", entries[0].Values["reason"],
|
||||
"reason field must be present in the stop envelope")
|
||||
}
|
||||
|
||||
func TestPublishRejectsEmptyGameID(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), ""))
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), " "))
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), "", "galaxy/game:v1.0.0"))
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), " ", ports.StopReasonCancelled))
|
||||
}
|
||||
|
||||
func TestPublishStartJobRejectsEmptyImageRef(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), "game-1", ""))
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), "game-1", " "))
|
||||
}
|
||||
|
||||
func TestPublishStopJobRejectsUnknownReason(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), "game-1", ports.StopReason("")))
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), "game-1", ports.StopReason("unknown_reason")))
|
||||
}
|
||||
|
||||
func TestPublishRejectsNilContext(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStartJob(nilContext(), "game-1"))
|
||||
require.Error(t, publisher.PublishStopJob(nilContext(), "game-1"))
|
||||
require.Error(t, publisher.PublishStartJob(nilContext(), "game-1", "galaxy/game:v1.0.0"))
|
||||
require.Error(t, publisher.PublishStopJob(nilContext(), "game-1", ports.StopReasonCancelled))
|
||||
}
|
||||
|
||||
// nilContext returns an explicit untyped nil to exercise the defensive
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// Package runtimemanagerstub provides an in-process ports.RuntimeManager
|
||||
// implementation used by service-level and worker-level tests that do
|
||||
// not need a real Redis connection. The stub records every published
|
||||
// job and supports inject-on-error to simulate stream failures.
|
||||
//
|
||||
// Production code never wires this stub.
|
||||
package runtimemanagerstub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Publisher is a concurrency-safe in-memory ports.RuntimeManager.
|
||||
type Publisher struct {
|
||||
mu sync.Mutex
|
||||
startErr error
|
||||
stopErr error
|
||||
startJobs []string
|
||||
stopJobs []string
|
||||
}
|
||||
|
||||
// NewPublisher constructs an empty Publisher.
|
||||
func NewPublisher() *Publisher {
|
||||
return &Publisher{}
|
||||
}
|
||||
|
||||
// SetStartError makes the next PublishStartJob calls return err.
|
||||
// Passing nil clears the override.
|
||||
func (publisher *Publisher) SetStartError(err error) {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
publisher.startErr = err
|
||||
}
|
||||
|
||||
// SetStopError makes the next PublishStopJob calls return err.
|
||||
// Passing nil clears the override.
|
||||
func (publisher *Publisher) SetStopError(err error) {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
publisher.stopErr = err
|
||||
}
|
||||
|
||||
// StartJobs returns the ordered slice of game ids passed to
|
||||
// PublishStartJob.
|
||||
func (publisher *Publisher) StartJobs() []string {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
return append([]string(nil), publisher.startJobs...)
|
||||
}
|
||||
|
||||
// StopJobs returns the ordered slice of game ids passed to
|
||||
// PublishStopJob.
|
||||
func (publisher *Publisher) StopJobs() []string {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
return append([]string(nil), publisher.stopJobs...)
|
||||
}
|
||||
|
||||
// PublishStartJob records gameID and returns the configured error.
|
||||
func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID string) error {
|
||||
if ctx == nil {
|
||||
return errors.New("publish start job: nil context")
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
if publisher.startErr != nil {
|
||||
return publisher.startErr
|
||||
}
|
||||
publisher.startJobs = append(publisher.startJobs, gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishStopJob records gameID and returns the configured error.
|
||||
func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string) error {
|
||||
if ctx == nil {
|
||||
return errors.New("publish stop job: nil context")
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
if publisher.stopErr != nil {
|
||||
return publisher.stopErr
|
||||
}
|
||||
publisher.stopJobs = append(publisher.stopJobs, gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.RuntimeManager = (*Publisher)(nil)
|
||||
@@ -1,61 +0,0 @@
|
||||
// Package streamlagprobestub provides an in-memory ports.StreamLagProbe
|
||||
// implementation for tests that do not need a Redis instance. Production
|
||||
// code never wires this stub.
|
||||
package streamlagprobestub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Probe is a concurrency-safe in-memory ports.StreamLagProbe. The zero
|
||||
// value reports `(0, false, nil)` for every stream until Set is called.
|
||||
type Probe struct {
|
||||
mu sync.Mutex
|
||||
results map[string]Result
|
||||
fallback Result
|
||||
}
|
||||
|
||||
// Result stores the value the probe reports for a stream.
|
||||
type Result struct {
|
||||
Age time.Duration
|
||||
Found bool
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewProbe constructs one Probe with no preconfigured results.
|
||||
func NewProbe() *Probe {
|
||||
return &Probe{results: make(map[string]Result)}
|
||||
}
|
||||
|
||||
// Set installs the result the probe will return for stream.
|
||||
func (probe *Probe) Set(stream string, result Result) {
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
probe.results[stream] = result
|
||||
}
|
||||
|
||||
// SetFallback installs the result returned when no per-stream result is
|
||||
// configured.
|
||||
func (probe *Probe) SetFallback(result Result) {
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
probe.fallback = result
|
||||
}
|
||||
|
||||
// OldestUnprocessedAge satisfies ports.StreamLagProbe.
|
||||
func (probe *Probe) OldestUnprocessedAge(_ context.Context, stream, _ string) (time.Duration, bool, error) {
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
|
||||
if result, ok := probe.results[stream]; ok {
|
||||
return result.Age, result.Found, result.Err
|
||||
}
|
||||
return probe.fallback.Age, probe.fallback.Found, probe.fallback.Err
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.StreamLagProbe = (*Probe)(nil)
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Package streamoffsetstub provides an in-process ports.StreamOffsetStore
|
||||
// Package streamoffsetinmem provides an in-process ports.StreamOffsetStore
|
||||
// used by worker-level tests that do not need Redis. Production code
|
||||
// never wires this stub.
|
||||
package streamoffsetstub
|
||||
package streamoffsetinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/streamoffsetstub"
|
||||
"galaxy/lobby/internal/adapters/streamoffsetinmem"
|
||||
"galaxy/lobby/internal/adapters/userlifecycle"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
@@ -33,7 +33,7 @@ func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discar
|
||||
type harness struct {
|
||||
server *miniredis.Miniredis
|
||||
client *redis.Client
|
||||
offsets *streamoffsetstub.Store
|
||||
offsets *streamoffsetinmem.Store
|
||||
consumer *userlifecycle.Consumer
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func newHarness(t *testing.T) *harness {
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
offsets := streamoffsetstub.NewStore()
|
||||
offsets := streamoffsetinmem.NewStore()
|
||||
consumer, err := userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Client: client,
|
||||
Stream: testStream,
|
||||
@@ -70,21 +70,21 @@ func TestNewConsumerRejectsMissingDeps(t *testing.T) {
|
||||
_, err := userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Stream: testStream,
|
||||
BlockTimeout: time.Second,
|
||||
OffsetStore: streamoffsetstub.NewStore(),
|
||||
OffsetStore: streamoffsetinmem.NewStore(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Client: client,
|
||||
BlockTimeout: time.Second,
|
||||
OffsetStore: streamoffsetstub.NewStore(),
|
||||
OffsetStore: streamoffsetinmem.NewStore(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Client: client,
|
||||
Stream: testStream,
|
||||
OffsetStore: streamoffsetstub.NewStore(),
|
||||
OffsetStore: streamoffsetinmem.NewStore(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Package userlifecyclestub provides an in-process
|
||||
// ports.UserLifecycleConsumer used by worker-level tests that do not
|
||||
// need a real Redis stream. Production code never wires this stub.
|
||||
package userlifecyclestub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Consumer is an in-memory ports.UserLifecycleConsumer. Tests publish
|
||||
// events synchronously through Deliver and observe handler errors via
|
||||
// the returned value.
|
||||
type Consumer struct {
|
||||
mu sync.Mutex
|
||||
handler ports.UserLifecycleHandler
|
||||
}
|
||||
|
||||
// NewConsumer constructs an empty Consumer.
|
||||
func NewConsumer() *Consumer {
|
||||
return &Consumer{}
|
||||
}
|
||||
|
||||
// OnEvent installs handler as the dispatch target. A second call
|
||||
// replaces the previous handler.
|
||||
func (consumer *Consumer) OnEvent(handler ports.UserLifecycleHandler) {
|
||||
if consumer == nil {
|
||||
return
|
||||
}
|
||||
consumer.mu.Lock()
|
||||
consumer.handler = handler
|
||||
consumer.mu.Unlock()
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled. The stub does not pull events from
|
||||
// any backend; test code drives delivery via Deliver.
|
||||
func (consumer *Consumer) Run(ctx context.Context) error {
|
||||
if consumer == nil {
|
||||
return errors.New("run user lifecycle stub: nil consumer")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("run user lifecycle stub: nil context")
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Shutdown is a no-op.
|
||||
func (consumer *Consumer) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown user lifecycle stub: nil context")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deliver dispatches event to the registered handler synchronously and
|
||||
// returns the handler's error. It is the test-only entry point used by
|
||||
// worker_test fixtures.
|
||||
func (consumer *Consumer) Deliver(ctx context.Context, event ports.UserLifecycleEvent) error {
|
||||
if consumer == nil {
|
||||
return errors.New("deliver user lifecycle stub: nil consumer")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("deliver user lifecycle stub: nil context")
|
||||
}
|
||||
consumer.mu.Lock()
|
||||
handler := consumer.handler
|
||||
consumer.mu.Unlock()
|
||||
if handler == nil {
|
||||
return errors.New("deliver user lifecycle stub: no handler registered")
|
||||
}
|
||||
return handler(ctx, event)
|
||||
}
|
||||
|
||||
// Compile-time assertion: Consumer satisfies the port interface.
|
||||
var _ ports.UserLifecycleConsumer = (*Consumer)(nil)
|
||||
@@ -1,107 +0,0 @@
|
||||
// Package userservicestub provides an in-process
|
||||
// ports.UserService implementation for service-level tests. The stub
|
||||
// stores per-user Eligibility values and lets tests inject errors for
|
||||
// specific user ids to exercise the unavailable / decode-failure paths.
|
||||
package userservicestub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Service is a concurrency-safe in-memory implementation of
|
||||
// ports.UserService. The zero value is not usable; call NewService to
|
||||
// construct.
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
eligibilities map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
defaultMissing bool
|
||||
}
|
||||
|
||||
// NewService constructs an empty Service with no preloaded
|
||||
// eligibilities. By default an unknown user maps to
|
||||
// Eligibility{Exists:false}, mirroring the production HTTP client's
|
||||
// 404 handling. Use WithDefaultUnavailable to flip the unknown-user
|
||||
// behaviour to a transport failure.
|
||||
func NewService(opts ...Option) *Service {
|
||||
service := &Service{
|
||||
eligibilities: make(map[string]ports.Eligibility),
|
||||
failures: make(map[string]error),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(service)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Option tunes Service construction.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithDefaultUnavailable makes the stub return ErrUserServiceUnavailable
|
||||
// for any user id without a preloaded eligibility or failure entry.
|
||||
// Useful for tests that exercise the "User Service down" path without
|
||||
// having to enumerate every caller.
|
||||
func WithDefaultUnavailable() Option {
|
||||
return func(service *Service) {
|
||||
service.defaultMissing = true
|
||||
}
|
||||
}
|
||||
|
||||
// SetEligibility preloads eligibility for userID. Subsequent calls
|
||||
// overwrite the prior value.
|
||||
func (service *Service) SetEligibility(userID string, eligibility ports.Eligibility) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
service.eligibilities[strings.TrimSpace(userID)] = eligibility
|
||||
}
|
||||
|
||||
// SetFailure preloads err to be returned for userID. err takes
|
||||
// precedence over any preloaded eligibility.
|
||||
func (service *Service) SetFailure(userID string, err error) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
service.failures[strings.TrimSpace(userID)] = err
|
||||
}
|
||||
|
||||
// GetEligibility returns the preloaded eligibility for userID.
|
||||
func (service *Service) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
|
||||
if service == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: user id must not be empty")
|
||||
}
|
||||
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
if err, ok := service.failures[trimmed]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if eligibility, ok := service.eligibilities[trimmed]; ok {
|
||||
return eligibility, nil
|
||||
}
|
||||
if service.defaultMissing {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", ports.ErrUserServiceUnavailable)
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.UserService = (*Service)(nil)
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -51,7 +51,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
return func() time.Time { return at }
|
||||
}
|
||||
|
||||
func buildHandler(t *testing.T, store *gamestub.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
func buildHandler(t *testing.T, store *gameinmem.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := silentLogger()
|
||||
@@ -131,7 +131,7 @@ func TestAdminCreatesPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "game-public"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
@@ -158,7 +158,7 @@ func TestAdminCannotCreatePrivateGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-priv"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-priv"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "Private Lobby",
|
||||
@@ -181,7 +181,7 @@ func TestAdminValidationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-bad"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-bad"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "",
|
||||
@@ -204,7 +204,7 @@ func TestAdminUpdateAllFieldsInDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftForTest(t, store, "game-u", game.GameTypePublic, "", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
@@ -221,7 +221,7 @@ func TestAdminOpenEnrollment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePublic, "", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
@@ -236,7 +236,7 @@ func TestAdminCancelFromRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftForTest(t, store, "game-run", game.GameTypePublic, "", now)
|
||||
// Force status to running to exercise the 409 conflict path.
|
||||
record.Status = game.StatusRunning
|
||||
@@ -257,7 +257,7 @@ func TestAdminUpdateNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
|
||||
desc := "x"
|
||||
body := updateGameRequest{Description: &desc}
|
||||
@@ -269,7 +269,7 @@ func TestAdminCreateUnknownFieldRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
|
||||
reqBody := map[string]any{
|
||||
"game_name": "x",
|
||||
@@ -289,7 +289,7 @@ func TestAdminCreateUnknownFieldRejected(t *testing.T) {
|
||||
|
||||
func seedDraftForTest(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -47,7 +47,7 @@ func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func buildHandler(t *testing.T, store *gamestub.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
func buildHandler(t *testing.T, store *gameinmem.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := silentLogger()
|
||||
@@ -134,7 +134,7 @@ func TestCreateGameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "game-first"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
@@ -164,7 +164,7 @@ func TestCreateGameMissingUserIDHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "x",
|
||||
@@ -189,7 +189,7 @@ func TestCreateGameUnknownJSONFieldRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
reqBody := map[string]any{
|
||||
"game_name": "x",
|
||||
@@ -211,7 +211,7 @@ func TestCreateGameUserCannotCreatePublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "x",
|
||||
@@ -234,7 +234,7 @@ func TestUpdateGameNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
desc := "new"
|
||||
body := updateGameRequest{Description: &desc}
|
||||
@@ -248,7 +248,7 @@ func TestOpenEnrollmentHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestOpenEnrollmentForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
@@ -278,7 +278,7 @@ func TestOpenEnrollmentConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
@@ -301,7 +301,7 @@ func TestCancelGameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-cx", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
@@ -315,7 +315,7 @@ func TestCancelGameHappyPath(t *testing.T) {
|
||||
|
||||
func seedDraftForTest(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
|
||||
@@ -4,44 +4,114 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/listmyracenames"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
"galaxy/notificationintent"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type publishedIntentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
}
|
||||
|
||||
func (r *publishedIntentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *publishedIntentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
type userEligibilityRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userEligibilityRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userEligibilityRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userEligibilityRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newPublishedIntentMock(t *testing.T, rec *publishedIntentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserEligibilityMock(t *testing.T, rec *userEligibilityRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
type raceNameFixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
directory *racenameinmem.Directory
|
||||
users *userEligibilityRec
|
||||
intents *publishedIntentRec
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func newRaceNameFixture(t *testing.T) *raceNameFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
users := userservicestub.NewService()
|
||||
intents := intentpubstub.NewPublisher()
|
||||
usersRec := &userEligibilityRec{}
|
||||
intentsRec := &publishedIntentRec{}
|
||||
|
||||
logger := silentLogger()
|
||||
svc, err := registerracename.NewService(registerracename.Dependencies{
|
||||
Directory: directory,
|
||||
Users: users,
|
||||
Intents: intents,
|
||||
Users: newUserEligibilityMock(t, usersRec),
|
||||
Intents: newPublishedIntentMock(t, intentsRec),
|
||||
Clock: func() time.Time { return now },
|
||||
Logger: logger,
|
||||
})
|
||||
@@ -50,8 +120,8 @@ func newRaceNameFixture(t *testing.T) *raceNameFixture {
|
||||
return &raceNameFixture{
|
||||
now: now,
|
||||
directory: directory,
|
||||
users: users,
|
||||
intents: intents,
|
||||
users: usersRec,
|
||||
intents: intentsRec,
|
||||
handler: newHandler(Dependencies{Logger: logger, RegisterRaceName: svc}, logger),
|
||||
}
|
||||
}
|
||||
@@ -66,7 +136,7 @@ func TestHandleRegisterRaceNameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(7*24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
@@ -82,7 +152,7 @@ func TestHandleRegisterRaceNameHappyPath(t *testing.T) {
|
||||
assert.Equal(t, f.now.UnixMilli(), resp.RegisteredAtMs)
|
||||
assert.NotEmpty(t, resp.CanonicalKey)
|
||||
|
||||
require.Len(t, f.intents.Published(), 1)
|
||||
require.Len(t, f.intents.snapshot(), 1)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNameRejectsMissingUserHeader(t *testing.T) {
|
||||
@@ -120,7 +190,7 @@ func TestHandleRegisterRaceNamePendingMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
@@ -137,7 +207,7 @@ func TestHandleRegisterRaceNamePendingExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(-time.Minute))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
@@ -155,7 +225,7 @@ func TestHandleRegisterRaceNameQuotaExceeded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 1})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 1})
|
||||
// pre-existing registered race name to exhaust quota
|
||||
f.seedPending(t, "game-old", "user-1", "OldName", f.now.Add(24*time.Hour))
|
||||
require.NoError(t, f.directory.Register(context.Background(), "game-old", "user-1", "OldName"))
|
||||
@@ -177,7 +247,7 @@ func TestHandleRegisterRaceNamePermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{
|
||||
f.users.setEligibility("user-1", ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
MaxRegisteredRaceNames: 2,
|
||||
@@ -199,7 +269,7 @@ func TestHandleRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
@@ -218,17 +288,17 @@ func TestHandleRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
// silent logger.
|
||||
type myRaceNamesFixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
games *gamestub.Store
|
||||
directory *racenameinmem.Directory
|
||||
games *gameinmem.Store
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func newMyRaceNamesFixture(t *testing.T) *myRaceNamesFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
|
||||
logger := silentLogger()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
|
||||
@@ -16,13 +16,14 @@ import (
|
||||
pginvitestore "galaxy/lobby/internal/adapters/postgres/invitestore"
|
||||
pgmembershipstore "galaxy/lobby/internal/adapters/postgres/membershipstore"
|
||||
pgracenamedir "galaxy/lobby/internal/adapters/postgres/racenamedir"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameintents"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/adapters/runtimemanager"
|
||||
"galaxy/lobby/internal/adapters/userlifecycle"
|
||||
"galaxy/lobby/internal/adapters/userservice"
|
||||
"galaxy/lobby/internal/config"
|
||||
"galaxy/lobby/internal/domain/engineimage"
|
||||
"galaxy/lobby/internal/domain/racename"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
@@ -497,6 +498,11 @@ func newWiring(
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
}
|
||||
|
||||
engineImageResolver, err := engineimage.NewResolver(cfg.RuntimeManager.EngineImageTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
}
|
||||
|
||||
streamOffsets, err := redisstate.NewStreamOffsetStore(redisClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby wiring: %w", err)
|
||||
@@ -505,6 +511,7 @@ func newWiring(
|
||||
startSvc, err := startgame.NewService(startgame.Dependencies{
|
||||
Games: gameStore,
|
||||
RuntimeManager: runtimePublisher,
|
||||
ImageResolver: engineImageResolver,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
@@ -804,7 +811,7 @@ func buildRaceNameDirectory(
|
||||
Clock: clock,
|
||||
})
|
||||
case config.RaceNameDirectoryBackendStub:
|
||||
return racenamestub.NewDirectory(racenamestub.WithClock(clock))
|
||||
return racenameinmem.NewDirectory(racenameinmem.WithClock(clock))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported race name directory backend %q", cfg.RaceNameDirectory.Backend)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/engineimage"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
@@ -49,6 +50,8 @@ const (
|
||||
raceNameDirectoryBackendEnvVar = "LOBBY_RACE_NAME_DIRECTORY_BACKEND"
|
||||
raceNameExpirationIntervalEnvVar = "LOBBY_RACE_NAME_EXPIRATION_INTERVAL"
|
||||
|
||||
engineImageTemplateEnvVar = "LOBBY_ENGINE_IMAGE_TEMPLATE"
|
||||
|
||||
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
|
||||
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
|
||||
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
|
||||
@@ -78,6 +81,7 @@ const (
|
||||
defaultGMTimeout = 5 * time.Second
|
||||
defaultEnrollmentAutomationInterval = 30 * time.Second
|
||||
defaultRaceNameExpirationInterval = time.Hour
|
||||
defaultEngineImageTemplate = "galaxy/game:" + engineimage.VersionPlaceholder
|
||||
defaultOTelServiceName = "galaxy-lobby"
|
||||
|
||||
// RaceNameDirectoryBackendPostgres selects the PostgreSQL-backed
|
||||
@@ -134,6 +138,9 @@ type Config struct {
|
||||
// every pending_registration whose eligible_until has passed.
|
||||
PendingRegistration PendingRegistrationConfig
|
||||
|
||||
// RuntimeManager configures the Runtime Manager publisher contract.
|
||||
RuntimeManager RuntimeManagerConfig
|
||||
|
||||
// Telemetry configures the process-wide OpenTelemetry runtime.
|
||||
Telemetry TelemetryConfig
|
||||
}
|
||||
@@ -410,6 +417,27 @@ func (cfg PendingRegistrationConfig) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RuntimeManagerConfig configures the Lobby-side Runtime Manager
|
||||
// publisher contract. Lobby resolves the Docker image reference it
|
||||
// publishes on `runtime:start_jobs` from a per-game
|
||||
// `target_engine_version` and the configured EngineImageTemplate.
|
||||
type RuntimeManagerConfig struct {
|
||||
// EngineImageTemplate stores the Docker reference template applied
|
||||
// to a game's `target_engine_version`. The string must contain the
|
||||
// literal placeholder `{engine_version}`; Lobby fails fast at
|
||||
// startup otherwise.
|
||||
EngineImageTemplate string
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Runtime Manager
|
||||
// publisher configuration.
|
||||
func (cfg RuntimeManagerConfig) Validate() error {
|
||||
if _, err := engineimage.NewResolver(cfg.EngineImageTemplate); err != nil {
|
||||
return fmt.Errorf("engine image template: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TelemetryConfig configures the Game Lobby Service OpenTelemetry runtime.
|
||||
type TelemetryConfig struct {
|
||||
// ServiceName overrides the default OpenTelemetry service name.
|
||||
@@ -504,6 +532,9 @@ func DefaultConfig() Config {
|
||||
PendingRegistration: PendingRegistrationConfig{
|
||||
Interval: defaultRaceNameExpirationInterval,
|
||||
},
|
||||
RuntimeManager: RuntimeManagerConfig{
|
||||
EngineImageTemplate: defaultEngineImageTemplate,
|
||||
},
|
||||
Telemetry: TelemetryConfig{
|
||||
ServiceName: defaultOTelServiceName,
|
||||
TracesExporter: "none",
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestDefaultConfig(t *testing.T) {
|
||||
assert.Equal(t, 5*time.Second, cfg.GM.Timeout)
|
||||
assert.Equal(t, 30*time.Second, cfg.EnrollmentAutomation.Interval)
|
||||
assert.Equal(t, time.Hour, cfg.PendingRegistration.Interval)
|
||||
assert.Equal(t, "galaxy/game:{engine_version}", cfg.RuntimeManager.EngineImageTemplate)
|
||||
assert.Equal(t, "galaxy-lobby", cfg.Telemetry.ServiceName)
|
||||
assert.Equal(t, "none", cfg.Telemetry.TracesExporter)
|
||||
assert.Equal(t, "none", cfg.Telemetry.MetricsExporter)
|
||||
@@ -114,6 +115,7 @@ func TestLoadFromEnvOverrides(t *testing.T) {
|
||||
t.Setenv("LOBBY_NOTIFICATION_INTENTS_STREAM", "alt:intents")
|
||||
t.Setenv("LOBBY_ENROLLMENT_AUTOMATION_INTERVAL", "45s")
|
||||
t.Setenv("LOBBY_RACE_NAME_EXPIRATION_INTERVAL", "15m")
|
||||
t.Setenv("LOBBY_ENGINE_IMAGE_TEMPLATE", "registry.example.com/galaxy/game:{engine_version}")
|
||||
t.Setenv("OTEL_SERVICE_NAME", "galaxy-lobby-test")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
@@ -129,6 +131,7 @@ func TestLoadFromEnvOverrides(t *testing.T) {
|
||||
assert.Equal(t, "alt:intents", cfg.Redis.NotificationIntentsStream)
|
||||
assert.Equal(t, 45*time.Second, cfg.EnrollmentAutomation.Interval)
|
||||
assert.Equal(t, 15*time.Minute, cfg.PendingRegistration.Interval)
|
||||
assert.Equal(t, "registry.example.com/galaxy/game:{engine_version}", cfg.RuntimeManager.EngineImageTemplate)
|
||||
assert.Equal(t, "galaxy-lobby-test", cfg.Telemetry.ServiceName)
|
||||
}
|
||||
|
||||
@@ -291,6 +294,34 @@ func TestEnrollmentAutomationConfigValidate(t *testing.T) {
|
||||
require.ErrorContains(t, EnrollmentAutomationConfig{}.Validate(), "interval must be positive")
|
||||
}
|
||||
|
||||
func TestRuntimeManagerConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, RuntimeManagerConfig{EngineImageTemplate: "galaxy/game:{engine_version}"}.Validate())
|
||||
require.ErrorContains(t,
|
||||
RuntimeManagerConfig{EngineImageTemplate: ""}.Validate(),
|
||||
"template must not be empty",
|
||||
)
|
||||
require.ErrorContains(t,
|
||||
RuntimeManagerConfig{EngineImageTemplate: "galaxy/game:1.0.0"}.Validate(),
|
||||
"placeholder",
|
||||
)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidEngineImageTemplate(t *testing.T) {
|
||||
clearAllEnv(t)
|
||||
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
|
||||
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
|
||||
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
|
||||
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
|
||||
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
|
||||
t.Setenv("LOBBY_ENGINE_IMAGE_TEMPLATE", "galaxy/game:no-placeholder")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "LOBBY_ENGINE_IMAGE_TEMPLATE")
|
||||
}
|
||||
|
||||
func TestPendingRegistrationConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -367,6 +398,7 @@ func clearAllEnv(t *testing.T) {
|
||||
enrollmentAutomationIntervalEnvVar,
|
||||
raceNameDirectoryBackendEnvVar,
|
||||
raceNameExpirationIntervalEnvVar,
|
||||
engineImageTemplateEnvVar,
|
||||
otelServiceNameEnvVar,
|
||||
otelTracesExporterEnvVar,
|
||||
otelMetricsExporterEnvVar,
|
||||
|
||||
@@ -108,6 +108,8 @@ func LoadFromEnv() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.RuntimeManager.EngineImageTemplate = stringEnv(engineImageTemplateEnvVar, cfg.RuntimeManager.EngineImageTemplate)
|
||||
|
||||
cfg.Telemetry.ServiceName = stringEnv(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(stringEnv(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = normalizeExporterValue(stringEnv(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
|
||||
|
||||
@@ -41,6 +41,9 @@ func (cfg Config) Validate() error {
|
||||
if err := cfg.PendingRegistration.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", raceNameExpirationIntervalEnvVar, err)
|
||||
}
|
||||
if err := cfg.RuntimeManager.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", engineImageTemplateEnvVar, err)
|
||||
}
|
||||
if err := cfg.Telemetry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package engineimage resolves the Docker reference Lobby publishes on
|
||||
// `runtime:start_jobs`. The reference is built from a configurable
|
||||
// template that must contain the literal `{engine_version}` placeholder
|
||||
// and a per-game `target_engine_version`.
|
||||
//
|
||||
// The resolver intentionally performs only template substitution and a
|
||||
// non-empty-version guard. Semver validation of the engine version
|
||||
// itself lives in `lobby/internal/domain/game` and runs at game-record
|
||||
// construction time; by the time `startgame.Service.Handle` reads the
|
||||
// record the version is already validated.
|
||||
package engineimage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VersionPlaceholder is the literal token a template must contain. The
|
||||
// resolver substitutes it with the per-game engine version verbatim.
|
||||
const VersionPlaceholder = "{engine_version}"
|
||||
|
||||
// Resolver substitutes a per-game engine version into a pre-validated
|
||||
// template. The template is validated once at construction so per-game
|
||||
// `Resolve` calls remain pure string substitution.
|
||||
type Resolver struct {
|
||||
template string
|
||||
}
|
||||
|
||||
// NewResolver returns a Resolver that uses template for every Resolve
|
||||
// call. It returns an error if template is empty or does not contain
|
||||
// VersionPlaceholder.
|
||||
func NewResolver(template string) (*Resolver, error) {
|
||||
trimmed := strings.TrimSpace(template)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("engine image resolver: template must not be empty")
|
||||
}
|
||||
if !strings.Contains(trimmed, VersionPlaceholder) {
|
||||
return nil, fmt.Errorf(
|
||||
"engine image resolver: template %q must contain placeholder %q",
|
||||
template, VersionPlaceholder,
|
||||
)
|
||||
}
|
||||
return &Resolver{template: trimmed}, nil
|
||||
}
|
||||
|
||||
// Template returns the validated template string the resolver was
|
||||
// constructed with. The accessor is intended for diagnostics and tests.
|
||||
func (resolver *Resolver) Template() string {
|
||||
if resolver == nil {
|
||||
return ""
|
||||
}
|
||||
return resolver.template
|
||||
}
|
||||
|
||||
// Resolve substitutes VersionPlaceholder in the validated template with
|
||||
// version. It returns an error when version is empty or whitespace.
|
||||
func (resolver *Resolver) Resolve(version string) (string, error) {
|
||||
if resolver == nil {
|
||||
return "", errors.New("engine image resolver: nil resolver")
|
||||
}
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return "", errors.New("engine image resolver: engine version must not be empty")
|
||||
}
|
||||
return strings.ReplaceAll(resolver.template, VersionPlaceholder, version), nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package engineimage_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"galaxy/lobby/internal/domain/engineimage"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewResolverAcceptsValidTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver, err := engineimage.NewResolver("galaxy/game:{engine_version}")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resolver)
|
||||
assert.Equal(t, "galaxy/game:{engine_version}", resolver.Template())
|
||||
}
|
||||
|
||||
func TestNewResolverRejectsEmptyTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []string{"", " "}
|
||||
for _, candidate := range cases {
|
||||
_, err := engineimage.NewResolver(candidate)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResolverRejectsTemplateWithoutPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := engineimage.NewResolver("galaxy/game:1.0.0")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestResolveSubstitutesVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver, err := engineimage.NewResolver("registry.example.com/galaxy/game:{engine_version}")
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := resolver.Resolve("v1.4.7")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "registry.example.com/galaxy/game:v1.4.7", got)
|
||||
}
|
||||
|
||||
func TestResolveSubstitutesEveryPlaceholderOccurrence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver, err := engineimage.NewResolver(
|
||||
"registry.example.com/{engine_version}/game:{engine_version}",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := resolver.Resolve("v2.0.1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "registry.example.com/v2.0.1/game:v2.0.1", got)
|
||||
}
|
||||
|
||||
func TestResolveRejectsEmptyVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver, err := engineimage.NewResolver("galaxy/game:{engine_version}")
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []string{"", " "}
|
||||
for _, candidate := range cases {
|
||||
_, err := resolver.Resolve(candidate)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReusesValidatedTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver, err := engineimage.NewResolver("galaxy/game:{engine_version}")
|
||||
require.NoError(t, err)
|
||||
|
||||
first, err := resolver.Resolve("v1.0.0")
|
||||
require.NoError(t, err)
|
||||
second, err := resolver.Resolve("v2.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "galaxy/game:v1.0.0", first)
|
||||
assert.Equal(t, "galaxy/game:v2.0.0", second)
|
||||
}
|
||||
|
||||
func TestNilResolverResolveReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var resolver *engineimage.Resolver
|
||||
_, err := resolver.Resolve("v1.0.0")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
// to `paused` and an admin notification is published.
|
||||
var ErrGMUnavailable = errors.New("game master unavailable")
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_gmclient.go -package=mocks galaxy/lobby/internal/ports GMClient
|
||||
|
||||
// GMClient executes synchronous calls to Game Master. introduced
|
||||
// the registration call; added the liveness probe used by the
|
||||
// voluntary resume flow.
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"galaxy/notificationintent"
|
||||
)
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_intentpublisher.go -package=mocks galaxy/lobby/internal/ports IntentPublisher
|
||||
|
||||
// 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.
|
||||
// service tests use a generated gomock 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
|
||||
|
||||
@@ -1,25 +1,92 @@
|
||||
package ports
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// StopReason classifies why Lobby asks Runtime Manager to stop a game
|
||||
// container. The enum is part of the `runtime:stop_jobs` envelope and
|
||||
// mirrors the AsyncAPI contract frozen in
|
||||
// `rtmanager/api/runtime-jobs-asyncapi.yaml`.
|
||||
//
|
||||
// Lobby v1 produces only StopReasonOrphanCleanup (orphan-container path
|
||||
// in the runtime-job-result worker) and StopReasonCancelled
|
||||
// (user-lifecycle cascade). The remaining values are reserved in the
|
||||
// shared contract for future producers (Game Master, Admin Service,
|
||||
// enrollment automation).
|
||||
type StopReason string
|
||||
|
||||
// StopReason enum values. The set is fixed by
|
||||
// `rtmanager/api/runtime-jobs-asyncapi.yaml`; adding a new value is a
|
||||
// contract bump that must be coordinated across producers and consumers.
|
||||
const (
|
||||
// StopReasonOrphanCleanup releases a container whose post-start
|
||||
// metadata persistence failed in Lobby.
|
||||
StopReasonOrphanCleanup StopReason = "orphan_cleanup"
|
||||
|
||||
// StopReasonCancelled covers user-lifecycle cascade and explicit
|
||||
// cancel paths for in-flight games.
|
||||
StopReasonCancelled StopReason = "cancelled"
|
||||
|
||||
// StopReasonFinished is reserved for engine-driven game finish
|
||||
// flows; not produced by Lobby in v1.
|
||||
StopReasonFinished StopReason = "finished"
|
||||
|
||||
// StopReasonAdminRequest is reserved for future admin-initiated
|
||||
// stop paths through Lobby; not produced by Lobby in v1.
|
||||
StopReasonAdminRequest StopReason = "admin_request"
|
||||
|
||||
// StopReasonTimeout is reserved for future enrollment-timeout-driven
|
||||
// stop paths; not produced by Lobby in v1.
|
||||
StopReasonTimeout StopReason = "timeout"
|
||||
)
|
||||
|
||||
// String returns reason as its stored enum value.
|
||||
func (reason StopReason) String() string {
|
||||
return string(reason)
|
||||
}
|
||||
|
||||
// Validate reports whether reason carries one of the five values fixed
|
||||
// by the AsyncAPI contract.
|
||||
func (reason StopReason) Validate() error {
|
||||
switch reason {
|
||||
case StopReasonOrphanCleanup,
|
||||
StopReasonCancelled,
|
||||
StopReasonFinished,
|
||||
StopReasonAdminRequest,
|
||||
StopReasonTimeout:
|
||||
return nil
|
||||
case "":
|
||||
return fmt.Errorf("stop reason must not be empty")
|
||||
default:
|
||||
return fmt.Errorf("stop reason %q is not a recognised value", string(reason))
|
||||
}
|
||||
}
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_runtimemanager.go -package=mocks galaxy/lobby/internal/ports RuntimeManager
|
||||
|
||||
// RuntimeManager publishes runtime jobs to Runtime Manager via Redis
|
||||
// Streams. introduces start and stop jobs; future stages may
|
||||
// extend the surface.
|
||||
// Streams. Lobby is the producer for both the start and the stop stream;
|
||||
// Runtime Manager (Stages 13+) is the eventual consumer.
|
||||
//
|
||||
// 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.
|
||||
// Image-reference resolution is intentionally a Lobby concern: each
|
||||
// game's `target_engine_version` is substituted into
|
||||
// `LOBBY_ENGINE_IMAGE_TEMPLATE` and the resulting `image_ref` is handed
|
||||
// to Runtime Manager verbatim on the start envelope. Runtime Manager
|
||||
// never resolves engine versions itself.
|
||||
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
|
||||
// PublishStartJob enqueues one start job for gameID with the
|
||||
// producer-resolved imageRef. 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
|
||||
PublishStartJob(ctx context.Context, gameID, imageRef 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
|
||||
// PublishStopJob enqueues one stop job for gameID with the
|
||||
// classifying reason. 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, reason StopReason) error
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ type Eligibility struct {
|
||||
MaxRegisteredRaceNames int
|
||||
}
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_userservice.go -package=mocks galaxy/lobby/internal/ports UserService
|
||||
|
||||
// UserService is the synchronous lobby-facing User Service eligibility
|
||||
// reader. The application flow consumes it via a single
|
||||
// GetEligibility call before accepting an applicant.
|
||||
|
||||
@@ -5,15 +5,16 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gapactivationstub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gapactivationinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -25,8 +26,44 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
|
||||
@@ -44,12 +81,13 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) { return f.me
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
applications *applicationstub.Store
|
||||
directory *racenamestub.Directory
|
||||
gapStore *gapactivationstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
gapStore *gapactivationinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
openPublicGameID common.GameID
|
||||
}
|
||||
@@ -57,11 +95,11 @@ type fixture struct {
|
||||
func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
applications := applicationstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
applications := applicationinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-public",
|
||||
@@ -80,14 +118,16 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
rec := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
memberships: memberships,
|
||||
applications: applications,
|
||||
directory: dir,
|
||||
gapStore: gapactivationstub.NewStore(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
gapStore: gapactivationinmem.NewStore(),
|
||||
intentRec: rec,
|
||||
intents: newIntentMock(t, rec),
|
||||
ids: fixedIDs{membershipID: "membership-fixed"},
|
||||
openPublicGameID: gameRecord.GameID,
|
||||
}
|
||||
@@ -151,7 +191,7 @@ func TestApproveHappyPath(t *testing.T) {
|
||||
assert.True(t, availability.Taken)
|
||||
assert.Equal(t, "user-1", availability.HolderUserID)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipApproved, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{"user-1"}, intents[0].RecipientUserIDs)
|
||||
@@ -328,10 +368,10 @@ func TestApproveNameTakenByAnotherUser(t *testing.T) {
|
||||
assert.Equal(t, "user-other", availability.HolderUserID)
|
||||
}
|
||||
|
||||
// approveCASStub wraps applicationstub.Store but injects ErrConflict on
|
||||
// approveCASStub wraps applicationinmem.Store but injects ErrConflict on
|
||||
// the next UpdateStatus call so we can observe the rollback path.
|
||||
type approveCASStub struct {
|
||||
*applicationstub.Store
|
||||
*applicationinmem.Store
|
||||
failNext bool
|
||||
}
|
||||
|
||||
@@ -379,7 +419,7 @@ func TestApprovePublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot")
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intentRec.setErr(errors.New("publish failed"))
|
||||
|
||||
svc := newService(t, f)
|
||||
got, err := svc.Handle(context.Background(), approveapplication.Input{
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -31,20 +31,20 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
}
|
||||
|
||||
type fixtures struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
directory *racenamestub.Directory
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
}
|
||||
|
||||
func newFixtures(t *testing.T) *fixtures {
|
||||
t.Helper()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &fixtures{
|
||||
games: gamestub.NewStore(),
|
||||
memberships: membershipstub.NewStore(),
|
||||
games: gameinmem.NewStore(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
directory: directory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -31,7 +31,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// status the surface must reject or accept.
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -101,7 +101,7 @@ func TestHandleFromCancellableStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-a", game.GameTypePublic, "", status, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -131,7 +131,7 @@ func TestHandleFromRejectedStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-b", game.GameTypePublic, "", status, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -149,7 +149,7 @@ func TestHandleAlreadyCancelledIsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-c", game.GameTypePublic, "", game.StatusCancelled, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -165,7 +165,7 @@ func TestHandleFinishedIsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-f", game.GameTypePublic, "", game.StatusFinished, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -181,7 +181,7 @@ func TestHandleOwnerCancelsPrivate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-1", game.StatusEnrollmentOpen, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -198,7 +198,7 @@ func TestHandleNonOwnerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-1", game.StatusEnrollmentOpen, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -214,7 +214,7 @@ func TestHandleUserCannotCancelPublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusEnrollmentOpen, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -229,7 +229,7 @@ func TestHandleUserCannotCancelPublic(t *testing.T) {
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), cancelgame.Input{
|
||||
@@ -242,7 +242,7 @@ func TestHandleNotFound(t *testing.T) {
|
||||
func TestHandleInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), cancelgame.Input{
|
||||
@@ -256,7 +256,7 @@ func TestHandleInvalidActor(t *testing.T) {
|
||||
func TestHandleInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), cancelgame.Input{
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/evaluationguardstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameturnstatsstub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/evaluationguardinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gameturnstatsinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -51,12 +51,12 @@ type fixture struct {
|
||||
finishedAt time.Time
|
||||
gameID common.GameID
|
||||
gameName string
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
stats *gameturnstatsstub.Store
|
||||
directory *racenamestub.Directory
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
stats *gameturnstatsinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
intents *spyIntents
|
||||
guard *evaluationguardstub.Store
|
||||
guard *evaluationguardinmem.Store
|
||||
service *capabilityevaluation.Service
|
||||
}
|
||||
|
||||
@@ -65,13 +65,13 @@ func newFixture(t *testing.T) *fixture {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
finishedAt := now
|
||||
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
stats := gameturnstatsstub.NewStore()
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now.Add(-time.Hour))))
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
stats := gameturnstatsinmem.NewStore()
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now.Add(-time.Hour))))
|
||||
require.NoError(t, err)
|
||||
intents := &spyIntents{}
|
||||
guard := evaluationguardstub.NewStore()
|
||||
guard := evaluationguardinmem.NewStore()
|
||||
|
||||
gameID := common.GameID("game-finished")
|
||||
gameName := "Final Showdown"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/idgen"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -88,11 +88,11 @@ func TestNewServiceRequiresStoreAndIDs(t *testing.T) {
|
||||
_, err := creategame.NewService(creategame.Dependencies{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = creategame.NewService(creategame.Dependencies{Games: gamestub.NewStore()})
|
||||
_, err = creategame.NewService(creategame.Dependencies{Games: gameinmem.NewStore()})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-ok"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -102,7 +102,7 @@ func TestHandleAdminCreatesPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: &stubIDGenerator{next: "game-alpha"},
|
||||
@@ -129,7 +129,7 @@ func TestHandleUserCreatesPrivateGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 11, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: &stubIDGenerator{next: "game-beta"},
|
||||
@@ -150,7 +150,7 @@ func TestHandleAdminForbiddenForPrivateGame(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -169,7 +169,7 @@ func TestHandleUserForbiddenForPublicGame(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -188,7 +188,7 @@ func TestHandleInvalidActorReturnsError(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -208,7 +208,7 @@ func TestHandleDomainValidationFailurePropagates(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-bad-cron"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -228,7 +228,7 @@ func TestHandleEnrollmentDeadlineInPastFails(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-past"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -249,7 +249,7 @@ func TestHandleIDGeneratorErrorPropagates(t *testing.T) {
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
boom := errors.New("entropy exhausted")
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{err: boom},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -309,7 +309,7 @@ func TestHandleUsesRealIDGeneratorShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: idgen.NewGenerator(),
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -23,8 +24,46 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// intentRec captures every Publish call so tests can assert on the
|
||||
// resulting intent. Per-test error injection sets err.
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
ownerUserID = "user-owner"
|
||||
inviteeUserID = "user-invitee"
|
||||
@@ -45,10 +84,11 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) { return "",
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
game game.Game
|
||||
}
|
||||
@@ -56,9 +96,9 @@ type fixture struct {
|
||||
func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-private",
|
||||
@@ -78,12 +118,13 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
rec := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invites,
|
||||
memberships: memberships,
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
intentRec: rec,
|
||||
ids: fixedIDs{inviteID: "invite-fixed"},
|
||||
game: gameRecord,
|
||||
}
|
||||
@@ -91,6 +132,9 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
|
||||
func newService(t *testing.T, f *fixture) *createinvite.Service {
|
||||
t.Helper()
|
||||
if f.intents == nil {
|
||||
f.intents = newIntentMock(t, f.intentRec)
|
||||
}
|
||||
svc, err := createinvite.NewService(createinvite.Dependencies{
|
||||
Games: f.games,
|
||||
Invites: f.invites,
|
||||
@@ -127,7 +171,7 @@ func TestHandleHappyPath(t *testing.T) {
|
||||
assert.Equal(t, f.game.EnrollmentEndsAt, got.ExpiresAt)
|
||||
assert.Empty(t, got.RaceName)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteCreated, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{inviteeUserID}, intents[0].RecipientUserIDs)
|
||||
@@ -316,7 +360,7 @@ func TestHandleInviterNameUsesActiveMembershipRaceName(t *testing.T) {
|
||||
_, err = svc.Handle(context.Background(), defaultInput(f))
|
||||
require.NoError(t, err)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Contains(t, intents[0].PayloadJSON, `"inviter_name":"OwnerRace"`)
|
||||
}
|
||||
@@ -329,7 +373,7 @@ func TestHandleInviterNameFallsBackToUserID(t *testing.T) {
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
require.NoError(t, err)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Contains(t, intents[0].PayloadJSON, `"inviter_name":"`+ownerUserID+`"`)
|
||||
}
|
||||
@@ -337,7 +381,7 @@ func TestHandleInviterNameFallsBackToUserID(t *testing.T) {
|
||||
func TestHandlePublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intentRec.setErr(errors.New("publish failed"))
|
||||
svc := newService(t, f)
|
||||
|
||||
got, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -30,14 +30,14 @@ func fixedClock(at time.Time) func() time.Time { return func() time.Time { retur
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
invites *invitestub.Store
|
||||
invites *inviteinmem.Store
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
return &fixture{
|
||||
now: time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC),
|
||||
invites: invitestub.NewStore(),
|
||||
invites: inviteinmem.NewStore(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -27,17 +27,17 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
invites *invitestub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
svc *getgame.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
svc, err := getgame.NewService(getgame.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -55,7 +55,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -88,7 +88,7 @@ func seedGame(
|
||||
|
||||
func seedMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
status membership.Status,
|
||||
@@ -121,7 +121,7 @@ func seedMembership(
|
||||
|
||||
func seedInvite(
|
||||
t *testing.T,
|
||||
store *invitestub.Store,
|
||||
store *inviteinmem.Store,
|
||||
gameID common.GameID,
|
||||
inviterID, inviteeID string,
|
||||
status invite.Status,
|
||||
@@ -364,9 +364,9 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps getgame.Dependencies
|
||||
}{
|
||||
{"nil games", getgame.Dependencies{Memberships: membershipstub.NewStore(), Invites: invitestub.NewStore()}},
|
||||
{"nil memberships", getgame.Dependencies{Games: gamestub.NewStore(), Invites: invitestub.NewStore()}},
|
||||
{"nil invites", getgame.Dependencies{Games: gamestub.NewStore(), Memberships: membershipstub.NewStore()}},
|
||||
{"nil games", getgame.Dependencies{Memberships: membershipinmem.NewStore(), Invites: inviteinmem.NewStore()}},
|
||||
{"nil memberships", getgame.Dependencies{Games: gameinmem.NewStore(), Invites: inviteinmem.NewStore()}},
|
||||
{"nil invites", getgame.Dependencies{Games: gameinmem.NewStore(), Memberships: membershipinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -380,12 +380,12 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
func TestHandleSurfacesStoreError(t *testing.T) {
|
||||
// Sanity check that errors from the membership store bubble up wrapped.
|
||||
t.Parallel()
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := &erroringMemberships{err: errors.New("stub failure")}
|
||||
svc, err := getgame.NewService(getgame.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
Invites: invitestub.NewStore(),
|
||||
Invites: inviteinmem.NewStore(),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -401,7 +401,7 @@ func TestHandleSurfacesStoreError(t *testing.T) {
|
||||
}
|
||||
|
||||
type erroringMemberships struct {
|
||||
membershipstub.Store
|
||||
membershipinmem.Store
|
||||
err error
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -23,15 +23,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listgames.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listgames.NewService(listgames.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -43,7 +43,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGameAt(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -76,7 +76,7 @@ func seedGameAt(
|
||||
|
||||
func seedActiveMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
now time.Time,
|
||||
@@ -289,8 +289,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listgames.Dependencies
|
||||
}{
|
||||
{"nil games", listgames.Dependencies{Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listgames.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listgames.Dependencies{Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listgames.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -24,15 +24,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listmemberships.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listmemberships.NewService(listmemberships.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -44,7 +44,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -71,7 +71,7 @@ func seedGame(
|
||||
|
||||
func seedMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
status membership.Status,
|
||||
@@ -230,8 +230,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmemberships.Dependencies
|
||||
}{
|
||||
{"nil games", listmemberships.Dependencies{Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listmemberships.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listmemberships.Dependencies{Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listmemberships.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -24,15 +24,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
applications *applicationstub.Store
|
||||
games *gameinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
svc *listmyapplications.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
apps := applicationstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
apps := applicationinmem.NewStore()
|
||||
svc, err := listmyapplications.NewService(listmyapplications.Dependencies{
|
||||
Games: games,
|
||||
Applications: apps,
|
||||
@@ -44,7 +44,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
name string,
|
||||
@@ -75,7 +75,7 @@ func seedGame(
|
||||
|
||||
func seedApplication(
|
||||
t *testing.T,
|
||||
store *applicationstub.Store,
|
||||
store *applicationinmem.Store,
|
||||
id common.ApplicationID,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
@@ -180,8 +180,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmyapplications.Dependencies
|
||||
}{
|
||||
{"nil games", listmyapplications.Dependencies{Applications: applicationstub.NewStore()}},
|
||||
{"nil applications", listmyapplications.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listmyapplications.Dependencies{Applications: applicationinmem.NewStore()}},
|
||||
{"nil applications", listmyapplications.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -24,15 +24,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listmygames.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listmygames.NewService(listmygames.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -44,7 +44,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
status game.Status,
|
||||
now time.Time,
|
||||
@@ -78,7 +78,7 @@ func seedGameWithStatus(
|
||||
|
||||
func seedMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
status membership.Status,
|
||||
@@ -188,8 +188,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmygames.Dependencies
|
||||
}{
|
||||
{"nil games", listmygames.Dependencies{Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listmygames.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listmygames.Dependencies{Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listmygames.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -26,17 +26,17 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listmyinvites.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listmyinvites.NewService(listmyinvites.Dependencies{
|
||||
Games: games,
|
||||
Invites: invites,
|
||||
@@ -49,7 +49,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedPrivateGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
owner string,
|
||||
name string,
|
||||
@@ -76,7 +76,7 @@ func seedPrivateGame(
|
||||
|
||||
func seedInvite(
|
||||
t *testing.T,
|
||||
store *invitestub.Store,
|
||||
store *inviteinmem.Store,
|
||||
id common.InviteID,
|
||||
gameID common.GameID,
|
||||
inviter, invitee string,
|
||||
@@ -110,7 +110,7 @@ func seedInvite(
|
||||
|
||||
func seedActiveMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID, raceName string,
|
||||
now time.Time,
|
||||
@@ -222,9 +222,9 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmyinvites.Dependencies
|
||||
}{
|
||||
{"nil games", listmyinvites.Dependencies{Invites: invitestub.NewStore(), Memberships: membershipstub.NewStore()}},
|
||||
{"nil invites", listmyinvites.Dependencies{Games: gamestub.NewStore(), Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listmyinvites.Dependencies{Games: gamestub.NewStore(), Invites: invitestub.NewStore()}},
|
||||
{"nil games", listmyinvites.Dependencies{Invites: inviteinmem.NewStore(), Memberships: membershipinmem.NewStore()}},
|
||||
{"nil invites", listmyinvites.Dependencies{Games: gameinmem.NewStore(), Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listmyinvites.Dependencies{Games: gameinmem.NewStore(), Invites: inviteinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -28,17 +28,17 @@ func silentLogger() *slog.Logger {
|
||||
// race-name directory stub and the in-process game store.
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
games *gamestub.Store
|
||||
directory *racenameinmem.Directory
|
||||
games *gameinmem.Store
|
||||
service *listmyracenames.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
Games: games,
|
||||
@@ -217,9 +217,9 @@ func TestHandleSortByTimestamp(t *testing.T) {
|
||||
const userID = "user-sort"
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
clock := now
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return clock }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return clock }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
Games: games,
|
||||
@@ -281,9 +281,9 @@ func TestHandleSortByTimestamp(t *testing.T) {
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
|
||||
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Games: games,
|
||||
@@ -299,4 +299,4 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
// Sanity guard so a future port refactor that drops the user-keyed
|
||||
// indexes immediately breaks the test build instead of silently
|
||||
// regressing the no-full-scan invariant.
|
||||
var _ ports.RaceNameDirectory = (*racenamestub.Directory)(nil)
|
||||
var _ ports.RaceNameDirectory = (*racenameinmem.Directory)(nil)
|
||||
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -21,8 +22,34 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
publicGameID = common.GameID("game-public")
|
||||
privateGameID = common.GameID("game-private")
|
||||
@@ -35,22 +62,26 @@ func fixedClock(at time.Time) func() time.Time { return func() time.Time { retur
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
return &fixture{
|
||||
rec := &intentRec{}
|
||||
f := &fixture{
|
||||
now: now,
|
||||
games: gamestub.NewStore(),
|
||||
invites: invitestub.NewStore(),
|
||||
memberships: membershipstub.NewStore(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
games: gameinmem.NewStore(),
|
||||
invites: inviteinmem.NewStore(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
intentRec: rec,
|
||||
}
|
||||
f.intents = newIntentMock(t, rec)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) addGame(t *testing.T, gameID common.GameID, gameType game.GameType, owner string, minPlayers int) game.Game {
|
||||
@@ -154,7 +185,7 @@ func TestHandleOwnerClosesPrivateEnrollmentAndExpiresInvites(t *testing.T) {
|
||||
assert.Equal(t, invite.StatusExpired, rec.Status)
|
||||
}
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 2)
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intent.NotificationType)
|
||||
@@ -231,7 +262,7 @@ func TestHandleBelowMinPlayersConflict(t *testing.T) {
|
||||
current, err := f.games.Get(context.Background(), publicGameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusEnrollmentOpen, current.Status)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
func TestHandleEmptyInvitesProducesNoNotifications(t *testing.T) {
|
||||
@@ -246,5 +277,5 @@ func TestHandleEmptyInvitesProducesNoNotifications(t *testing.T) {
|
||||
GameID: privateGameID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -27,7 +27,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
|
||||
func seedDraftGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -71,7 +71,7 @@ func TestHandleAdminHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-alpha", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -89,7 +89,7 @@ func TestHandleOwnerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-p", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -106,7 +106,7 @@ func TestHandleNonOwnerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-p", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -122,7 +122,7 @@ func TestHandleUserCannotOpenPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-pub", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -138,7 +138,7 @@ func TestHandleFromEnrollmentOpenConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-x", game.GameTypePublic, "", now)
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
@@ -161,7 +161,7 @@ func TestHandleFromReadyToStartInvalidTransition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-rts", game.GameTypePublic, "", now)
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
@@ -191,7 +191,7 @@ func TestHandleFromReadyToStartInvalidTransition(t *testing.T) {
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), openenrollment.Input{
|
||||
@@ -204,7 +204,7 @@ func TestHandleNotFound(t *testing.T) {
|
||||
func TestHandleInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), openenrollment.Input{
|
||||
@@ -218,7 +218,7 @@ func TestHandleInvalidActor(t *testing.T) {
|
||||
func TestHandleInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), openenrollment.Input{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -32,7 +32,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// any source status.
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -98,7 +98,7 @@ func TestPauseGameAdminHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusRunning, now)
|
||||
|
||||
at := now.Add(time.Hour)
|
||||
@@ -117,7 +117,7 @@ func TestPauseGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusRunning, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -134,7 +134,7 @@ func TestPauseGameRejectsNonOwnerUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusRunning, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -150,7 +150,7 @@ func TestPauseGameRejectsUserActorOnPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusRunning, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -181,7 +181,7 @@ func TestPauseGameRejectsWrongStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-x", game.GameTypePublic, "", status, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -197,7 +197,7 @@ func TestPauseGameRejectsWrongStatuses(t *testing.T) {
|
||||
func TestPauseGameRejectsMissingRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), pausegame.Input{
|
||||
@@ -210,7 +210,7 @@ func TestPauseGameRejectsMissingRecord(t *testing.T) {
|
||||
func TestPauseGameInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), pausegame.Input{
|
||||
@@ -224,7 +224,7 @@ func TestPauseGameInvalidActor(t *testing.T) {
|
||||
func TestPauseGameInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), pausegame.Input{
|
||||
|
||||
@@ -5,16 +5,16 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gapactivationstub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gapactivationinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -26,8 +26,87 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
type userRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
ownerUserID = "user-owner"
|
||||
inviteeUserID = "user-invitee"
|
||||
@@ -49,13 +128,15 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) { return f.me
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
gapStore *gapactivationstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
users *userRec
|
||||
usersMock *mocks.MockUserService
|
||||
gapStore *gapactivationinmem.Store
|
||||
intents *intentRec
|
||||
intentsMock *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
game game.Game
|
||||
}
|
||||
@@ -63,11 +144,11 @@ type fixture struct {
|
||||
func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-private",
|
||||
@@ -87,7 +168,7 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
users := userservicestub.NewService()
|
||||
users := &userRec{}
|
||||
activeEligibility := ports.Eligibility{
|
||||
Exists: true,
|
||||
CanLogin: true,
|
||||
@@ -96,9 +177,10 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
CanJoinGame: true,
|
||||
CanUpdateProfile: true,
|
||||
}
|
||||
users.SetEligibility(ownerUserID, activeEligibility)
|
||||
users.SetEligibility(inviteeUserID, activeEligibility)
|
||||
users.setEligibility(ownerUserID, activeEligibility)
|
||||
users.setEligibility(inviteeUserID, activeEligibility)
|
||||
|
||||
intents := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
@@ -106,8 +188,10 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
memberships: memberships,
|
||||
directory: dir,
|
||||
users: users,
|
||||
gapStore: gapactivationstub.NewStore(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
usersMock: newUserMock(t, users),
|
||||
gapStore: gapactivationinmem.NewStore(),
|
||||
intents: intents,
|
||||
intentsMock: newIntentMock(t, intents),
|
||||
ids: fixedIDs{membershipID: "membership-fixed"},
|
||||
game: gameRecord,
|
||||
}
|
||||
@@ -120,9 +204,9 @@ func newService(t *testing.T, f *fixture) *redeeminvite.Service {
|
||||
Invites: f.invites,
|
||||
Memberships: f.memberships,
|
||||
Directory: f.directory,
|
||||
Users: f.users,
|
||||
Users: f.usersMock,
|
||||
GapStore: f.gapStore,
|
||||
Intents: f.intents,
|
||||
Intents: f.intentsMock,
|
||||
IDs: f.ids,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
@@ -181,7 +265,7 @@ func TestRedeemHappyPath(t *testing.T) {
|
||||
assert.True(t, avail.Taken)
|
||||
assert.Equal(t, inviteeUserID, avail.HolderUserID)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteRedeemed, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{ownerUserID}, intents[0].RecipientUserIDs)
|
||||
@@ -194,7 +278,7 @@ func TestRedeemRejectsInviterPermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetEligibility(ownerUserID, ports.Eligibility{
|
||||
f.users.setEligibility(ownerUserID, ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
})
|
||||
@@ -212,7 +296,7 @@ func TestRedeemRejectsInviteePermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetEligibility(inviteeUserID, ports.Eligibility{
|
||||
f.users.setEligibility(inviteeUserID, ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
})
|
||||
@@ -226,7 +310,7 @@ func TestRedeemRejectsDeletedInviter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetEligibility(ownerUserID, ports.Eligibility{Exists: false})
|
||||
f.users.setEligibility(ownerUserID, ports.Eligibility{Exists: false})
|
||||
svc := newService(t, f)
|
||||
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f, inv))
|
||||
@@ -237,7 +321,7 @@ func TestRedeemSurfacesUserServiceTransportFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetFailure(ownerUserID, ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure(ownerUserID, ports.ErrUserServiceUnavailable)
|
||||
svc := newService(t, f)
|
||||
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f, inv))
|
||||
@@ -410,10 +494,10 @@ func TestRedeemInvalidRaceName(t *testing.T) {
|
||||
require.ErrorIs(t, err, ports.ErrInvalidName)
|
||||
}
|
||||
|
||||
// redeemCASStub wraps invitestub.Store but injects ErrConflict on the next
|
||||
// redeemCASStub wraps inviteinmem.Store but injects ErrConflict on the next
|
||||
// UpdateStatus call so we can observe the rollback path.
|
||||
type redeemCASStub struct {
|
||||
*invitestub.Store
|
||||
*inviteinmem.Store
|
||||
failNext bool
|
||||
}
|
||||
|
||||
@@ -436,9 +520,9 @@ func TestRedeemCASConflictReleasesReservation(t *testing.T) {
|
||||
Invites: cas,
|
||||
Memberships: f.memberships,
|
||||
Directory: f.directory,
|
||||
Users: f.users,
|
||||
Users: f.usersMock,
|
||||
GapStore: f.gapStore,
|
||||
Intents: f.intents,
|
||||
Intents: f.intentsMock,
|
||||
IDs: f.ids,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
@@ -458,7 +542,7 @@ func TestRedeemPublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intents.setErr(errors.New("publish failed"))
|
||||
|
||||
svc := newService(t, f)
|
||||
got, err := svc.Handle(context.Background(), defaultInput(f, inv))
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
@@ -19,28 +19,113 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
type userRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
directory *racenameinmem.Directory
|
||||
users *userRec
|
||||
usersMock *mocks.MockUserService
|
||||
intents *intentRec
|
||||
pubMock *mocks.MockIntentPublisher
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, now time.Time) *fixture {
|
||||
t.Helper()
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
users := &userRec{}
|
||||
intents := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
directory: directory,
|
||||
users: userservicestub.NewService(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
users: users,
|
||||
usersMock: newUserMock(t, users),
|
||||
intents: intents,
|
||||
pubMock: newIntentMock(t, intents),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +133,8 @@ func (f *fixture) newService(t *testing.T) *registerracename.Service {
|
||||
t.Helper()
|
||||
svc, err := registerracename.NewService(registerracename.Dependencies{
|
||||
Directory: f.directory,
|
||||
Users: f.users,
|
||||
Intents: f.intents,
|
||||
Users: f.usersMock,
|
||||
Intents: f.pubMock,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
@@ -102,7 +187,7 @@ func TestRegisterRaceNameHappyPath(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -128,7 +213,7 @@ func TestRegisterRaceNameHappyPath(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
intent := intents[0]
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistered, intent.NotificationType)
|
||||
@@ -144,7 +229,7 @@ func TestRegisterRaceNameIdempotentRetry(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(1))
|
||||
f.users.setEligibility("user-1", defaultEligibility(1))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -167,7 +252,7 @@ func TestRegisterRaceNameIdempotentRetry(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, registered, 1, "registration must remain idempotent")
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 2, "idempotent retry republishes the intent")
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, "lobby.race_name.registered:game-1:user-1", intent.IdempotencyKey)
|
||||
@@ -257,7 +342,7 @@ func TestRegisterRaceNameRejectsPermanentBlock(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{
|
||||
f.users.setEligibility("user-1", ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
MaxRegisteredRaceNames: 2,
|
||||
@@ -278,7 +363,7 @@ func TestRegisterRaceNamePendingMissing(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
|
||||
svc := f.newService(t)
|
||||
_, err := svc.Handle(context.Background(), registerracename.Input{
|
||||
@@ -294,7 +379,7 @@ func TestRegisterRaceNamePendingForOtherUserSurfacesAsMissing(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
// Pending exists for a different user; the actor has none.
|
||||
f.seedPending(t, "game-1", "user-other", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
@@ -316,7 +401,7 @@ func TestRegisterRaceNamePendingExpired(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
// Pending elig until is in the past relative to now.
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(-time.Minute))
|
||||
|
||||
@@ -335,7 +420,7 @@ func TestRegisterRaceNameQuotaExceeded(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
// Free-tier quota = 1; user already has one registered name.
|
||||
f.users.SetEligibility("user-1", defaultEligibility(1))
|
||||
f.users.setEligibility("user-1", defaultEligibility(1))
|
||||
f.seedRegistered(t, "game-existing", "user-1", "OldName")
|
||||
f.seedPending(t, "game-new", "user-1", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
@@ -354,7 +439,7 @@ func TestRegisterRaceNameUnlimitedQuotaAllowsManyRegistrations(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
// MaxRegisteredRaceNames=0 marker → unlimited.
|
||||
f.users.SetEligibility("user-1", defaultEligibility(0))
|
||||
f.users.setEligibility("user-1", defaultEligibility(0))
|
||||
f.seedRegistered(t, "game-a", "user-1", "First")
|
||||
f.seedRegistered(t, "game-b", "user-1", "Second")
|
||||
f.seedPending(t, "game-c", "user-1", "Third", now.Add(24*time.Hour))
|
||||
@@ -373,7 +458,7 @@ func TestRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -390,9 +475,9 @@ func TestRegisterRaceNameCommitsEvenIfPublishFails(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
f.intents.SetError(errors.New("notification stream unavailable"))
|
||||
f.intents.setErr(errors.New("notification stream unavailable"))
|
||||
|
||||
svc := f.newService(t)
|
||||
out, err := svc.Handle(context.Background(), registerracename.Input{
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -22,28 +23,65 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
applications *applicationstub.Store
|
||||
directory *racenamestub.Directory
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
openPublicGameID common.GameID
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
applications := applicationstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
applications := applicationinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-public",
|
||||
@@ -62,18 +100,22 @@ func newFixture(t *testing.T) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
rec := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
applications: applications,
|
||||
directory: dir,
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
intentRec: rec,
|
||||
openPublicGameID: gameRecord.GameID,
|
||||
}
|
||||
}
|
||||
|
||||
func newService(t *testing.T, f *fixture) *rejectapplication.Service {
|
||||
t.Helper()
|
||||
if f.intents == nil {
|
||||
f.intents = newIntentMock(t, f.intentRec)
|
||||
}
|
||||
svc, err := rejectapplication.NewService(rejectapplication.Dependencies{
|
||||
Games: f.games,
|
||||
Applications: f.applications,
|
||||
@@ -116,7 +158,7 @@ func TestRejectHappyPath(t *testing.T) {
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.Equal(t, f.now, got.DecidedAt.UTC())
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipRejected, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{"user-1"}, intents[0].RecipientUserIDs)
|
||||
@@ -208,7 +250,7 @@ func TestRejectPublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot")
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intentRec.setErr(errors.New("publish failed"))
|
||||
|
||||
svc := newService(t, f)
|
||||
got, err := svc.Handle(context.Background(), rejectapplication.Input{
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -31,20 +31,20 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
}
|
||||
|
||||
type fixtures struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
directory *racenamestub.Directory
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
}
|
||||
|
||||
func newFixtures(t *testing.T) *fixtures {
|
||||
t.Helper()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &fixtures{
|
||||
games: gamestub.NewStore(),
|
||||
memberships: membershipstub.NewStore(),
|
||||
games: gameinmem.NewStore(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
directory: directory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gmclientstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
@@ -33,7 +34,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// source status.
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -94,13 +95,18 @@ func newService(
|
||||
return svc
|
||||
}
|
||||
|
||||
func newGMMock(t *testing.T) *mocks.MockGMClient {
|
||||
t.Helper()
|
||||
return mocks.NewMockGMClient(gomock.NewController(t))
|
||||
}
|
||||
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := resumegame.NewService(resumegame.Dependencies{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = resumegame.NewService(resumegame.Dependencies{Games: gamestub.NewStore()})
|
||||
_, err = resumegame.NewService(resumegame.Dependencies{Games: gameinmem.NewStore()})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -108,10 +114,11 @@ func TestResumeGameAdminHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
gm.EXPECT().Ping(gomock.Any()).Return(nil).Times(1)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
updated, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -120,17 +127,17 @@ func TestResumeGameAdminHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusRunning, updated.Status)
|
||||
assert.Equal(t, 1, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
gm.EXPECT().Ping(gomock.Any()).Return(nil).Times(1)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
updated, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -139,17 +146,16 @@ func TestResumeGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusRunning, updated.Status)
|
||||
assert.Equal(t, 1, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGameRejectsNonOwnerUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -157,17 +163,16 @@ func TestResumeGameRejectsNonOwnerUser(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
assert.Equal(t, 0, gm.PingCalls(), "ping must not run before authorization passes")
|
||||
}
|
||||
|
||||
func TestResumeGameRejectsUserActorOnPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -175,7 +180,6 @@ func TestResumeGameRejectsUserActorOnPublicGame(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
assert.Equal(t, 0, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGameRejectsWrongStatuses(t *testing.T) {
|
||||
@@ -197,10 +201,10 @@ func TestResumeGameRejectsWrongStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-x", game.GameTypePublic, "", status, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -208,7 +212,6 @@ func TestResumeGameRejectsWrongStatuses(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Equal(t, 0, gm.PingCalls(), "ping must not run before status check passes")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -217,11 +220,13 @@ func TestResumeGameGMUnavailableKeepsPaused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm.SetPingError(errors.Join(ports.ErrGMUnavailable, errors.New("dial tcp: connection refused")))
|
||||
gm := newGMMock(t)
|
||||
gm.EXPECT().Ping(gomock.Any()).
|
||||
Return(errors.Join(ports.ErrGMUnavailable, errors.New("dial tcp: connection refused"))).
|
||||
Times(1)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -231,7 +236,6 @@ func TestResumeGameGMUnavailableKeepsPaused(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, shared.ErrServiceUnavailable)
|
||||
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
|
||||
assert.Equal(t, 1, gm.PingCalls())
|
||||
|
||||
persisted, err := store.Get(context.Background(), record.GameID)
|
||||
require.NoError(t, err)
|
||||
@@ -242,8 +246,8 @@ func TestResumeGameGMUnavailableKeepsPaused(t *testing.T) {
|
||||
func TestResumeGameRejectsMissingRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
store := gamestub.NewStore()
|
||||
gm := newGMMock(t)
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -251,14 +255,13 @@ func TestResumeGameRejectsMissingRecord(t *testing.T) {
|
||||
GameID: common.GameID("game-missing"),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
assert.Equal(t, 0, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGameInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
store := gamestub.NewStore()
|
||||
gm := newGMMock(t)
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -272,8 +275,8 @@ func TestResumeGameInvalidActor(t *testing.T) {
|
||||
func TestResumeGameInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
store := gamestub.NewStore()
|
||||
gm := newGMMock(t)
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/service/retrystartgame"
|
||||
@@ -47,7 +47,7 @@ func newFailedGame(t *testing.T, gameType game.GameType, ownerID string) (game.G
|
||||
return record, now
|
||||
}
|
||||
|
||||
func newService(t *testing.T, games *gamestub.Store, at time.Time) *retrystartgame.Service {
|
||||
func newService(t *testing.T, games *gameinmem.Store, at time.Time) *retrystartgame.Service {
|
||||
t.Helper()
|
||||
service, err := retrystartgame.NewService(retrystartgame.Dependencies{
|
||||
Games: games,
|
||||
@@ -65,7 +65,7 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
|
||||
func TestRetryStartGameAdminHappyPath(t *testing.T) {
|
||||
record, now := newFailedGame(t, game.GameTypePublic, "")
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -79,7 +79,7 @@ func TestRetryStartGameAdminHappyPath(t *testing.T) {
|
||||
|
||||
func TestRetryStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
record, now := newFailedGame(t, game.GameTypePrivate, "user-owner")
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -93,7 +93,7 @@ func TestRetryStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
|
||||
func TestRetryStartGameRejectsNonOwnerUser(t *testing.T) {
|
||||
record, now := newFailedGame(t, game.GameTypePrivate, "user-owner")
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -109,7 +109,7 @@ func TestRetryStartGameRejectsWrongStatus(t *testing.T) {
|
||||
record.Status = game.StatusRunning
|
||||
startedAt := now.Add(30 * time.Minute)
|
||||
record.StartedAt = &startedAt
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -121,7 +121,7 @@ func TestRetryStartGameRejectsWrongStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRetryStartGameRejectsMissingRecord(t *testing.T) {
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
service := newService(t, games, time.Now().UTC())
|
||||
|
||||
_, err := service.Handle(context.Background(), retrystartgame.Input{
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -31,16 +31,16 @@ func fixedClock(at time.Time) func() time.Time { return func() time.Time { retur
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
game game.Game
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-private",
|
||||
@@ -196,7 +196,7 @@ func TestRevokeGameNotFound(t *testing.T) {
|
||||
// game path is a defensive guard, but the surfaced error must be
|
||||
// subject_not_found rather than forbidden.
|
||||
svc, err := revokeinvite.NewService(revokeinvite.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
Invites: f.invites,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -30,20 +32,57 @@ const (
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
type closeFixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
game game.Game
|
||||
now time.Time
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
game game.Game
|
||||
}
|
||||
|
||||
func newCloseFixture(t *testing.T) *closeFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
intents := intentpubstub.NewPublisher()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
rec := &intentRec{}
|
||||
intents := newIntentMock(t, rec)
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: closeGameID,
|
||||
@@ -64,11 +103,12 @@ func newCloseFixture(t *testing.T) *closeFixture {
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
return &closeFixture{
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invites,
|
||||
intents: intents,
|
||||
game: gameRecord,
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invites,
|
||||
intentRec: rec,
|
||||
intents: intents,
|
||||
game: gameRecord,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +160,7 @@ func TestCloseEnrollmentTransitionsGameAndExpiresInvites(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusExpired, second.Status)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 2)
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intent.NotificationType)
|
||||
@@ -158,7 +198,7 @@ func TestCloseEnrollmentLeavesNonCreatedInvitesUntouched(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusDeclined, declinedAfter.Status)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
}
|
||||
|
||||
@@ -184,14 +224,14 @@ func TestCloseEnrollmentSurfacesGameConflict(t *testing.T) {
|
||||
stillCreated, err := f.invites.Get(context.Background(), "invite-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusCreated, stillCreated.Status)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentSwallowsIntentPublishFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
f.addCreatedInvite(t, "invite-1", "user-a")
|
||||
f.intents.SetError(errors.New("publisher offline"))
|
||||
f.intentRec.setErr(errors.New("publisher offline"))
|
||||
|
||||
updated, err := shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
@@ -221,7 +261,7 @@ func TestCloseEnrollmentIsIdempotentOnSecondCall(t *testing.T) {
|
||||
f.now.Add(time.Minute),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, f.intents.Published(), 1)
|
||||
assert.Len(t, f.intentRec.snapshot(), 1)
|
||||
|
||||
_, err = shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
@@ -231,7 +271,7 @@ func TestCloseEnrollmentIsIdempotentOnSecondCall(t *testing.T) {
|
||||
f.now.Add(2*time.Minute),
|
||||
)
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Len(t, f.intents.Published(), 1)
|
||||
assert.Len(t, f.intentRec.snapshot(), 1)
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentRejectsUnknownTrigger(t *testing.T) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/engineimage"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/logging"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -23,11 +24,12 @@ import (
|
||||
|
||||
// Service executes the start-game use case.
|
||||
type Service struct {
|
||||
games ports.GameStore
|
||||
runtimeManager ports.RuntimeManager
|
||||
clock func() time.Time
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
games ports.GameStore
|
||||
runtimeManager ports.RuntimeManager
|
||||
imageResolver *engineimage.Resolver
|
||||
clock func() time.Time
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// Dependencies groups the collaborators used by Service.
|
||||
@@ -38,6 +40,11 @@ type Dependencies struct {
|
||||
// RuntimeManager publishes the start job after the CAS succeeds.
|
||||
RuntimeManager ports.RuntimeManager
|
||||
|
||||
// ImageResolver substitutes a game's TargetEngineVersion into the
|
||||
// configured engine-image template to produce the `image_ref`
|
||||
// published on `runtime:start_jobs`.
|
||||
ImageResolver *engineimage.Resolver
|
||||
|
||||
// Clock supplies the wall-clock used for UpdatedAt. Defaults to
|
||||
// time.Now when nil.
|
||||
Clock func() time.Time
|
||||
@@ -58,6 +65,8 @@ func NewService(deps Dependencies) (*Service, error) {
|
||||
return nil, errors.New("new start game service: nil game store")
|
||||
case deps.RuntimeManager == nil:
|
||||
return nil, errors.New("new start game service: nil runtime manager")
|
||||
case deps.ImageResolver == nil:
|
||||
return nil, errors.New("new start game service: nil image resolver")
|
||||
}
|
||||
|
||||
clock := deps.Clock
|
||||
@@ -72,6 +81,7 @@ func NewService(deps Dependencies) (*Service, error) {
|
||||
return &Service{
|
||||
games: deps.Games,
|
||||
runtimeManager: deps.RuntimeManager,
|
||||
imageResolver: deps.ImageResolver,
|
||||
clock: clock,
|
||||
logger: logger.With("service", "lobby.startgame"),
|
||||
telemetry: deps.Telemetry,
|
||||
@@ -127,6 +137,11 @@ func (service *Service) Handle(ctx context.Context, input Input) (game.Game, err
|
||||
)
|
||||
}
|
||||
|
||||
imageRef, err := service.imageResolver.Resolve(record.TargetEngineVersion)
|
||||
if err != nil {
|
||||
return game.Game{}, fmt.Errorf("start game: resolve image ref: %w", err)
|
||||
}
|
||||
|
||||
at := service.clock().UTC()
|
||||
if err := service.games.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: input.GameID,
|
||||
@@ -144,7 +159,7 @@ func (service *Service) Handle(ctx context.Context, input Input) (game.Game, err
|
||||
string(game.TriggerCommand),
|
||||
)
|
||||
|
||||
if err := service.runtimeManager.PublishStartJob(ctx, input.GameID.String()); err != nil {
|
||||
if err := service.runtimeManager.PublishStartJob(ctx, input.GameID.String(), imageRef); err != nil {
|
||||
// Status is already `starting` and the domain forbids a direct
|
||||
// rollback to `ready_to_start`. We surface the publish error to
|
||||
// the caller; the game stays in `starting` until either a
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/runtimemanagerstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/engineimage"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
@@ -18,8 +20,11 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
const testImageTemplate = "registry.example.com/galaxy/game:{engine_version}"
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
@@ -50,36 +55,113 @@ func newReadyGame(t *testing.T, gameType game.GameType, ownerID string) (game.Ga
|
||||
return record, now
|
||||
}
|
||||
|
||||
// runtimeRec captures every PublishStartJob/PublishStopJob call so tests
|
||||
// can assert which jobs ran. Per-test error injection sets startErr.
|
||||
type runtimeRec struct {
|
||||
mu sync.Mutex
|
||||
startIDs []string
|
||||
startRefs []string
|
||||
stopIDs []string
|
||||
stopReas []ports.StopReason
|
||||
startErr error
|
||||
}
|
||||
|
||||
func (r *runtimeRec) recordStart(_ context.Context, gameID, imageRef string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.startErr != nil {
|
||||
return r.startErr
|
||||
}
|
||||
r.startIDs = append(r.startIDs, gameID)
|
||||
r.startRefs = append(r.startRefs, imageRef)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeRec) recordStop(_ context.Context, gameID string, reason ports.StopReason) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopIDs = append(r.stopIDs, gameID)
|
||||
r.stopReas = append(r.stopReas, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeRec) startJobs() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.startIDs...)
|
||||
}
|
||||
|
||||
func (r *runtimeRec) startImageRefs() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.startRefs...)
|
||||
}
|
||||
|
||||
func (r *runtimeRec) stopJobs() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.stopIDs...)
|
||||
}
|
||||
|
||||
func newRuntimeMock(t *testing.T, rec *runtimeRec) *mocks.MockRuntimeManager {
|
||||
t.Helper()
|
||||
m := mocks.NewMockRuntimeManager(gomock.NewController(t))
|
||||
m.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordStart).AnyTimes()
|
||||
m.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordStop).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
runtime *runtimemanagerstub.Publisher
|
||||
games *gameinmem.Store
|
||||
rec *runtimeRec
|
||||
runtime *mocks.MockRuntimeManager
|
||||
service *startgame.Service
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, record game.Game, now time.Time) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
runtime := runtimemanagerstub.NewPublisher()
|
||||
rec := &runtimeRec{}
|
||||
runtime := newRuntimeMock(t, rec)
|
||||
resolver, err := engineimage.NewResolver(testImageTemplate)
|
||||
require.NoError(t, err)
|
||||
service, err := startgame.NewService(startgame.Dependencies{
|
||||
Games: games,
|
||||
RuntimeManager: runtime,
|
||||
ImageResolver: resolver,
|
||||
Clock: fixedClock(now.Add(time.Hour)),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return &fixture{games: games, runtime: runtime, service: service, now: now}
|
||||
return &fixture{games: games, rec: rec, runtime: runtime, service: service, now: now}
|
||||
}
|
||||
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
_, err := startgame.NewService(startgame.Dependencies{
|
||||
RuntimeManager: runtimemanagerstub.NewPublisher(),
|
||||
resolver, err := engineimage.NewResolver(testImageTemplate)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := &runtimeRec{}
|
||||
runtime := newRuntimeMock(t, rec)
|
||||
|
||||
_, err = startgame.NewService(startgame.Dependencies{
|
||||
RuntimeManager: runtime,
|
||||
ImageResolver: resolver,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = startgame.NewService(startgame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
ImageResolver: resolver,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = startgame.NewService(startgame.Dependencies{
|
||||
Games: gameinmem.NewStore(),
|
||||
RuntimeManager: runtime,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -94,8 +176,13 @@ func TestStartGamePublicAdminHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusStarting, updated.Status)
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.runtime.StartJobs())
|
||||
assert.Empty(t, f.runtime.StopJobs())
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.rec.startJobs())
|
||||
assert.Equal(t,
|
||||
[]string{"registry.example.com/galaxy/game:" + record.TargetEngineVersion},
|
||||
f.rec.startImageRefs(),
|
||||
"resolved image_ref must propagate to publisher",
|
||||
)
|
||||
assert.Empty(t, f.rec.stopJobs())
|
||||
}
|
||||
|
||||
func TestStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
@@ -108,7 +195,7 @@ func TestStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusStarting, updated.Status)
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.runtime.StartJobs())
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.rec.startJobs())
|
||||
}
|
||||
|
||||
func TestStartGameRejectsNonOwnerUser(t *testing.T) {
|
||||
@@ -120,7 +207,7 @@ func TestStartGameRejectsNonOwnerUser(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
assert.Empty(t, f.runtime.StartJobs(), "no start job published on forbidden")
|
||||
assert.Empty(t, f.rec.startJobs(), "no start job published on forbidden")
|
||||
|
||||
stored, err := f.games.Get(context.Background(), record.GameID)
|
||||
require.NoError(t, err)
|
||||
@@ -148,7 +235,7 @@ func TestStartGameRejectsWrongStatus(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Empty(t, f.runtime.StartJobs())
|
||||
assert.Empty(t, f.rec.startJobs())
|
||||
}
|
||||
|
||||
func TestStartGameRejectsCASLossOnRecentTransition(t *testing.T) {
|
||||
@@ -169,13 +256,13 @@ func TestStartGameRejectsCASLossOnRecentTransition(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Empty(t, f.runtime.StartJobs())
|
||||
assert.Empty(t, f.rec.startJobs())
|
||||
}
|
||||
|
||||
func TestStartGamePublishFailureSurfacesUnavailable(t *testing.T) {
|
||||
record, now := newReadyGame(t, game.GameTypePublic, "")
|
||||
f := newFixture(t, record, now)
|
||||
f.runtime.SetStartError(errors.New("redis down"))
|
||||
f.rec.startErr = errors.New("redis down")
|
||||
|
||||
_, err := f.service.Handle(context.Background(), startgame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
@@ -191,11 +278,15 @@ func TestStartGamePublishFailureSurfacesUnavailable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStartGameRejectsMissingRecord(t *testing.T) {
|
||||
games := gamestub.NewStore()
|
||||
runtime := runtimemanagerstub.NewPublisher()
|
||||
games := gameinmem.NewStore()
|
||||
rec := &runtimeRec{}
|
||||
runtime := newRuntimeMock(t, rec)
|
||||
resolver, err := engineimage.NewResolver(testImageTemplate)
|
||||
require.NoError(t, err)
|
||||
service, err := startgame.NewService(startgame.Dependencies{
|
||||
Games: games,
|
||||
RuntimeManager: runtime,
|
||||
ImageResolver: resolver,
|
||||
Clock: fixedClock(time.Now().UTC()),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -25,8 +25,87 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
type userRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
defaultRaceName = "SolarPilot"
|
||||
otherRaceName = "VoidRunner"
|
||||
@@ -58,12 +137,14 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) {
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
applications *applicationstub.Store
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
users *userRec
|
||||
usersMock *mocks.MockUserService
|
||||
intents *intentRec
|
||||
intentsMock *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
openPublicGameID common.GameID
|
||||
defaultUserID string
|
||||
@@ -72,13 +153,13 @@ type fixture struct {
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
users := userservicestub.NewService()
|
||||
users.SetEligibility("user-1", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: true})
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
applications := applicationstub.NewStore()
|
||||
users := &userRec{}
|
||||
users.setEligibility("user-1", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: true})
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
applications := applicationinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-public",
|
||||
@@ -97,6 +178,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
intents := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
@@ -104,7 +186,9 @@ func newFixture(t *testing.T) *fixture {
|
||||
applications: applications,
|
||||
directory: dir,
|
||||
users: users,
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
usersMock: newUserMock(t, users),
|
||||
intents: intents,
|
||||
intentsMock: newIntentMock(t, intents),
|
||||
ids: fixedIDs{applicationID: "application-fixed", membershipID: "membership-fixed"},
|
||||
openPublicGameID: gameRecord.GameID,
|
||||
defaultUserID: "user-1",
|
||||
@@ -117,9 +201,9 @@ func newService(t *testing.T, f *fixture) *submitapplication.Service {
|
||||
Games: f.games,
|
||||
Memberships: f.memberships,
|
||||
Applications: f.applications,
|
||||
Users: f.users,
|
||||
Users: f.usersMock,
|
||||
Directory: f.directory,
|
||||
Intents: f.intents,
|
||||
Intents: f.intentsMock,
|
||||
IDs: f.ids,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
@@ -147,7 +231,7 @@ func TestHandleHappyPath(t *testing.T) {
|
||||
assert.Equal(t, common.ApplicationID("application-fixed"), got.ApplicationID)
|
||||
assert.Equal(t, defaultRaceName, got.RaceName)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyApplicationSubmitted, intents[0].NotificationType)
|
||||
assert.Equal(t, notificationintent.AudienceKindAdminEmail, intents[0].AudienceKind)
|
||||
@@ -236,7 +320,7 @@ func TestHandleUserMissingEligibilityDenied(t *testing.T) {
|
||||
func TestHandleCanJoinGameFalseEligibilityDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
f.users.SetEligibility("user-blocked", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: false})
|
||||
f.users.setEligibility("user-blocked", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: false})
|
||||
svc := newService(t, f)
|
||||
input := defaultInput(f)
|
||||
input.Actor = shared.NewUserActor("user-blocked")
|
||||
@@ -248,7 +332,7 @@ func TestHandleCanJoinGameFalseEligibilityDenied(t *testing.T) {
|
||||
func TestHandleUserServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
f.users.SetFailure(f.defaultUserID, ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure(f.defaultUserID, ports.ErrUserServiceUnavailable)
|
||||
svc := newService(t, f)
|
||||
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
@@ -322,7 +406,7 @@ func TestHandleDuplicateActiveApplicationConflict(t *testing.T) {
|
||||
func TestHandlePublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intents.setErr(errors.New("publish failed"))
|
||||
svc := newService(t, f)
|
||||
|
||||
got, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -29,7 +29,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// returns the persisted record.
|
||||
func seedDraftGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -73,7 +73,7 @@ func TestHandleAdminFullEditInDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-a", game.GameTypePublic, "", now)
|
||||
|
||||
later := now.Add(30 * time.Minute)
|
||||
@@ -107,7 +107,7 @@ func TestHandleOwnerEditInDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-private", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -125,7 +125,7 @@ func TestHandleNonOwnerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-private", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -142,7 +142,7 @@ func TestHandleUserCannotEditPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-public", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -159,7 +159,7 @@ func TestHandleEnrollmentOpenDescriptionOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-open", game.GameTypePublic, "", now)
|
||||
|
||||
// Force status to enrollment_open via UpdateStatus.
|
||||
@@ -187,7 +187,7 @@ func TestHandleEnrollmentOpenNonDescriptionRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-open", game.GameTypePublic, "", now)
|
||||
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
@@ -212,7 +212,7 @@ func TestHandleTerminalStatusRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-cancel", game.GameTypePublic, "", now)
|
||||
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
@@ -236,7 +236,7 @@ func TestHandleTerminalStatusRejected(t *testing.T) {
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), updategame.Input{
|
||||
@@ -251,7 +251,7 @@ func TestHandleValidationFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-a", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -270,7 +270,7 @@ func TestHandleInvalidActorReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(now))
|
||||
|
||||
_, err := service.Handle(context.Background(), updategame.Input{
|
||||
@@ -286,7 +286,7 @@ func TestHandleInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(now))
|
||||
|
||||
_, err := service.Handle(context.Background(), updategame.Input{
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gapactivationstub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gapactivationinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -21,8 +22,34 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
gameID = common.GameID("game-private")
|
||||
ownerUserID = "user-owner"
|
||||
@@ -34,11 +61,12 @@ func fixedClock(at time.Time) func() time.Time { return func() time.Time { retur
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
gapStore *gapactivationstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
gapStore *gapactivationinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
game game.Game
|
||||
}
|
||||
|
||||
@@ -86,16 +114,18 @@ func newFixture(t *testing.T, opts fixtureOptions) *fixture {
|
||||
require.NoError(t, err)
|
||||
rec.Status = game.StatusEnrollmentOpen
|
||||
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), rec))
|
||||
|
||||
intentRecord := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invitestub.NewStore(),
|
||||
memberships: membershipstub.NewStore(),
|
||||
gapStore: gapactivationstub.NewStore(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
invites: inviteinmem.NewStore(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
gapStore: gapactivationinmem.NewStore(),
|
||||
intentRec: intentRecord,
|
||||
intents: newIntentMock(t, intentRecord),
|
||||
game: rec,
|
||||
}
|
||||
}
|
||||
@@ -159,11 +189,11 @@ func currentStatus(t *testing.T, f *fixture) game.Status {
|
||||
func TestNewWorkerRejectsZeroInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := enrollmentautomation.NewWorker(enrollmentautomation.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Memberships: membershipstub.NewStore(),
|
||||
Invites: invitestub.NewStore(),
|
||||
Intents: intentpubstub.NewPublisher(),
|
||||
GapStore: gapactivationstub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
Memberships: membershipinmem.NewStore(),
|
||||
Invites: inviteinmem.NewStore(),
|
||||
Intents: newIntentMock(t, &intentRec{}),
|
||||
GapStore: gapactivationinmem.NewStore(),
|
||||
Interval: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
@@ -185,7 +215,7 @@ func TestTickDeadlineTriggers(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusExpired, expired.Status)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intents[0].NotificationType)
|
||||
}
|
||||
@@ -200,7 +230,7 @@ func TestTickDeadlineSkipsBelowMinPlayers(t *testing.T) {
|
||||
f.newWorker(t, tickAt).Tick(context.Background())
|
||||
|
||||
assert.Equal(t, game.StatusEnrollmentOpen, currentStatus(t, f))
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
func TestTickGapTimeTriggers(t *testing.T) {
|
||||
@@ -260,7 +290,7 @@ func TestTickIsIdempotent(t *testing.T) {
|
||||
worker.Tick(context.Background())
|
||||
|
||||
assert.Equal(t, game.StatusReadyToStart, currentStatus(t, f))
|
||||
assert.Len(t, f.intents.Published(), 1)
|
||||
assert.Len(t, f.intentRec.snapshot(), 1)
|
||||
}
|
||||
|
||||
func TestRunStopsOnContextCancel(t *testing.T) {
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameturnstatsstub"
|
||||
"galaxy/lobby/internal/adapters/streamoffsetstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gameturnstatsinmem"
|
||||
"galaxy/lobby/internal/adapters/streamoffsetinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -60,10 +60,10 @@ func (e *fakeEvaluator) SetError(err error) {
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
games *gamestub.Store
|
||||
stats *gameturnstatsstub.Store
|
||||
games *gameinmem.Store
|
||||
stats *gameturnstatsinmem.Store
|
||||
evaluator *fakeEvaluator
|
||||
offsets *streamoffsetstub.Store
|
||||
offsets *streamoffsetinmem.Store
|
||||
consumer *gmevents.Consumer
|
||||
server *miniredis.Miniredis
|
||||
clientRedis *redis.Client
|
||||
@@ -78,10 +78,10 @@ func newHarness(t *testing.T) *harness {
|
||||
clientRedis := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = clientRedis.Close() })
|
||||
|
||||
games := gamestub.NewStore()
|
||||
stats := gameturnstatsstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
stats := gameturnstatsinmem.NewStore()
|
||||
evaluator := &fakeEvaluator{}
|
||||
offsets := streamoffsetstub.NewStore()
|
||||
offsets := streamoffsetinmem.NewStore()
|
||||
at := time.Date(2026, 4, 25, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
now := at.Add(-2 * time.Hour)
|
||||
@@ -207,8 +207,8 @@ func TestNewConsumerRejectsMissingDeps(t *testing.T) {
|
||||
Client: client,
|
||||
Stream: "gm:lobby_events",
|
||||
BlockTimeout: time.Second,
|
||||
Games: gamestub.NewStore(),
|
||||
Stats: gameturnstatsstub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
Stats: gameturnstatsinmem.NewStore(),
|
||||
})
|
||||
require.Error(t, err, "missing capability evaluator")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/worker/pendingregistration"
|
||||
|
||||
@@ -32,9 +32,9 @@ type controlledClock struct{ instant time.Time }
|
||||
func (clock *controlledClock) now() time.Time { return clock.instant }
|
||||
func (clock *controlledClock) advance(d time.Duration) { clock.instant = clock.instant.Add(d) }
|
||||
|
||||
func newDirectory(t *testing.T, clock *controlledClock) *racenamestub.Directory {
|
||||
func newDirectory(t *testing.T, clock *controlledClock) *racenameinmem.Directory {
|
||||
t.Helper()
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(clock.now))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(clock.now))
|
||||
require.NoError(t, err)
|
||||
return directory
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestNewWorkerRejectsNilDirectory(t *testing.T) {
|
||||
|
||||
func TestNewWorkerRejectsNonPositiveInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = pendingregistration.NewWorker(pendingregistration.Dependencies{
|
||||
|
||||
@@ -401,7 +401,7 @@ func (consumer *Consumer) handleOrphan(ctx context.Context, entryID string, even
|
||||
"game_id", event.GameID.String(),
|
||||
"err", cause.Error(),
|
||||
)
|
||||
if err := consumer.runtimeManager.PublishStopJob(ctx, event.GameID.String()); err != nil {
|
||||
if err := consumer.runtimeManager.PublishStopJob(ctx, event.GameID.String(), ports.StopReasonOrphanCleanup); err != nil {
|
||||
consumer.logger.WarnContext(ctx, "publish stop job for orphan container",
|
||||
"stream_entry_id", entryID,
|
||||
"game_id", event.GameID.String(),
|
||||
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gmclientstub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/runtimemanagerstub"
|
||||
"galaxy/lobby/internal/adapters/streamoffsetstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/streamoffsetinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -23,18 +22,92 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// recorder captures every call passed through the mocks. The harness
|
||||
// installs a default EXPECT().AnyTimes() that funnels every call into
|
||||
// the recorder so individual tests can assert on observed calls.
|
||||
// Per-test error injection uses recorder.gmErr/intentsErr.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
stopGameIDs []string
|
||||
stopReasons []ports.StopReason
|
||||
gmRequests []ports.RegisterGameRequest
|
||||
publishedIntents []notificationintent.Intent
|
||||
gmErr error
|
||||
intentsErr error
|
||||
}
|
||||
|
||||
func (r *recorder) recordStop(_ context.Context, gameID string, reason ports.StopReason) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopGameIDs = append(r.stopGameIDs, gameID)
|
||||
r.stopReasons = append(r.stopReasons, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recorder) recordGM(_ context.Context, request ports.RegisterGameRequest) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.gmErr != nil {
|
||||
return r.gmErr
|
||||
}
|
||||
r.gmRequests = append(r.gmRequests, request)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recorder) recordIntent(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.intentsErr != nil {
|
||||
return "", r.intentsErr
|
||||
}
|
||||
r.publishedIntents = append(r.publishedIntents, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *recorder) stopGameIDsSnapshot() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.stopGameIDs...)
|
||||
}
|
||||
|
||||
func (r *recorder) stopReasonsSnapshot() []ports.StopReason {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]ports.StopReason(nil), r.stopReasons...)
|
||||
}
|
||||
|
||||
func (r *recorder) gmRequestsSnapshot() []ports.RegisterGameRequest {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]ports.RegisterGameRequest(nil), r.gmRequests...)
|
||||
}
|
||||
|
||||
func (r *recorder) publishedSnapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.publishedIntents...)
|
||||
}
|
||||
|
||||
func (r *recorder) setGMErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.gmErr = err
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
games *gamestub.Store
|
||||
runtime *runtimemanagerstub.Publisher
|
||||
gm *gmclientstub.Client
|
||||
intents *intentpubstub.Publisher
|
||||
offsets *streamoffsetstub.Store
|
||||
games *gameinmem.Store
|
||||
runtime *mocks.MockRuntimeManager
|
||||
gm *mocks.MockGMClient
|
||||
intents *mocks.MockIntentPublisher
|
||||
rec *recorder
|
||||
offsets *streamoffsetinmem.Store
|
||||
consumer *runtimejobresult.Consumer
|
||||
server *miniredis.Miniredis
|
||||
clientRedis *redis.Client
|
||||
@@ -49,11 +122,26 @@ func newHarness(t *testing.T) *harness {
|
||||
clientRedis := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = clientRedis.Close() })
|
||||
|
||||
games := gamestub.NewStore()
|
||||
runtime := runtimemanagerstub.NewPublisher()
|
||||
gm := gmclientstub.NewClient()
|
||||
intents := intentpubstub.NewPublisher()
|
||||
offsets := streamoffsetstub.NewStore()
|
||||
ctrl := gomock.NewController(t)
|
||||
rec := &recorder{}
|
||||
|
||||
games := gameinmem.NewStore()
|
||||
runtime := mocks.NewMockRuntimeManager(ctrl)
|
||||
runtime.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, _, _ string) error { return nil }).AnyTimes()
|
||||
runtime.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordStop).AnyTimes()
|
||||
|
||||
gm := mocks.NewMockGMClient(ctrl)
|
||||
gm.EXPECT().RegisterGame(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordGM).AnyTimes()
|
||||
gm.EXPECT().Ping(gomock.Any()).Return(nil).AnyTimes()
|
||||
|
||||
intents := mocks.NewMockIntentPublisher(ctrl)
|
||||
intents.EXPECT().Publish(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordIntent).AnyTimes()
|
||||
|
||||
offsets := streamoffsetinmem.NewStore()
|
||||
at := time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
h := &harness{
|
||||
@@ -61,6 +149,7 @@ func newHarness(t *testing.T) *harness {
|
||||
runtime: runtime,
|
||||
gm: gm,
|
||||
intents: intents,
|
||||
rec: rec,
|
||||
offsets: offsets,
|
||||
server: server,
|
||||
clientRedis: clientRedis,
|
||||
@@ -165,21 +254,22 @@ func TestHandleSuccessTransitionsToRunning(t *testing.T) {
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(h.at))
|
||||
|
||||
require.Len(t, h.gm.Requests(), 1)
|
||||
req := h.gm.Requests()[0]
|
||||
gmRequests := h.rec.gmRequestsSnapshot()
|
||||
require.Len(t, gmRequests, 1)
|
||||
req := gmRequests[0]
|
||||
assert.Equal(t, h.gameRecord.GameID, req.GameID)
|
||||
assert.Equal(t, "container-1", req.ContainerID)
|
||||
assert.Equal(t, "engine.local:9000", req.EngineEndpoint)
|
||||
assert.Equal(t, h.gameRecord.TargetEngineVersion, req.TargetEngineVersion)
|
||||
assert.Equal(t, h.gameRecord.TurnSchedule, req.TurnSchedule)
|
||||
|
||||
assert.Empty(t, h.runtime.StopJobs())
|
||||
assert.Empty(t, h.intents.Published())
|
||||
assert.Empty(t, h.rec.stopGameIDsSnapshot())
|
||||
assert.Empty(t, h.rec.publishedSnapshot())
|
||||
}
|
||||
|
||||
func TestHandleSuccessGMUnavailableMovesToPausedAndPublishesIntent(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.gm.SetError(ports.ErrGMUnavailable)
|
||||
h.rec.setGMErr(ports.ErrGMUnavailable)
|
||||
|
||||
h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000001-0"))
|
||||
|
||||
@@ -188,10 +278,10 @@ func TestHandleSuccessGMUnavailableMovesToPausedAndPublishesIntent(t *testing.T)
|
||||
assert.Equal(t, game.StatusPaused, got.Status)
|
||||
require.NotNil(t, got.RuntimeBinding, "binding still persisted before paused")
|
||||
|
||||
published := h.intents.Published()
|
||||
published := h.rec.publishedSnapshot()
|
||||
require.Len(t, published, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRuntimePausedAfterStart, published[0].NotificationType)
|
||||
assert.Empty(t, h.runtime.StopJobs())
|
||||
assert.Empty(t, h.rec.stopGameIDsSnapshot())
|
||||
}
|
||||
|
||||
func TestHandleFailureTransitionsToStartFailed(t *testing.T) {
|
||||
@@ -202,9 +292,9 @@ func TestHandleFailureTransitionsToStartFailed(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusStartFailed, got.Status)
|
||||
assert.Nil(t, got.RuntimeBinding)
|
||||
assert.Empty(t, h.runtime.StopJobs())
|
||||
assert.Empty(t, h.gm.Requests())
|
||||
assert.Empty(t, h.intents.Published())
|
||||
assert.Empty(t, h.rec.stopGameIDsSnapshot())
|
||||
assert.Empty(t, h.rec.gmRequestsSnapshot())
|
||||
assert.Empty(t, h.rec.publishedSnapshot())
|
||||
}
|
||||
|
||||
func TestHandleSuccessOrphanContainerWhenBindingFails(t *testing.T) {
|
||||
@@ -236,15 +326,20 @@ func TestHandleSuccessOrphanContainerWhenBindingFails(t *testing.T) {
|
||||
"orphan path must move game to start_failed")
|
||||
assert.Nil(t, got.RuntimeBinding, "binding never persisted")
|
||||
|
||||
assert.Equal(t, []string{h.gameRecord.GameID.String()}, h.runtime.StopJobs())
|
||||
assert.Empty(t, h.gm.Requests())
|
||||
assert.Empty(t, h.intents.Published())
|
||||
assert.Equal(t, []string{h.gameRecord.GameID.String()}, h.rec.stopGameIDsSnapshot())
|
||||
assert.Equal(t,
|
||||
[]ports.StopReason{ports.StopReasonOrphanCleanup},
|
||||
h.rec.stopReasonsSnapshot(),
|
||||
"orphan path must classify the stop job as orphan_cleanup",
|
||||
)
|
||||
assert.Empty(t, h.rec.gmRequestsSnapshot())
|
||||
assert.Empty(t, h.rec.publishedSnapshot())
|
||||
}
|
||||
|
||||
func TestHandleSuccessReplayIsNoOp(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000004-0"))
|
||||
require.Len(t, h.gm.Requests(), 1)
|
||||
require.Len(t, h.rec.gmRequestsSnapshot(), 1)
|
||||
|
||||
got, err := h.games.Get(context.Background(), h.gameRecord.GameID)
|
||||
require.NoError(t, err)
|
||||
@@ -253,16 +348,16 @@ func TestHandleSuccessReplayIsNoOp(t *testing.T) {
|
||||
// Replay the same event: status is already running, so the early
|
||||
// status check exits before any side-effect call (no binding
|
||||
// overwrite, no GM call, no transition).
|
||||
h.gm.SetError(errors.New("must not be called again"))
|
||||
h.rec.setGMErr(errors.New("must not be called again"))
|
||||
h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000004-0"))
|
||||
|
||||
require.Len(t, h.gm.Requests(), 1, "GM register-game is invoked once across replays")
|
||||
require.Len(t, h.rec.gmRequestsSnapshot(), 1, "GM register-game is invoked once across replays")
|
||||
|
||||
got, err = h.games.Get(context.Background(), h.gameRecord.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusRunning, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(originalUpdatedAt), "no further mutations on replay")
|
||||
assert.Empty(t, h.intents.Published())
|
||||
assert.Empty(t, h.rec.publishedSnapshot())
|
||||
}
|
||||
|
||||
func TestHandleFailureReplayIsNoOp(t *testing.T) {
|
||||
@@ -298,14 +393,14 @@ func TestHandleMalformedEvents(t *testing.T) {
|
||||
got, err := h.games.Get(context.Background(), h.gameRecord.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusStarting, got.Status, "malformed events leave game untouched")
|
||||
assert.Empty(t, h.runtime.StopJobs())
|
||||
assert.Empty(t, h.gm.Requests())
|
||||
assert.Empty(t, h.rec.stopGameIDsSnapshot())
|
||||
assert.Empty(t, h.rec.gmRequestsSnapshot())
|
||||
}
|
||||
|
||||
// fakeBindingFailer wraps gamestub.Store and forces UpdateRuntimeBinding
|
||||
// fakeBindingFailer wraps gameinmem.Store and forces UpdateRuntimeBinding
|
||||
// to fail; everything else delegates to the embedded store.
|
||||
type fakeBindingFailer struct {
|
||||
*gamestub.Store
|
||||
*gameinmem.Store
|
||||
err error
|
||||
}
|
||||
|
||||
|
||||
@@ -429,7 +429,7 @@ func (worker *Worker) cascadeOwnedGames(
|
||||
}
|
||||
|
||||
if _, inflight := inflightGameStatuses[record.Status]; inflight {
|
||||
if err := worker.runtimeManager.PublishStopJob(ctx, record.GameID.String()); err != nil {
|
||||
if err := worker.runtimeManager.PublishStopJob(ctx, record.GameID.String(), ports.StopReasonCancelled); err != nil {
|
||||
return cancelled, fmt.Errorf("user lifecycle handle: publish stop job for %s: %w",
|
||||
record.GameID, err)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/runtimemanagerstub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -27,18 +27,94 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
type runtimeRec struct {
|
||||
mu sync.Mutex
|
||||
stopIDs []string
|
||||
stopReas []ports.StopReason
|
||||
stopErr error
|
||||
}
|
||||
|
||||
func (r *runtimeRec) recordStart(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
func (r *runtimeRec) recordStop(_ context.Context, gameID string, reason ports.StopReason) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.stopErr != nil {
|
||||
return r.stopErr
|
||||
}
|
||||
r.stopIDs = append(r.stopIDs, gameID)
|
||||
r.stopReas = append(r.stopReas, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeRec) stopJobs() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.stopIDs...)
|
||||
}
|
||||
|
||||
func (r *runtimeRec) stopReasons() []ports.StopReason {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]ports.StopReason(nil), r.stopReas...)
|
||||
}
|
||||
|
||||
func (r *runtimeRec) setStopErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopErr = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newRuntimeMock(t *testing.T, rec *runtimeRec) *mocks.MockRuntimeManager {
|
||||
t.Helper()
|
||||
m := mocks.NewMockRuntimeManager(gomock.NewController(t))
|
||||
m.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordStart).AnyTimes()
|
||||
m.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordStop).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
type fixture struct {
|
||||
directory *racenamestub.Directory
|
||||
memberships *membershipstub.Store
|
||||
applications *applicationstub.Store
|
||||
invites *invitestub.Store
|
||||
games *gamestub.Store
|
||||
runtimeManager *runtimemanagerstub.Publisher
|
||||
intents *intentpubstub.Publisher
|
||||
directory *racenameinmem.Directory
|
||||
memberships *membershipinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
games *gameinmem.Store
|
||||
runtimeRec *runtimeRec
|
||||
runtimeManager *mocks.MockRuntimeManager
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
worker *userlifecycle.Worker
|
||||
now time.Time
|
||||
}
|
||||
@@ -46,18 +122,22 @@ type fixture struct {
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
rtRec := &runtimeRec{}
|
||||
intRec := &intentRec{}
|
||||
f := &fixture{
|
||||
directory: directory,
|
||||
memberships: membershipstub.NewStore(),
|
||||
applications: applicationstub.NewStore(),
|
||||
invites: invitestub.NewStore(),
|
||||
games: gamestub.NewStore(),
|
||||
runtimeManager: runtimemanagerstub.NewPublisher(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
applications: applicationinmem.NewStore(),
|
||||
invites: inviteinmem.NewStore(),
|
||||
games: gameinmem.NewStore(),
|
||||
runtimeRec: rtRec,
|
||||
runtimeManager: newRuntimeMock(t, rtRec),
|
||||
intentRec: intRec,
|
||||
intents: newIntentMock(t, intRec),
|
||||
now: now,
|
||||
}
|
||||
|
||||
@@ -276,12 +356,16 @@ func TestHandleFullCascadePermanentBlock(t *testing.T) {
|
||||
gotOwned2, err := f.games.Get(context.Background(), ownedDraft.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusCancelled, gotOwned2.Status)
|
||||
stopJobs := f.runtimeManager.StopJobs()
|
||||
stopJobs := f.runtimeRec.stopJobs()
|
||||
require.Len(t, stopJobs, 1)
|
||||
assert.Equal(t, ownedRunning.GameID.String(), stopJobs[0])
|
||||
stopReasons := f.runtimeRec.stopReasons()
|
||||
require.Len(t, stopReasons, 1)
|
||||
assert.Equal(t, ports.StopReasonCancelled, stopReasons[0],
|
||||
"user-lifecycle cascade must classify the stop job as cancelled")
|
||||
|
||||
// Notification published only for the third-party private game owner.
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipBlocked, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{"owner-other"}, intents[0].RecipientUserIDs)
|
||||
@@ -309,7 +393,7 @@ func TestHandleIsIdempotentOnReplay(t *testing.T) {
|
||||
require.NoError(t, f.worker.Handle(context.Background(), event))
|
||||
require.NoError(t, f.worker.Handle(context.Background(), event))
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1, "second pass must not double-publish")
|
||||
assert.Contains(t, intents[0].PayloadJSON, `"reason":"deleted"`)
|
||||
}
|
||||
@@ -378,7 +462,7 @@ func TestHandleUnknownEventTypeIsNoop(t *testing.T) {
|
||||
got, err := f.memberships.Get(context.Background(), member.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, membership.StatusActive, got.Status)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
func TestHandlePropagatesStopJobError(t *testing.T) {
|
||||
@@ -386,7 +470,7 @@ func TestHandlePropagatesStopJobError(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.seedGame(t, "game-owned-3", game.GameTypePrivate, "user-victim", game.StatusRunning)
|
||||
|
||||
f.runtimeManager.SetStopError(errors.New("runtime down"))
|
||||
f.runtimeRec.setStopErr(errors.New("runtime down"))
|
||||
|
||||
err := f.worker.Handle(context.Background(), ports.UserLifecycleEvent{
|
||||
EntryID: "1700000000000-0",
|
||||
@@ -399,10 +483,10 @@ func TestHandlePropagatesStopJobError(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// flakyMembershipStore wraps membershipstub.Store with a one-shot
|
||||
// flakyMembershipStore wraps membershipinmem.Store with a one-shot
|
||||
// UpdateStatus failure injection used by the retry-after-error test.
|
||||
type flakyMembershipStore struct {
|
||||
*membershipstub.Store
|
||||
*membershipinmem.Store
|
||||
failOnce bool
|
||||
failError error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user