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
@@ -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
}