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