feat: runtime manager
This commit is contained in:
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package applicationstub provides an in-memory ports.ApplicationStore
|
||||
// Package applicationinmem provides an in-memory ports.ApplicationStore
|
||||
// implementation for service-level tests. The stub mirrors the
|
||||
// behavioural contract of the Redis adapter in redisstate: it enforces
|
||||
// application.Transition for status updates, the single-active
|
||||
@@ -8,7 +8,7 @@
|
||||
// Production code never wires this stub; it is test-only but exposed as
|
||||
// a regular (non _test.go) package so other service test packages can
|
||||
// import it.
|
||||
package applicationstub
|
||||
package applicationinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Package evaluationguardstub provides an in-memory
|
||||
// Package evaluationguardinmem provides an in-memory
|
||||
// ports.EvaluationGuardStore used by service-level capability evaluation
|
||||
// tests. Production code never wires this stub.
|
||||
package evaluationguardstub
|
||||
package evaluationguardinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+9
-9
@@ -1,13 +1,13 @@
|
||||
// Package gamestub provides an in-memory ports.GameStore implementation for
|
||||
// service-level tests. The stub mirrors the behavioural contract of the
|
||||
// Redis-backed adapter in redisstate: it enforces game.Transition for status
|
||||
// updates, the ExpectedFrom CAS check, and the StartedAt/FinishedAt side
|
||||
// effects of the canonical status transitions.
|
||||
// Package gameinmem provides an in-memory ports.GameStore implementation
|
||||
// for service-level tests. It mirrors the behavioural contract of the
|
||||
// Redis-backed adapter in redisstate: it enforces game.Transition for
|
||||
// status updates, the ExpectedFrom CAS check, and the
|
||||
// StartedAt/FinishedAt side effects of the canonical status transitions.
|
||||
//
|
||||
// Production code never wires this stub; it is test-only but exposed as a
|
||||
// regular (non _test.go) package so other service test packages can import
|
||||
// it.
|
||||
package gamestub
|
||||
// Production code never wires this adapter; it is test-only but exposed
|
||||
// as a regular (non _test.go) package so other service test packages can
|
||||
// import it.
|
||||
package gameinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gamestub
|
||||
package gameinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package gameturnstatsstub provides an in-memory ports.GameTurnStatsStore
|
||||
// Package gameturnstatsinmem provides an in-memory ports.GameTurnStatsStore
|
||||
// implementation for service-level tests. The stub mirrors the behavioural
|
||||
// contract of the Redis adapter in redisstate: SaveInitial freezes the
|
||||
// initial fields on the first call per user, UpdateMax keeps the max fields
|
||||
@@ -8,7 +8,7 @@
|
||||
// Production code never wires this stub; it is test-only but exposed as a
|
||||
// regular (non _test.go) package so downstream service test packages can
|
||||
// import it.
|
||||
package gameturnstatsstub
|
||||
package gameturnstatsinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
// Package gapactivationstub provides an in-memory
|
||||
// Package gapactivationinmem provides an in-memory
|
||||
// ports.GapActivationStore implementation for service-level tests. The
|
||||
// stub records every MarkActivated call and offers WasActivated /
|
||||
// ActivatedAt accessors so test bodies can assert the gap-window trigger
|
||||
// fired exactly once.
|
||||
package gapactivationstub
|
||||
package gapactivationinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,89 +0,0 @@
|
||||
// Package gmclientstub provides an in-process ports.GMClient
|
||||
// implementation used by service-level and worker-level tests that do
|
||||
// not need to spin up an httptest server. The stub records every
|
||||
// register call and every liveness probe, and supports independent
|
||||
// error injection for each method so and paths can
|
||||
// be exercised separately.
|
||||
//
|
||||
// Production code never wires this stub.
|
||||
package gmclientstub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Client is a concurrency-safe in-memory ports.GMClient.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
pingErr error
|
||||
requests []ports.RegisterGameRequest
|
||||
pingCalls int
|
||||
}
|
||||
|
||||
// NewClient constructs an empty Client.
|
||||
func NewClient() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
// SetError makes the next RegisterGame calls return err. Passing nil
|
||||
// clears the override.
|
||||
func (client *Client) SetError(err error) {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
client.err = err
|
||||
}
|
||||
|
||||
// SetPingError makes the next Ping calls return err. Passing nil
|
||||
// clears the override. RegisterGame is unaffected.
|
||||
func (client *Client) SetPingError(err error) {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
client.pingErr = err
|
||||
}
|
||||
|
||||
// Requests returns the ordered slice of register requests received.
|
||||
func (client *Client) Requests() []ports.RegisterGameRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return append([]ports.RegisterGameRequest(nil), client.requests...)
|
||||
}
|
||||
|
||||
// PingCalls returns the number of Ping invocations observed so far.
|
||||
func (client *Client) PingCalls() int {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return client.pingCalls
|
||||
}
|
||||
|
||||
// RegisterGame records the request and returns the configured error.
|
||||
func (client *Client) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
|
||||
if ctx == nil {
|
||||
return errors.New("register game: nil context")
|
||||
}
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
if client.err != nil {
|
||||
return client.err
|
||||
}
|
||||
client.requests = append(client.requests, request)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping increments the call counter and returns the configured error.
|
||||
func (client *Client) Ping(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("ping: nil context")
|
||||
}
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
client.pingCalls++
|
||||
return client.pingErr
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.GMClient = (*Client)(nil)
|
||||
@@ -1,79 +0,0 @@
|
||||
// Package intentpubstub provides an in-process
|
||||
// ports.IntentPublisher implementation for service-level tests. The
|
||||
// stub records every Publish call and lets tests inject failures to
|
||||
// verify that publication errors do not roll back already-committed
|
||||
// business state.
|
||||
package intentpubstub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/notificationintent"
|
||||
)
|
||||
|
||||
// Publisher is a concurrency-safe in-memory implementation of
|
||||
// ports.IntentPublisher. The zero value is not usable; call NewPublisher
|
||||
// to construct.
|
||||
type Publisher struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
nextID int
|
||||
err error
|
||||
}
|
||||
|
||||
// NewPublisher constructs an empty Publisher ready for use.
|
||||
func NewPublisher() *Publisher {
|
||||
return &Publisher{}
|
||||
}
|
||||
|
||||
// SetError preloads err to be returned by every Publish call. Pass nil
|
||||
// to reset.
|
||||
func (publisher *Publisher) SetError(err error) {
|
||||
if publisher == nil {
|
||||
return
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
publisher.err = err
|
||||
}
|
||||
|
||||
// Publish records intent and returns a synthetic stream entry id.
|
||||
func (publisher *Publisher) Publish(ctx context.Context, intent notificationintent.Intent) (string, error) {
|
||||
if publisher == nil {
|
||||
return "", errors.New("publish notification intent: nil publisher")
|
||||
}
|
||||
if ctx == nil {
|
||||
return "", errors.New("publish notification intent: nil context")
|
||||
}
|
||||
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
|
||||
if publisher.err != nil {
|
||||
return "", publisher.err
|
||||
}
|
||||
|
||||
publisher.nextID++
|
||||
publisher.published = append(publisher.published, intent)
|
||||
return strconv.Itoa(publisher.nextID), nil
|
||||
}
|
||||
|
||||
// Published returns a snapshot of every Publish-accepted intent in the
|
||||
// order it was received.
|
||||
func (publisher *Publisher) Published() []notificationintent.Intent {
|
||||
if publisher == nil {
|
||||
return nil
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
out := make([]notificationintent.Intent, len(publisher.published))
|
||||
copy(out, publisher.published)
|
||||
return out
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.IntentPublisher = (*Publisher)(nil)
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package invitestub provides an in-memory ports.InviteStore implementation
|
||||
// Package inviteinmem provides an in-memory ports.InviteStore implementation
|
||||
// for service-level tests. The stub mirrors the behavioural contract of the
|
||||
// Redis adapter in redisstate: Save is create-only, UpdateStatus enforces
|
||||
// invite.Transition and the ExpectedFrom CAS guard, and the index reads
|
||||
@@ -6,7 +6,7 @@
|
||||
//
|
||||
// Production code never wires this stub; it is test-only but exposed as a
|
||||
// regular (non _test.go) package so other service test packages can import it.
|
||||
package invitestub
|
||||
package inviteinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package membershipstub provides an in-memory ports.MembershipStore
|
||||
// Package membershipinmem provides an in-memory ports.MembershipStore
|
||||
// implementation for service-level tests. The stub mirrors the
|
||||
// behavioural contract of the Redis adapter in redisstate: Save is
|
||||
// create-only, UpdateStatus enforces membership.Transition and the
|
||||
@@ -8,7 +8,7 @@
|
||||
// Production code never wires this stub; it is test-only but exposed as
|
||||
// a regular (non _test.go) package so other service test packages can
|
||||
// import it.
|
||||
package membershipstub
|
||||
package membershipinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/metricsracenamedir"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
|
||||
@@ -28,7 +28,7 @@ func newRuntime(t *testing.T) (*telemetry.Runtime, sdkmetric.Reader) {
|
||||
|
||||
func newInner(t *testing.T) ports.RaceNameDirectory {
|
||||
t.Helper()
|
||||
stub, err := racenamestub.NewDirectory()
|
||||
stub, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
return stub
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: GMClient)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_gmclient.go -package=mocks galaxy/lobby/internal/ports GMClient
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
ports "galaxy/lobby/internal/ports"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockGMClient is a mock of GMClient interface.
|
||||
type MockGMClient struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockGMClientMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockGMClientMockRecorder is the mock recorder for MockGMClient.
|
||||
type MockGMClientMockRecorder struct {
|
||||
mock *MockGMClient
|
||||
}
|
||||
|
||||
// NewMockGMClient creates a new mock instance.
|
||||
func NewMockGMClient(ctrl *gomock.Controller) *MockGMClient {
|
||||
mock := &MockGMClient{ctrl: ctrl}
|
||||
mock.recorder = &MockGMClientMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockGMClient) EXPECT() *MockGMClientMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Ping mocks base method.
|
||||
func (m *MockGMClient) Ping(ctx context.Context) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Ping", ctx)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Ping indicates an expected call of Ping.
|
||||
func (mr *MockGMClientMockRecorder) Ping(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockGMClient)(nil).Ping), ctx)
|
||||
}
|
||||
|
||||
// RegisterGame mocks base method.
|
||||
func (m *MockGMClient) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RegisterGame", ctx, request)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// RegisterGame indicates an expected call of RegisterGame.
|
||||
func (mr *MockGMClientMockRecorder) RegisterGame(ctx, request any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterGame", reflect.TypeOf((*MockGMClient)(nil).RegisterGame), ctx, request)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: IntentPublisher)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_intentpublisher.go -package=mocks galaxy/lobby/internal/ports IntentPublisher
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
notificationintent "galaxy/notificationintent"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockIntentPublisher is a mock of IntentPublisher interface.
|
||||
type MockIntentPublisher struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockIntentPublisherMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockIntentPublisherMockRecorder is the mock recorder for MockIntentPublisher.
|
||||
type MockIntentPublisherMockRecorder struct {
|
||||
mock *MockIntentPublisher
|
||||
}
|
||||
|
||||
// NewMockIntentPublisher creates a new mock instance.
|
||||
func NewMockIntentPublisher(ctrl *gomock.Controller) *MockIntentPublisher {
|
||||
mock := &MockIntentPublisher{ctrl: ctrl}
|
||||
mock.recorder = &MockIntentPublisherMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockIntentPublisher) EXPECT() *MockIntentPublisherMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Publish mocks base method.
|
||||
func (m *MockIntentPublisher) Publish(ctx context.Context, intent notificationintent.Intent) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Publish", ctx, intent)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Publish indicates an expected call of Publish.
|
||||
func (mr *MockIntentPublisherMockRecorder) Publish(ctx, intent any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Publish", reflect.TypeOf((*MockIntentPublisher)(nil).Publish), ctx, intent)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: RuntimeManager)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_runtimemanager.go -package=mocks galaxy/lobby/internal/ports RuntimeManager
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
ports "galaxy/lobby/internal/ports"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRuntimeManager is a mock of RuntimeManager interface.
|
||||
type MockRuntimeManager struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRuntimeManagerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRuntimeManagerMockRecorder is the mock recorder for MockRuntimeManager.
|
||||
type MockRuntimeManagerMockRecorder struct {
|
||||
mock *MockRuntimeManager
|
||||
}
|
||||
|
||||
// NewMockRuntimeManager creates a new mock instance.
|
||||
func NewMockRuntimeManager(ctrl *gomock.Controller) *MockRuntimeManager {
|
||||
mock := &MockRuntimeManager{ctrl: ctrl}
|
||||
mock.recorder = &MockRuntimeManagerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRuntimeManager) EXPECT() *MockRuntimeManagerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// PublishStartJob mocks base method.
|
||||
func (m *MockRuntimeManager) PublishStartJob(ctx context.Context, gameID, imageRef string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PublishStartJob", ctx, gameID, imageRef)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PublishStartJob indicates an expected call of PublishStartJob.
|
||||
func (mr *MockRuntimeManagerMockRecorder) PublishStartJob(ctx, gameID, imageRef any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishStartJob", reflect.TypeOf((*MockRuntimeManager)(nil).PublishStartJob), ctx, gameID, imageRef)
|
||||
}
|
||||
|
||||
// PublishStopJob mocks base method.
|
||||
func (m *MockRuntimeManager) PublishStopJob(ctx context.Context, gameID string, reason ports.StopReason) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PublishStopJob", ctx, gameID, reason)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PublishStopJob indicates an expected call of PublishStopJob.
|
||||
func (mr *MockRuntimeManagerMockRecorder) PublishStopJob(ctx, gameID, reason any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishStopJob", reflect.TypeOf((*MockRuntimeManager)(nil).PublishStopJob), ctx, gameID, reason)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/lobby/internal/ports (interfaces: UserService)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=../adapters/mocks/mock_userservice.go -package=mocks galaxy/lobby/internal/ports UserService
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
ports "galaxy/lobby/internal/ports"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockUserService is a mock of UserService interface.
|
||||
type MockUserService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockUserServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockUserServiceMockRecorder is the mock recorder for MockUserService.
|
||||
type MockUserServiceMockRecorder struct {
|
||||
mock *MockUserService
|
||||
}
|
||||
|
||||
// NewMockUserService creates a new mock instance.
|
||||
func NewMockUserService(ctrl *gomock.Controller) *MockUserService {
|
||||
mock := &MockUserService{ctrl: ctrl}
|
||||
mock.recorder = &MockUserServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockUserService) EXPECT() *MockUserServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetEligibility mocks base method.
|
||||
func (m *MockUserService) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetEligibility", ctx, userID)
|
||||
ret0, _ := ret[0].(ports.Eligibility)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetEligibility indicates an expected call of GetEligibility.
|
||||
func (mr *MockUserServiceMockRecorder) GetEligibility(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEligibility", reflect.TypeOf((*MockUserService)(nil).GetEligibility), ctx, userID)
|
||||
}
|
||||
+10
-7
@@ -1,10 +1,13 @@
|
||||
// Package racenamestub provides the in-process implementation of the
|
||||
// ports.RaceNameDirectory contract used by unit tests that do not need
|
||||
// a Redis dependency. The stub enforces the full two-tier Race Name
|
||||
// Directory invariants (registered, reservation, pending_registration)
|
||||
// across the lifetime of one process, and is interchangeable with the
|
||||
// Redis adapter under the same shared behavioural test suite.
|
||||
package racenamestub
|
||||
// Package racenameinmem provides the in-process implementation of the
|
||||
// ports.RaceNameDirectory contract. It is used both by unit tests that
|
||||
// do not need a Redis dependency and by deployments that select the
|
||||
// in-memory backend via LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub. It
|
||||
// enforces the full two-tier Race Name Directory invariants
|
||||
// (registered, reservation, pending_registration) across the lifetime
|
||||
// of one process, and is interchangeable with the PostgreSQL adapter
|
||||
// under the shared behavioural test suite at
|
||||
// galaxy/lobby/internal/ports/racenamedirtest.
|
||||
package racenameinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
package racenamestub_test
|
||||
package racenameinmem_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/ports/racenamedirtest"
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
|
||||
func TestDirectoryContract(t *testing.T) {
|
||||
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
|
||||
var opts []racenamestub.Option
|
||||
var opts []racenameinmem.Option
|
||||
if now != nil {
|
||||
opts = append(opts, racenamestub.WithClock(now))
|
||||
opts = append(opts, racenameinmem.WithClock(now))
|
||||
}
|
||||
directory, err := racenamestub.NewDirectory(opts...)
|
||||
directory, err := racenameinmem.NewDirectory(opts...)
|
||||
require.NoError(t, err)
|
||||
return directory
|
||||
})
|
||||
@@ -37,7 +37,7 @@ func TestReserveConcurrentUniquenessInvariant(t *testing.T) {
|
||||
const gameID = "game-concurrency"
|
||||
|
||||
ctx := context.Background()
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
var (
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameintents"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/service/capabilityevaluation"
|
||||
@@ -14,13 +14,26 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func captureIntents(t *testing.T) (*mocks.MockIntentPublisher, *[]notificationintent.Intent) {
|
||||
t.Helper()
|
||||
publisher := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
var captured []notificationintent.Intent
|
||||
publisher.EXPECT().Publish(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
captured = append(captured, intent)
|
||||
return "1", nil
|
||||
}).AnyTimes()
|
||||
return publisher, &captured
|
||||
}
|
||||
|
||||
func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := intentpubstub.NewPublisher()
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
|
||||
mock, captured := captureIntents(t)
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
|
||||
require.NoError(t, err)
|
||||
|
||||
finishedAt := time.UnixMilli(1775121700000).UTC()
|
||||
@@ -34,9 +47,8 @@ func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
|
||||
FinishedAt: finishedAt,
|
||||
}))
|
||||
|
||||
published := stub.Published()
|
||||
require.Len(t, published, 1)
|
||||
intent := published[0]
|
||||
require.Len(t, *captured, 1)
|
||||
intent := (*captured)[0]
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationEligible, intent.NotificationType)
|
||||
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
|
||||
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
|
||||
@@ -53,8 +65,8 @@ func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
|
||||
func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := intentpubstub.NewPublisher()
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
|
||||
mock, captured := captureIntents(t)
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
|
||||
require.NoError(t, err)
|
||||
|
||||
finishedAt := time.UnixMilli(1775121700000).UTC()
|
||||
@@ -67,9 +79,8 @@ func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
|
||||
Reason: capabilityevaluation.ReasonCapabilityNotMet,
|
||||
}))
|
||||
|
||||
published := stub.Published()
|
||||
require.Len(t, published, 1)
|
||||
intent := published[0]
|
||||
require.Len(t, *captured, 1)
|
||||
intent := (*captured)[0]
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationDenied, intent.NotificationType)
|
||||
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
|
||||
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
|
||||
@@ -86,9 +97,10 @@ func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
|
||||
func TestPublisherSurfacesPublisherError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := intentpubstub.NewPublisher()
|
||||
stub.SetError(errors.New("transport unavailable"))
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
|
||||
mock := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
mock.EXPECT().Publish(gomock.Any(), gomock.Any()).
|
||||
Return("", errors.New("transport unavailable")).Times(1)
|
||||
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
|
||||
require.NoError(t, err)
|
||||
|
||||
finishedAt := time.UnixMilli(1775121700000).UTC()
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
// The two streams are intentionally separate: each one carries a single
|
||||
// command kind, which keeps the consumer-side logic in Runtime Manager
|
||||
// simple and avoids a `kind` discriminator inside the message body.
|
||||
//
|
||||
// Envelope shape per `rtmanager/api/runtime-jobs-asyncapi.yaml`:
|
||||
//
|
||||
// - `runtime:start_jobs` — `{game_id, image_ref, requested_at_ms}`,
|
||||
// - `runtime:stop_jobs` — `{game_id, reason, requested_at_ms}`.
|
||||
//
|
||||
// The producer-supplied `image_ref` is resolved by the caller from the
|
||||
// game's `target_engine_version` and the configured engine-image
|
||||
// template; Runtime Manager never resolves engine versions itself.
|
||||
package runtimemanager
|
||||
|
||||
import (
|
||||
@@ -75,20 +84,45 @@ func NewPublisher(cfg Config) (*Publisher, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublishStartJob appends one start-job event for gameID to the
|
||||
// configured start-jobs stream.
|
||||
func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID string) error {
|
||||
return publisher.publish(ctx, "publish start job", publisher.startJobsStream, gameID)
|
||||
// PublishStartJob appends one start-job event for gameID with the
|
||||
// resolved imageRef to the configured start-jobs stream.
|
||||
func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID, imageRef string) error {
|
||||
const op = "publish start job"
|
||||
if err := publisher.checkCommon(op, ctx, gameID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(imageRef) == "" {
|
||||
return fmt.Errorf("%s: image ref must not be empty", op)
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"game_id": gameID,
|
||||
"image_ref": imageRef,
|
||||
"requested_at_ms": publisher.clock().UTC().UnixMilli(),
|
||||
}
|
||||
return publisher.xadd(ctx, op, publisher.startJobsStream, values)
|
||||
}
|
||||
|
||||
// PublishStopJob appends one stop-job event for gameID to the configured
|
||||
// stop-jobs stream. In Lobby publishes stop jobs only from the
|
||||
// orphan-container path inside the runtimejobresult worker.
|
||||
func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string) error {
|
||||
return publisher.publish(ctx, "publish stop job", publisher.stopJobsStream, gameID)
|
||||
// PublishStopJob appends one stop-job event for gameID classified by
|
||||
// reason to the configured stop-jobs stream.
|
||||
func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string, reason ports.StopReason) error {
|
||||
const op = "publish stop job"
|
||||
if err := publisher.checkCommon(op, ctx, gameID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reason.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"game_id": gameID,
|
||||
"reason": reason.String(),
|
||||
"requested_at_ms": publisher.clock().UTC().UnixMilli(),
|
||||
}
|
||||
return publisher.xadd(ctx, op, publisher.stopJobsStream, values)
|
||||
}
|
||||
|
||||
func (publisher *Publisher) publish(ctx context.Context, op, stream, gameID string) error {
|
||||
func (publisher *Publisher) checkCommon(op string, ctx context.Context, gameID string) error {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return fmt.Errorf("%s: nil publisher", op)
|
||||
}
|
||||
@@ -98,11 +132,10 @@ func (publisher *Publisher) publish(ctx context.Context, op, stream, gameID stri
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return fmt.Errorf("%s: game id must not be empty", op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"game_id": gameID,
|
||||
"requested_at_ms": publisher.clock().UTC().UnixMilli(),
|
||||
}
|
||||
func (publisher *Publisher) xadd(ctx context.Context, op, stream string, values map[string]any) error {
|
||||
if _, err := publisher.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: stream,
|
||||
Values: values,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/runtimemanager"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -60,12 +61,13 @@ func TestPublishStartJobAppendsToStartStream(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
publisher, _, client := newTestPublisher(t, func() time.Time { return now })
|
||||
|
||||
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1"))
|
||||
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1", "galaxy/game:v1.0.0"))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:start_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "game-1", entries[0].Values["game_id"])
|
||||
assert.Equal(t, "galaxy/game:v1.0.0", entries[0].Values["image_ref"])
|
||||
assert.Equal(t, strconv.FormatInt(now.UnixMilli(), 10), entries[0].Values["requested_at_ms"])
|
||||
|
||||
stop, err := client.XLen(context.Background(), "runtime:stop_jobs").Result()
|
||||
@@ -73,16 +75,29 @@ func TestPublishStartJobAppendsToStartStream(t *testing.T) {
|
||||
assert.Equal(t, int64(0), stop, "stop stream must remain empty")
|
||||
}
|
||||
|
||||
func TestPublisherStartJobIncludesImageRef(t *testing.T) {
|
||||
publisher, _, client := newTestPublisher(t, nil)
|
||||
|
||||
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1", "registry.example.com/galaxy/game:v1.4.7"))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:start_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "registry.example.com/galaxy/game:v1.4.7", entries[0].Values["image_ref"],
|
||||
"image_ref field must be present in the start envelope")
|
||||
}
|
||||
|
||||
func TestPublishStopJobAppendsToStopStream(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC)
|
||||
publisher, _, client := newTestPublisher(t, func() time.Time { return now })
|
||||
|
||||
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2"))
|
||||
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2", ports.StopReasonOrphanCleanup))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:stop_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "game-2", entries[0].Values["game_id"])
|
||||
assert.Equal(t, "orphan_cleanup", entries[0].Values["reason"])
|
||||
assert.Equal(t, strconv.FormatInt(now.UnixMilli(), 10), entries[0].Values["requested_at_ms"])
|
||||
|
||||
startLen, err := client.XLen(context.Background(), "runtime:start_jobs").Result()
|
||||
@@ -90,18 +105,44 @@ func TestPublishStopJobAppendsToStopStream(t *testing.T) {
|
||||
assert.Equal(t, int64(0), startLen, "start stream must remain empty")
|
||||
}
|
||||
|
||||
func TestPublisherStopJobIncludesReason(t *testing.T) {
|
||||
publisher, _, client := newTestPublisher(t, nil)
|
||||
|
||||
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2", ports.StopReasonCancelled))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:stop_jobs", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "cancelled", entries[0].Values["reason"],
|
||||
"reason field must be present in the stop envelope")
|
||||
}
|
||||
|
||||
func TestPublishRejectsEmptyGameID(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), ""))
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), " "))
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), "", "galaxy/game:v1.0.0"))
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), " ", ports.StopReasonCancelled))
|
||||
}
|
||||
|
||||
func TestPublishStartJobRejectsEmptyImageRef(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), "game-1", ""))
|
||||
require.Error(t, publisher.PublishStartJob(context.Background(), "game-1", " "))
|
||||
}
|
||||
|
||||
func TestPublishStopJobRejectsUnknownReason(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), "game-1", ports.StopReason("")))
|
||||
require.Error(t, publisher.PublishStopJob(context.Background(), "game-1", ports.StopReason("unknown_reason")))
|
||||
}
|
||||
|
||||
func TestPublishRejectsNilContext(t *testing.T) {
|
||||
publisher, _, _ := newTestPublisher(t, nil)
|
||||
|
||||
require.Error(t, publisher.PublishStartJob(nilContext(), "game-1"))
|
||||
require.Error(t, publisher.PublishStopJob(nilContext(), "game-1"))
|
||||
require.Error(t, publisher.PublishStartJob(nilContext(), "game-1", "galaxy/game:v1.0.0"))
|
||||
require.Error(t, publisher.PublishStopJob(nilContext(), "game-1", ports.StopReasonCancelled))
|
||||
}
|
||||
|
||||
// nilContext returns an explicit untyped nil to exercise the defensive
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// Package runtimemanagerstub provides an in-process ports.RuntimeManager
|
||||
// implementation used by service-level and worker-level tests that do
|
||||
// not need a real Redis connection. The stub records every published
|
||||
// job and supports inject-on-error to simulate stream failures.
|
||||
//
|
||||
// Production code never wires this stub.
|
||||
package runtimemanagerstub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Publisher is a concurrency-safe in-memory ports.RuntimeManager.
|
||||
type Publisher struct {
|
||||
mu sync.Mutex
|
||||
startErr error
|
||||
stopErr error
|
||||
startJobs []string
|
||||
stopJobs []string
|
||||
}
|
||||
|
||||
// NewPublisher constructs an empty Publisher.
|
||||
func NewPublisher() *Publisher {
|
||||
return &Publisher{}
|
||||
}
|
||||
|
||||
// SetStartError makes the next PublishStartJob calls return err.
|
||||
// Passing nil clears the override.
|
||||
func (publisher *Publisher) SetStartError(err error) {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
publisher.startErr = err
|
||||
}
|
||||
|
||||
// SetStopError makes the next PublishStopJob calls return err.
|
||||
// Passing nil clears the override.
|
||||
func (publisher *Publisher) SetStopError(err error) {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
publisher.stopErr = err
|
||||
}
|
||||
|
||||
// StartJobs returns the ordered slice of game ids passed to
|
||||
// PublishStartJob.
|
||||
func (publisher *Publisher) StartJobs() []string {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
return append([]string(nil), publisher.startJobs...)
|
||||
}
|
||||
|
||||
// StopJobs returns the ordered slice of game ids passed to
|
||||
// PublishStopJob.
|
||||
func (publisher *Publisher) StopJobs() []string {
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
return append([]string(nil), publisher.stopJobs...)
|
||||
}
|
||||
|
||||
// PublishStartJob records gameID and returns the configured error.
|
||||
func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID string) error {
|
||||
if ctx == nil {
|
||||
return errors.New("publish start job: nil context")
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
if publisher.startErr != nil {
|
||||
return publisher.startErr
|
||||
}
|
||||
publisher.startJobs = append(publisher.startJobs, gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishStopJob records gameID and returns the configured error.
|
||||
func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string) error {
|
||||
if ctx == nil {
|
||||
return errors.New("publish stop job: nil context")
|
||||
}
|
||||
publisher.mu.Lock()
|
||||
defer publisher.mu.Unlock()
|
||||
if publisher.stopErr != nil {
|
||||
return publisher.stopErr
|
||||
}
|
||||
publisher.stopJobs = append(publisher.stopJobs, gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.RuntimeManager = (*Publisher)(nil)
|
||||
@@ -1,61 +0,0 @@
|
||||
// Package streamlagprobestub provides an in-memory ports.StreamLagProbe
|
||||
// implementation for tests that do not need a Redis instance. Production
|
||||
// code never wires this stub.
|
||||
package streamlagprobestub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Probe is a concurrency-safe in-memory ports.StreamLagProbe. The zero
|
||||
// value reports `(0, false, nil)` for every stream until Set is called.
|
||||
type Probe struct {
|
||||
mu sync.Mutex
|
||||
results map[string]Result
|
||||
fallback Result
|
||||
}
|
||||
|
||||
// Result stores the value the probe reports for a stream.
|
||||
type Result struct {
|
||||
Age time.Duration
|
||||
Found bool
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewProbe constructs one Probe with no preconfigured results.
|
||||
func NewProbe() *Probe {
|
||||
return &Probe{results: make(map[string]Result)}
|
||||
}
|
||||
|
||||
// Set installs the result the probe will return for stream.
|
||||
func (probe *Probe) Set(stream string, result Result) {
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
probe.results[stream] = result
|
||||
}
|
||||
|
||||
// SetFallback installs the result returned when no per-stream result is
|
||||
// configured.
|
||||
func (probe *Probe) SetFallback(result Result) {
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
probe.fallback = result
|
||||
}
|
||||
|
||||
// OldestUnprocessedAge satisfies ports.StreamLagProbe.
|
||||
func (probe *Probe) OldestUnprocessedAge(_ context.Context, stream, _ string) (time.Duration, bool, error) {
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
|
||||
if result, ok := probe.results[stream]; ok {
|
||||
return result.Age, result.Found, result.Err
|
||||
}
|
||||
return probe.fallback.Age, probe.fallback.Found, probe.fallback.Err
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.StreamLagProbe = (*Probe)(nil)
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Package streamoffsetstub provides an in-process ports.StreamOffsetStore
|
||||
// Package streamoffsetinmem provides an in-process ports.StreamOffsetStore
|
||||
// used by worker-level tests that do not need Redis. Production code
|
||||
// never wires this stub.
|
||||
package streamoffsetstub
|
||||
package streamoffsetinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/streamoffsetstub"
|
||||
"galaxy/lobby/internal/adapters/streamoffsetinmem"
|
||||
"galaxy/lobby/internal/adapters/userlifecycle"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
@@ -33,7 +33,7 @@ func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discar
|
||||
type harness struct {
|
||||
server *miniredis.Miniredis
|
||||
client *redis.Client
|
||||
offsets *streamoffsetstub.Store
|
||||
offsets *streamoffsetinmem.Store
|
||||
consumer *userlifecycle.Consumer
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func newHarness(t *testing.T) *harness {
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
offsets := streamoffsetstub.NewStore()
|
||||
offsets := streamoffsetinmem.NewStore()
|
||||
consumer, err := userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Client: client,
|
||||
Stream: testStream,
|
||||
@@ -70,21 +70,21 @@ func TestNewConsumerRejectsMissingDeps(t *testing.T) {
|
||||
_, err := userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Stream: testStream,
|
||||
BlockTimeout: time.Second,
|
||||
OffsetStore: streamoffsetstub.NewStore(),
|
||||
OffsetStore: streamoffsetinmem.NewStore(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Client: client,
|
||||
BlockTimeout: time.Second,
|
||||
OffsetStore: streamoffsetstub.NewStore(),
|
||||
OffsetStore: streamoffsetinmem.NewStore(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = userlifecycle.NewConsumer(userlifecycle.Config{
|
||||
Client: client,
|
||||
Stream: testStream,
|
||||
OffsetStore: streamoffsetstub.NewStore(),
|
||||
OffsetStore: streamoffsetinmem.NewStore(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Package userlifecyclestub provides an in-process
|
||||
// ports.UserLifecycleConsumer used by worker-level tests that do not
|
||||
// need a real Redis stream. Production code never wires this stub.
|
||||
package userlifecyclestub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Consumer is an in-memory ports.UserLifecycleConsumer. Tests publish
|
||||
// events synchronously through Deliver and observe handler errors via
|
||||
// the returned value.
|
||||
type Consumer struct {
|
||||
mu sync.Mutex
|
||||
handler ports.UserLifecycleHandler
|
||||
}
|
||||
|
||||
// NewConsumer constructs an empty Consumer.
|
||||
func NewConsumer() *Consumer {
|
||||
return &Consumer{}
|
||||
}
|
||||
|
||||
// OnEvent installs handler as the dispatch target. A second call
|
||||
// replaces the previous handler.
|
||||
func (consumer *Consumer) OnEvent(handler ports.UserLifecycleHandler) {
|
||||
if consumer == nil {
|
||||
return
|
||||
}
|
||||
consumer.mu.Lock()
|
||||
consumer.handler = handler
|
||||
consumer.mu.Unlock()
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled. The stub does not pull events from
|
||||
// any backend; test code drives delivery via Deliver.
|
||||
func (consumer *Consumer) Run(ctx context.Context) error {
|
||||
if consumer == nil {
|
||||
return errors.New("run user lifecycle stub: nil consumer")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("run user lifecycle stub: nil context")
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Shutdown is a no-op.
|
||||
func (consumer *Consumer) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown user lifecycle stub: nil context")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deliver dispatches event to the registered handler synchronously and
|
||||
// returns the handler's error. It is the test-only entry point used by
|
||||
// worker_test fixtures.
|
||||
func (consumer *Consumer) Deliver(ctx context.Context, event ports.UserLifecycleEvent) error {
|
||||
if consumer == nil {
|
||||
return errors.New("deliver user lifecycle stub: nil consumer")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("deliver user lifecycle stub: nil context")
|
||||
}
|
||||
consumer.mu.Lock()
|
||||
handler := consumer.handler
|
||||
consumer.mu.Unlock()
|
||||
if handler == nil {
|
||||
return errors.New("deliver user lifecycle stub: no handler registered")
|
||||
}
|
||||
return handler(ctx, event)
|
||||
}
|
||||
|
||||
// Compile-time assertion: Consumer satisfies the port interface.
|
||||
var _ ports.UserLifecycleConsumer = (*Consumer)(nil)
|
||||
@@ -1,107 +0,0 @@
|
||||
// Package userservicestub provides an in-process
|
||||
// ports.UserService implementation for service-level tests. The stub
|
||||
// stores per-user Eligibility values and lets tests inject errors for
|
||||
// specific user ids to exercise the unavailable / decode-failure paths.
|
||||
package userservicestub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Service is a concurrency-safe in-memory implementation of
|
||||
// ports.UserService. The zero value is not usable; call NewService to
|
||||
// construct.
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
eligibilities map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
defaultMissing bool
|
||||
}
|
||||
|
||||
// NewService constructs an empty Service with no preloaded
|
||||
// eligibilities. By default an unknown user maps to
|
||||
// Eligibility{Exists:false}, mirroring the production HTTP client's
|
||||
// 404 handling. Use WithDefaultUnavailable to flip the unknown-user
|
||||
// behaviour to a transport failure.
|
||||
func NewService(opts ...Option) *Service {
|
||||
service := &Service{
|
||||
eligibilities: make(map[string]ports.Eligibility),
|
||||
failures: make(map[string]error),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(service)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Option tunes Service construction.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithDefaultUnavailable makes the stub return ErrUserServiceUnavailable
|
||||
// for any user id without a preloaded eligibility or failure entry.
|
||||
// Useful for tests that exercise the "User Service down" path without
|
||||
// having to enumerate every caller.
|
||||
func WithDefaultUnavailable() Option {
|
||||
return func(service *Service) {
|
||||
service.defaultMissing = true
|
||||
}
|
||||
}
|
||||
|
||||
// SetEligibility preloads eligibility for userID. Subsequent calls
|
||||
// overwrite the prior value.
|
||||
func (service *Service) SetEligibility(userID string, eligibility ports.Eligibility) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
service.eligibilities[strings.TrimSpace(userID)] = eligibility
|
||||
}
|
||||
|
||||
// SetFailure preloads err to be returned for userID. err takes
|
||||
// precedence over any preloaded eligibility.
|
||||
func (service *Service) SetFailure(userID string, err error) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
service.failures[strings.TrimSpace(userID)] = err
|
||||
}
|
||||
|
||||
// GetEligibility returns the preloaded eligibility for userID.
|
||||
func (service *Service) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
|
||||
if service == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil service")
|
||||
}
|
||||
if ctx == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: user id must not be empty")
|
||||
}
|
||||
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
if err, ok := service.failures[trimmed]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if eligibility, ok := service.eligibilities[trimmed]; ok {
|
||||
return eligibility, nil
|
||||
}
|
||||
if service.defaultMissing {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", ports.ErrUserServiceUnavailable)
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.UserService = (*Service)(nil)
|
||||
Reference in New Issue
Block a user