feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
+8 -1
View File
@@ -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/...
+9
View File
@@ -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
View File
@@ -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
+5 -2
View File
@@ -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
+5 -2
View File
@@ -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
+1
View File
@@ -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
)
+2
View File
@@ -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=
@@ -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"
@@ -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"
@@ -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,4 +1,4 @@
package gamestub
package gameinmem
import (
"context"
@@ -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"
@@ -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)
@@ -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"
@@ -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)
}
@@ -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"
@@ -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)
@@ -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 -11
View File
@@ -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,
+12 -12
View File
@@ -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,
+95 -25
View File
@@ -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{
+9 -2
View File
@@ -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)
}
+31
View File
@@ -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",
+32
View File
@@ -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,
+2
View File
@@ -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))
+3
View File
@@ -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)
}
+2
View File
@@ -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.
+3 -1
View File
@@ -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
+83 -16
View File
@@ -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
}
+2
View File
@@ -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(),
}
}
+18 -18
View File
@@ -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) {
+21 -6
View File
@@ -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
+110 -19
View File
@@ -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) {
+11 -11
View File
@@ -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
}