feat: runtime manager
This commit is contained in:
@@ -5,15 +5,16 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gapactivationstub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gapactivationinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -25,8 +26,44 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
|
||||
@@ -44,12 +81,13 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) { return f.me
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
applications *applicationstub.Store
|
||||
directory *racenamestub.Directory
|
||||
gapStore *gapactivationstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
gapStore *gapactivationinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
openPublicGameID common.GameID
|
||||
}
|
||||
@@ -57,11 +95,11 @@ type fixture struct {
|
||||
func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
applications := applicationstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
applications := applicationinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-public",
|
||||
@@ -80,14 +118,16 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
rec := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
memberships: memberships,
|
||||
applications: applications,
|
||||
directory: dir,
|
||||
gapStore: gapactivationstub.NewStore(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
gapStore: gapactivationinmem.NewStore(),
|
||||
intentRec: rec,
|
||||
intents: newIntentMock(t, rec),
|
||||
ids: fixedIDs{membershipID: "membership-fixed"},
|
||||
openPublicGameID: gameRecord.GameID,
|
||||
}
|
||||
@@ -151,7 +191,7 @@ func TestApproveHappyPath(t *testing.T) {
|
||||
assert.True(t, availability.Taken)
|
||||
assert.Equal(t, "user-1", availability.HolderUserID)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipApproved, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{"user-1"}, intents[0].RecipientUserIDs)
|
||||
@@ -328,10 +368,10 @@ func TestApproveNameTakenByAnotherUser(t *testing.T) {
|
||||
assert.Equal(t, "user-other", availability.HolderUserID)
|
||||
}
|
||||
|
||||
// approveCASStub wraps applicationstub.Store but injects ErrConflict on
|
||||
// approveCASStub wraps applicationinmem.Store but injects ErrConflict on
|
||||
// the next UpdateStatus call so we can observe the rollback path.
|
||||
type approveCASStub struct {
|
||||
*applicationstub.Store
|
||||
*applicationinmem.Store
|
||||
failNext bool
|
||||
}
|
||||
|
||||
@@ -379,7 +419,7 @@ func TestApprovePublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot")
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intentRec.setErr(errors.New("publish failed"))
|
||||
|
||||
svc := newService(t, f)
|
||||
got, err := svc.Handle(context.Background(), approveapplication.Input{
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -31,20 +31,20 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
}
|
||||
|
||||
type fixtures struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
directory *racenamestub.Directory
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
}
|
||||
|
||||
func newFixtures(t *testing.T) *fixtures {
|
||||
t.Helper()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &fixtures{
|
||||
games: gamestub.NewStore(),
|
||||
memberships: membershipstub.NewStore(),
|
||||
games: gameinmem.NewStore(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
directory: directory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -31,7 +31,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// status the surface must reject or accept.
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -101,7 +101,7 @@ func TestHandleFromCancellableStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-a", game.GameTypePublic, "", status, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -131,7 +131,7 @@ func TestHandleFromRejectedStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-b", game.GameTypePublic, "", status, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -149,7 +149,7 @@ func TestHandleAlreadyCancelledIsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-c", game.GameTypePublic, "", game.StatusCancelled, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -165,7 +165,7 @@ func TestHandleFinishedIsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-f", game.GameTypePublic, "", game.StatusFinished, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -181,7 +181,7 @@ func TestHandleOwnerCancelsPrivate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-1", game.StatusEnrollmentOpen, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -198,7 +198,7 @@ func TestHandleNonOwnerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-1", game.StatusEnrollmentOpen, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -214,7 +214,7 @@ func TestHandleUserCannotCancelPublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusEnrollmentOpen, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -229,7 +229,7 @@ func TestHandleUserCannotCancelPublic(t *testing.T) {
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), cancelgame.Input{
|
||||
@@ -242,7 +242,7 @@ func TestHandleNotFound(t *testing.T) {
|
||||
func TestHandleInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), cancelgame.Input{
|
||||
@@ -256,7 +256,7 @@ func TestHandleInvalidActor(t *testing.T) {
|
||||
func TestHandleInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), cancelgame.Input{
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/evaluationguardstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameturnstatsstub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/evaluationguardinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gameturnstatsinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -51,12 +51,12 @@ type fixture struct {
|
||||
finishedAt time.Time
|
||||
gameID common.GameID
|
||||
gameName string
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
stats *gameturnstatsstub.Store
|
||||
directory *racenamestub.Directory
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
stats *gameturnstatsinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
intents *spyIntents
|
||||
guard *evaluationguardstub.Store
|
||||
guard *evaluationguardinmem.Store
|
||||
service *capabilityevaluation.Service
|
||||
}
|
||||
|
||||
@@ -65,13 +65,13 @@ func newFixture(t *testing.T) *fixture {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
finishedAt := now
|
||||
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
stats := gameturnstatsstub.NewStore()
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now.Add(-time.Hour))))
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
stats := gameturnstatsinmem.NewStore()
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now.Add(-time.Hour))))
|
||||
require.NoError(t, err)
|
||||
intents := &spyIntents{}
|
||||
guard := evaluationguardstub.NewStore()
|
||||
guard := evaluationguardinmem.NewStore()
|
||||
|
||||
gameID := common.GameID("game-finished")
|
||||
gameName := "Final Showdown"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/idgen"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -88,11 +88,11 @@ func TestNewServiceRequiresStoreAndIDs(t *testing.T) {
|
||||
_, err := creategame.NewService(creategame.Dependencies{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = creategame.NewService(creategame.Dependencies{Games: gamestub.NewStore()})
|
||||
_, err = creategame.NewService(creategame.Dependencies{Games: gameinmem.NewStore()})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-ok"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -102,7 +102,7 @@ func TestHandleAdminCreatesPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: &stubIDGenerator{next: "game-alpha"},
|
||||
@@ -129,7 +129,7 @@ func TestHandleUserCreatesPrivateGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 11, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: &stubIDGenerator{next: "game-beta"},
|
||||
@@ -150,7 +150,7 @@ func TestHandleAdminForbiddenForPrivateGame(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -169,7 +169,7 @@ func TestHandleUserForbiddenForPublicGame(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -188,7 +188,7 @@ func TestHandleInvalidActorReturnsError(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -208,7 +208,7 @@ func TestHandleDomainValidationFailurePropagates(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-bad-cron"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -228,7 +228,7 @@ func TestHandleEnrollmentDeadlineInPastFails(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-past"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -249,7 +249,7 @@ func TestHandleIDGeneratorErrorPropagates(t *testing.T) {
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
boom := errors.New("entropy exhausted")
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
IDs: &stubIDGenerator{err: boom},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
@@ -309,7 +309,7 @@ func TestHandleUsesRealIDGeneratorShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: idgen.NewGenerator(),
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -23,8 +24,46 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// intentRec captures every Publish call so tests can assert on the
|
||||
// resulting intent. Per-test error injection sets err.
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
ownerUserID = "user-owner"
|
||||
inviteeUserID = "user-invitee"
|
||||
@@ -45,10 +84,11 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) { return "",
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
game game.Game
|
||||
}
|
||||
@@ -56,9 +96,9 @@ type fixture struct {
|
||||
func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-private",
|
||||
@@ -78,12 +118,13 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
rec := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invites,
|
||||
memberships: memberships,
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
intentRec: rec,
|
||||
ids: fixedIDs{inviteID: "invite-fixed"},
|
||||
game: gameRecord,
|
||||
}
|
||||
@@ -91,6 +132,9 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
|
||||
func newService(t *testing.T, f *fixture) *createinvite.Service {
|
||||
t.Helper()
|
||||
if f.intents == nil {
|
||||
f.intents = newIntentMock(t, f.intentRec)
|
||||
}
|
||||
svc, err := createinvite.NewService(createinvite.Dependencies{
|
||||
Games: f.games,
|
||||
Invites: f.invites,
|
||||
@@ -127,7 +171,7 @@ func TestHandleHappyPath(t *testing.T) {
|
||||
assert.Equal(t, f.game.EnrollmentEndsAt, got.ExpiresAt)
|
||||
assert.Empty(t, got.RaceName)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteCreated, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{inviteeUserID}, intents[0].RecipientUserIDs)
|
||||
@@ -316,7 +360,7 @@ func TestHandleInviterNameUsesActiveMembershipRaceName(t *testing.T) {
|
||||
_, err = svc.Handle(context.Background(), defaultInput(f))
|
||||
require.NoError(t, err)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Contains(t, intents[0].PayloadJSON, `"inviter_name":"OwnerRace"`)
|
||||
}
|
||||
@@ -329,7 +373,7 @@ func TestHandleInviterNameFallsBackToUserID(t *testing.T) {
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
require.NoError(t, err)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Contains(t, intents[0].PayloadJSON, `"inviter_name":"`+ownerUserID+`"`)
|
||||
}
|
||||
@@ -337,7 +381,7 @@ func TestHandleInviterNameFallsBackToUserID(t *testing.T) {
|
||||
func TestHandlePublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intentRec.setErr(errors.New("publish failed"))
|
||||
svc := newService(t, f)
|
||||
|
||||
got, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -30,14 +30,14 @@ func fixedClock(at time.Time) func() time.Time { return func() time.Time { retur
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
invites *invitestub.Store
|
||||
invites *inviteinmem.Store
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
return &fixture{
|
||||
now: time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC),
|
||||
invites: invitestub.NewStore(),
|
||||
invites: inviteinmem.NewStore(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -27,17 +27,17 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
invites *invitestub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
svc *getgame.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
svc, err := getgame.NewService(getgame.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -55,7 +55,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -88,7 +88,7 @@ func seedGame(
|
||||
|
||||
func seedMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
status membership.Status,
|
||||
@@ -121,7 +121,7 @@ func seedMembership(
|
||||
|
||||
func seedInvite(
|
||||
t *testing.T,
|
||||
store *invitestub.Store,
|
||||
store *inviteinmem.Store,
|
||||
gameID common.GameID,
|
||||
inviterID, inviteeID string,
|
||||
status invite.Status,
|
||||
@@ -364,9 +364,9 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps getgame.Dependencies
|
||||
}{
|
||||
{"nil games", getgame.Dependencies{Memberships: membershipstub.NewStore(), Invites: invitestub.NewStore()}},
|
||||
{"nil memberships", getgame.Dependencies{Games: gamestub.NewStore(), Invites: invitestub.NewStore()}},
|
||||
{"nil invites", getgame.Dependencies{Games: gamestub.NewStore(), Memberships: membershipstub.NewStore()}},
|
||||
{"nil games", getgame.Dependencies{Memberships: membershipinmem.NewStore(), Invites: inviteinmem.NewStore()}},
|
||||
{"nil memberships", getgame.Dependencies{Games: gameinmem.NewStore(), Invites: inviteinmem.NewStore()}},
|
||||
{"nil invites", getgame.Dependencies{Games: gameinmem.NewStore(), Memberships: membershipinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -380,12 +380,12 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
func TestHandleSurfacesStoreError(t *testing.T) {
|
||||
// Sanity check that errors from the membership store bubble up wrapped.
|
||||
t.Parallel()
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := &erroringMemberships{err: errors.New("stub failure")}
|
||||
svc, err := getgame.NewService(getgame.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
Invites: invitestub.NewStore(),
|
||||
Invites: inviteinmem.NewStore(),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -401,7 +401,7 @@ func TestHandleSurfacesStoreError(t *testing.T) {
|
||||
}
|
||||
|
||||
type erroringMemberships struct {
|
||||
membershipstub.Store
|
||||
membershipinmem.Store
|
||||
err error
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -23,15 +23,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listgames.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listgames.NewService(listgames.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -43,7 +43,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGameAt(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -76,7 +76,7 @@ func seedGameAt(
|
||||
|
||||
func seedActiveMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
now time.Time,
|
||||
@@ -289,8 +289,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listgames.Dependencies
|
||||
}{
|
||||
{"nil games", listgames.Dependencies{Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listgames.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listgames.Dependencies{Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listgames.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -24,15 +24,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listmemberships.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listmemberships.NewService(listmemberships.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -44,7 +44,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -71,7 +71,7 @@ func seedGame(
|
||||
|
||||
func seedMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
status membership.Status,
|
||||
@@ -230,8 +230,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmemberships.Dependencies
|
||||
}{
|
||||
{"nil games", listmemberships.Dependencies{Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listmemberships.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listmemberships.Dependencies{Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listmemberships.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -24,15 +24,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
applications *applicationstub.Store
|
||||
games *gameinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
svc *listmyapplications.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
apps := applicationstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
apps := applicationinmem.NewStore()
|
||||
svc, err := listmyapplications.NewService(listmyapplications.Dependencies{
|
||||
Games: games,
|
||||
Applications: apps,
|
||||
@@ -44,7 +44,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
name string,
|
||||
@@ -75,7 +75,7 @@ func seedGame(
|
||||
|
||||
func seedApplication(
|
||||
t *testing.T,
|
||||
store *applicationstub.Store,
|
||||
store *applicationinmem.Store,
|
||||
id common.ApplicationID,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
@@ -180,8 +180,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmyapplications.Dependencies
|
||||
}{
|
||||
{"nil games", listmyapplications.Dependencies{Applications: applicationstub.NewStore()}},
|
||||
{"nil applications", listmyapplications.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listmyapplications.Dependencies{Applications: applicationinmem.NewStore()}},
|
||||
{"nil applications", listmyapplications.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -24,15 +24,15 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listmygames.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listmygames.NewService(listmygames.Dependencies{
|
||||
Games: games,
|
||||
Memberships: memberships,
|
||||
@@ -44,7 +44,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
status game.Status,
|
||||
now time.Time,
|
||||
@@ -78,7 +78,7 @@ func seedGameWithStatus(
|
||||
|
||||
func seedMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID string,
|
||||
status membership.Status,
|
||||
@@ -188,8 +188,8 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmygames.Dependencies
|
||||
}{
|
||||
{"nil games", listmygames.Dependencies{Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listmygames.Dependencies{Games: gamestub.NewStore()}},
|
||||
{"nil games", listmygames.Dependencies{Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listmygames.Dependencies{Games: gameinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -26,17 +26,17 @@ func silentLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
svc *listmyinvites.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
svc, err := listmyinvites.NewService(listmyinvites.Dependencies{
|
||||
Games: games,
|
||||
Invites: invites,
|
||||
@@ -49,7 +49,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
|
||||
func seedPrivateGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
owner string,
|
||||
name string,
|
||||
@@ -76,7 +76,7 @@ func seedPrivateGame(
|
||||
|
||||
func seedInvite(
|
||||
t *testing.T,
|
||||
store *invitestub.Store,
|
||||
store *inviteinmem.Store,
|
||||
id common.InviteID,
|
||||
gameID common.GameID,
|
||||
inviter, invitee string,
|
||||
@@ -110,7 +110,7 @@ func seedInvite(
|
||||
|
||||
func seedActiveMembership(
|
||||
t *testing.T,
|
||||
store *membershipstub.Store,
|
||||
store *membershipinmem.Store,
|
||||
gameID common.GameID,
|
||||
userID, raceName string,
|
||||
now time.Time,
|
||||
@@ -222,9 +222,9 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
name string
|
||||
deps listmyinvites.Dependencies
|
||||
}{
|
||||
{"nil games", listmyinvites.Dependencies{Invites: invitestub.NewStore(), Memberships: membershipstub.NewStore()}},
|
||||
{"nil invites", listmyinvites.Dependencies{Games: gamestub.NewStore(), Memberships: membershipstub.NewStore()}},
|
||||
{"nil memberships", listmyinvites.Dependencies{Games: gamestub.NewStore(), Invites: invitestub.NewStore()}},
|
||||
{"nil games", listmyinvites.Dependencies{Invites: inviteinmem.NewStore(), Memberships: membershipinmem.NewStore()}},
|
||||
{"nil invites", listmyinvites.Dependencies{Games: gameinmem.NewStore(), Memberships: membershipinmem.NewStore()}},
|
||||
{"nil memberships", listmyinvites.Dependencies{Games: gameinmem.NewStore(), Invites: inviteinmem.NewStore()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -28,17 +28,17 @@ func silentLogger() *slog.Logger {
|
||||
// race-name directory stub and the in-process game store.
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
games *gamestub.Store
|
||||
directory *racenameinmem.Directory
|
||||
games *gameinmem.Store
|
||||
service *listmyracenames.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
Games: games,
|
||||
@@ -217,9 +217,9 @@ func TestHandleSortByTimestamp(t *testing.T) {
|
||||
const userID = "user-sort"
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
clock := now
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return clock }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return clock }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
Games: games,
|
||||
@@ -281,9 +281,9 @@ func TestHandleSortByTimestamp(t *testing.T) {
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
|
||||
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Games: games,
|
||||
@@ -299,4 +299,4 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
// Sanity guard so a future port refactor that drops the user-keyed
|
||||
// indexes immediately breaks the test build instead of silently
|
||||
// regressing the no-full-scan invariant.
|
||||
var _ ports.RaceNameDirectory = (*racenamestub.Directory)(nil)
|
||||
var _ ports.RaceNameDirectory = (*racenameinmem.Directory)(nil)
|
||||
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -21,8 +22,34 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
publicGameID = common.GameID("game-public")
|
||||
privateGameID = common.GameID("game-private")
|
||||
@@ -35,22 +62,26 @@ func fixedClock(at time.Time) func() time.Time { return func() time.Time { retur
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
return &fixture{
|
||||
rec := &intentRec{}
|
||||
f := &fixture{
|
||||
now: now,
|
||||
games: gamestub.NewStore(),
|
||||
invites: invitestub.NewStore(),
|
||||
memberships: membershipstub.NewStore(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
games: gameinmem.NewStore(),
|
||||
invites: inviteinmem.NewStore(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
intentRec: rec,
|
||||
}
|
||||
f.intents = newIntentMock(t, rec)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) addGame(t *testing.T, gameID common.GameID, gameType game.GameType, owner string, minPlayers int) game.Game {
|
||||
@@ -154,7 +185,7 @@ func TestHandleOwnerClosesPrivateEnrollmentAndExpiresInvites(t *testing.T) {
|
||||
assert.Equal(t, invite.StatusExpired, rec.Status)
|
||||
}
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 2)
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intent.NotificationType)
|
||||
@@ -231,7 +262,7 @@ func TestHandleBelowMinPlayersConflict(t *testing.T) {
|
||||
current, err := f.games.Get(context.Background(), publicGameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusEnrollmentOpen, current.Status)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
func TestHandleEmptyInvitesProducesNoNotifications(t *testing.T) {
|
||||
@@ -246,5 +277,5 @@ func TestHandleEmptyInvitesProducesNoNotifications(t *testing.T) {
|
||||
GameID: privateGameID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -27,7 +27,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
|
||||
func seedDraftGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -71,7 +71,7 @@ func TestHandleAdminHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-alpha", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -89,7 +89,7 @@ func TestHandleOwnerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-p", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -106,7 +106,7 @@ func TestHandleNonOwnerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-p", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -122,7 +122,7 @@ func TestHandleUserCannotOpenPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-pub", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -138,7 +138,7 @@ func TestHandleFromEnrollmentOpenConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-x", game.GameTypePublic, "", now)
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
@@ -161,7 +161,7 @@ func TestHandleFromReadyToStartInvalidTransition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-rts", game.GameTypePublic, "", now)
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
@@ -191,7 +191,7 @@ func TestHandleFromReadyToStartInvalidTransition(t *testing.T) {
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), openenrollment.Input{
|
||||
@@ -204,7 +204,7 @@ func TestHandleNotFound(t *testing.T) {
|
||||
func TestHandleInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), openenrollment.Input{
|
||||
@@ -218,7 +218,7 @@ func TestHandleInvalidActor(t *testing.T) {
|
||||
func TestHandleInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), openenrollment.Input{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -32,7 +32,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// any source status.
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -98,7 +98,7 @@ func TestPauseGameAdminHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusRunning, now)
|
||||
|
||||
at := now.Add(time.Hour)
|
||||
@@ -117,7 +117,7 @@ func TestPauseGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusRunning, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -134,7 +134,7 @@ func TestPauseGameRejectsNonOwnerUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusRunning, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -150,7 +150,7 @@ func TestPauseGameRejectsUserActorOnPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusRunning, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -181,7 +181,7 @@ func TestPauseGameRejectsWrongStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-x", game.GameTypePublic, "", status, now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -197,7 +197,7 @@ func TestPauseGameRejectsWrongStatuses(t *testing.T) {
|
||||
func TestPauseGameRejectsMissingRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), pausegame.Input{
|
||||
@@ -210,7 +210,7 @@ func TestPauseGameRejectsMissingRecord(t *testing.T) {
|
||||
func TestPauseGameInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), pausegame.Input{
|
||||
@@ -224,7 +224,7 @@ func TestPauseGameInvalidActor(t *testing.T) {
|
||||
func TestPauseGameInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), pausegame.Input{
|
||||
|
||||
@@ -5,16 +5,16 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gapactivationstub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/gapactivationinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -26,8 +26,87 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
type userRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
ownerUserID = "user-owner"
|
||||
inviteeUserID = "user-invitee"
|
||||
@@ -49,13 +128,15 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) { return f.me
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
memberships *membershipstub.Store
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
gapStore *gapactivationstub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
users *userRec
|
||||
usersMock *mocks.MockUserService
|
||||
gapStore *gapactivationinmem.Store
|
||||
intents *intentRec
|
||||
intentsMock *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
game game.Game
|
||||
}
|
||||
@@ -63,11 +144,11 @@ type fixture struct {
|
||||
func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-private",
|
||||
@@ -87,7 +168,7 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
users := userservicestub.NewService()
|
||||
users := &userRec{}
|
||||
activeEligibility := ports.Eligibility{
|
||||
Exists: true,
|
||||
CanLogin: true,
|
||||
@@ -96,9 +177,10 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
CanJoinGame: true,
|
||||
CanUpdateProfile: true,
|
||||
}
|
||||
users.SetEligibility(ownerUserID, activeEligibility)
|
||||
users.SetEligibility(inviteeUserID, activeEligibility)
|
||||
users.setEligibility(ownerUserID, activeEligibility)
|
||||
users.setEligibility(inviteeUserID, activeEligibility)
|
||||
|
||||
intents := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
@@ -106,8 +188,10 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
|
||||
memberships: memberships,
|
||||
directory: dir,
|
||||
users: users,
|
||||
gapStore: gapactivationstub.NewStore(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
usersMock: newUserMock(t, users),
|
||||
gapStore: gapactivationinmem.NewStore(),
|
||||
intents: intents,
|
||||
intentsMock: newIntentMock(t, intents),
|
||||
ids: fixedIDs{membershipID: "membership-fixed"},
|
||||
game: gameRecord,
|
||||
}
|
||||
@@ -120,9 +204,9 @@ func newService(t *testing.T, f *fixture) *redeeminvite.Service {
|
||||
Invites: f.invites,
|
||||
Memberships: f.memberships,
|
||||
Directory: f.directory,
|
||||
Users: f.users,
|
||||
Users: f.usersMock,
|
||||
GapStore: f.gapStore,
|
||||
Intents: f.intents,
|
||||
Intents: f.intentsMock,
|
||||
IDs: f.ids,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
@@ -181,7 +265,7 @@ func TestRedeemHappyPath(t *testing.T) {
|
||||
assert.True(t, avail.Taken)
|
||||
assert.Equal(t, inviteeUserID, avail.HolderUserID)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteRedeemed, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{ownerUserID}, intents[0].RecipientUserIDs)
|
||||
@@ -194,7 +278,7 @@ func TestRedeemRejectsInviterPermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetEligibility(ownerUserID, ports.Eligibility{
|
||||
f.users.setEligibility(ownerUserID, ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
})
|
||||
@@ -212,7 +296,7 @@ func TestRedeemRejectsInviteePermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetEligibility(inviteeUserID, ports.Eligibility{
|
||||
f.users.setEligibility(inviteeUserID, ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
})
|
||||
@@ -226,7 +310,7 @@ func TestRedeemRejectsDeletedInviter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetEligibility(ownerUserID, ports.Eligibility{Exists: false})
|
||||
f.users.setEligibility(ownerUserID, ports.Eligibility{Exists: false})
|
||||
svc := newService(t, f)
|
||||
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f, inv))
|
||||
@@ -237,7 +321,7 @@ func TestRedeemSurfacesUserServiceTransportFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.users.SetFailure(ownerUserID, ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure(ownerUserID, ports.ErrUserServiceUnavailable)
|
||||
svc := newService(t, f)
|
||||
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f, inv))
|
||||
@@ -410,10 +494,10 @@ func TestRedeemInvalidRaceName(t *testing.T) {
|
||||
require.ErrorIs(t, err, ports.ErrInvalidName)
|
||||
}
|
||||
|
||||
// redeemCASStub wraps invitestub.Store but injects ErrConflict on the next
|
||||
// redeemCASStub wraps inviteinmem.Store but injects ErrConflict on the next
|
||||
// UpdateStatus call so we can observe the rollback path.
|
||||
type redeemCASStub struct {
|
||||
*invitestub.Store
|
||||
*inviteinmem.Store
|
||||
failNext bool
|
||||
}
|
||||
|
||||
@@ -436,9 +520,9 @@ func TestRedeemCASConflictReleasesReservation(t *testing.T) {
|
||||
Invites: cas,
|
||||
Memberships: f.memberships,
|
||||
Directory: f.directory,
|
||||
Users: f.users,
|
||||
Users: f.usersMock,
|
||||
GapStore: f.gapStore,
|
||||
Intents: f.intents,
|
||||
Intents: f.intentsMock,
|
||||
IDs: f.ids,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
@@ -458,7 +542,7 @@ func TestRedeemPublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t, 4, 1)
|
||||
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intents.setErr(errors.New("publish failed"))
|
||||
|
||||
svc := newService(t, f)
|
||||
got, err := svc.Handle(context.Background(), defaultInput(f, inv))
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
@@ -19,28 +19,113 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
type userRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
directory *racenameinmem.Directory
|
||||
users *userRec
|
||||
usersMock *mocks.MockUserService
|
||||
intents *intentRec
|
||||
pubMock *mocks.MockIntentPublisher
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, now time.Time) *fixture {
|
||||
t.Helper()
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
users := &userRec{}
|
||||
intents := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
directory: directory,
|
||||
users: userservicestub.NewService(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
users: users,
|
||||
usersMock: newUserMock(t, users),
|
||||
intents: intents,
|
||||
pubMock: newIntentMock(t, intents),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +133,8 @@ func (f *fixture) newService(t *testing.T) *registerracename.Service {
|
||||
t.Helper()
|
||||
svc, err := registerracename.NewService(registerracename.Dependencies{
|
||||
Directory: f.directory,
|
||||
Users: f.users,
|
||||
Intents: f.intents,
|
||||
Users: f.usersMock,
|
||||
Intents: f.pubMock,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
@@ -102,7 +187,7 @@ func TestRegisterRaceNameHappyPath(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -128,7 +213,7 @@ func TestRegisterRaceNameHappyPath(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
intent := intents[0]
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistered, intent.NotificationType)
|
||||
@@ -144,7 +229,7 @@ func TestRegisterRaceNameIdempotentRetry(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(1))
|
||||
f.users.setEligibility("user-1", defaultEligibility(1))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -167,7 +252,7 @@ func TestRegisterRaceNameIdempotentRetry(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, registered, 1, "registration must remain idempotent")
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 2, "idempotent retry republishes the intent")
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, "lobby.race_name.registered:game-1:user-1", intent.IdempotencyKey)
|
||||
@@ -257,7 +342,7 @@ func TestRegisterRaceNameRejectsPermanentBlock(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{
|
||||
f.users.setEligibility("user-1", ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
MaxRegisteredRaceNames: 2,
|
||||
@@ -278,7 +363,7 @@ func TestRegisterRaceNamePendingMissing(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
|
||||
svc := f.newService(t)
|
||||
_, err := svc.Handle(context.Background(), registerracename.Input{
|
||||
@@ -294,7 +379,7 @@ func TestRegisterRaceNamePendingForOtherUserSurfacesAsMissing(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
// Pending exists for a different user; the actor has none.
|
||||
f.seedPending(t, "game-1", "user-other", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
@@ -316,7 +401,7 @@ func TestRegisterRaceNamePendingExpired(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
// Pending elig until is in the past relative to now.
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(-time.Minute))
|
||||
|
||||
@@ -335,7 +420,7 @@ func TestRegisterRaceNameQuotaExceeded(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
// Free-tier quota = 1; user already has one registered name.
|
||||
f.users.SetEligibility("user-1", defaultEligibility(1))
|
||||
f.users.setEligibility("user-1", defaultEligibility(1))
|
||||
f.seedRegistered(t, "game-existing", "user-1", "OldName")
|
||||
f.seedPending(t, "game-new", "user-1", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
@@ -354,7 +439,7 @@ func TestRegisterRaceNameUnlimitedQuotaAllowsManyRegistrations(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
// MaxRegisteredRaceNames=0 marker → unlimited.
|
||||
f.users.SetEligibility("user-1", defaultEligibility(0))
|
||||
f.users.setEligibility("user-1", defaultEligibility(0))
|
||||
f.seedRegistered(t, "game-a", "user-1", "First")
|
||||
f.seedRegistered(t, "game-b", "user-1", "Second")
|
||||
f.seedPending(t, "game-c", "user-1", "Third", now.Add(24*time.Hour))
|
||||
@@ -373,7 +458,7 @@ func TestRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -390,9 +475,9 @@ func TestRegisterRaceNameCommitsEvenIfPublishFails(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
f.intents.SetError(errors.New("notification stream unavailable"))
|
||||
f.intents.setErr(errors.New("notification stream unavailable"))
|
||||
|
||||
svc := f.newService(t)
|
||||
out, err := svc.Handle(context.Background(), registerracename.Input{
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -22,28 +23,65 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
applications *applicationstub.Store
|
||||
directory *racenamestub.Directory
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
openPublicGameID common.GameID
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
applications := applicationstub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
applications := applicationinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-public",
|
||||
@@ -62,18 +100,22 @@ func newFixture(t *testing.T) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
rec := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
applications: applications,
|
||||
directory: dir,
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
intentRec: rec,
|
||||
openPublicGameID: gameRecord.GameID,
|
||||
}
|
||||
}
|
||||
|
||||
func newService(t *testing.T, f *fixture) *rejectapplication.Service {
|
||||
t.Helper()
|
||||
if f.intents == nil {
|
||||
f.intents = newIntentMock(t, f.intentRec)
|
||||
}
|
||||
svc, err := rejectapplication.NewService(rejectapplication.Dependencies{
|
||||
Games: f.games,
|
||||
Applications: f.applications,
|
||||
@@ -116,7 +158,7 @@ func TestRejectHappyPath(t *testing.T) {
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.Equal(t, f.now, got.DecidedAt.UTC())
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipRejected, intents[0].NotificationType)
|
||||
assert.Equal(t, []string{"user-1"}, intents[0].RecipientUserIDs)
|
||||
@@ -208,7 +250,7 @@ func TestRejectPublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
app := seedSubmittedApplication(t, f, "application-1", "user-1", "SolarPilot")
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intentRec.setErr(errors.New("publish failed"))
|
||||
|
||||
svc := newService(t, f)
|
||||
got, err := svc.Handle(context.Background(), rejectapplication.Input{
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
@@ -31,20 +31,20 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
}
|
||||
|
||||
type fixtures struct {
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
directory *racenamestub.Directory
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
}
|
||||
|
||||
func newFixtures(t *testing.T) *fixtures {
|
||||
t.Helper()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
directory, err := racenameinmem.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &fixtures{
|
||||
games: gamestub.NewStore(),
|
||||
memberships: membershipstub.NewStore(),
|
||||
games: gameinmem.NewStore(),
|
||||
memberships: membershipinmem.NewStore(),
|
||||
directory: directory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gmclientstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
@@ -33,7 +34,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// source status.
|
||||
func seedGameWithStatus(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -94,13 +95,18 @@ func newService(
|
||||
return svc
|
||||
}
|
||||
|
||||
func newGMMock(t *testing.T) *mocks.MockGMClient {
|
||||
t.Helper()
|
||||
return mocks.NewMockGMClient(gomock.NewController(t))
|
||||
}
|
||||
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := resumegame.NewService(resumegame.Dependencies{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = resumegame.NewService(resumegame.Dependencies{Games: gamestub.NewStore()})
|
||||
_, err = resumegame.NewService(resumegame.Dependencies{Games: gameinmem.NewStore()})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -108,10 +114,11 @@ func TestResumeGameAdminHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
gm.EXPECT().Ping(gomock.Any()).Return(nil).Times(1)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
updated, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -120,17 +127,17 @@ func TestResumeGameAdminHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusRunning, updated.Status)
|
||||
assert.Equal(t, 1, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
gm.EXPECT().Ping(gomock.Any()).Return(nil).Times(1)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
updated, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -139,17 +146,16 @@ func TestResumeGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusRunning, updated.Status)
|
||||
assert.Equal(t, 1, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGameRejectsNonOwnerUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -157,17 +163,16 @@ func TestResumeGameRejectsNonOwnerUser(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
assert.Equal(t, 0, gm.PingCalls(), "ping must not run before authorization passes")
|
||||
}
|
||||
|
||||
func TestResumeGameRejectsUserActorOnPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -175,7 +180,6 @@ func TestResumeGameRejectsUserActorOnPublicGame(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
assert.Equal(t, 0, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGameRejectsWrongStatuses(t *testing.T) {
|
||||
@@ -197,10 +201,10 @@ func TestResumeGameRejectsWrongStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-x", game.GameTypePublic, "", status, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm := newGMMock(t)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -208,7 +212,6 @@ func TestResumeGameRejectsWrongStatuses(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Equal(t, 0, gm.PingCalls(), "ping must not run before status check passes")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -217,11 +220,13 @@ func TestResumeGameGMUnavailableKeepsPaused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusPaused, now)
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
gm.SetPingError(errors.Join(ports.ErrGMUnavailable, errors.New("dial tcp: connection refused")))
|
||||
gm := newGMMock(t)
|
||||
gm.EXPECT().Ping(gomock.Any()).
|
||||
Return(errors.Join(ports.ErrGMUnavailable, errors.New("dial tcp: connection refused"))).
|
||||
Times(1)
|
||||
service := newService(t, store, gm, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -231,7 +236,6 @@ func TestResumeGameGMUnavailableKeepsPaused(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, shared.ErrServiceUnavailable)
|
||||
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
|
||||
assert.Equal(t, 1, gm.PingCalls())
|
||||
|
||||
persisted, err := store.Get(context.Background(), record.GameID)
|
||||
require.NoError(t, err)
|
||||
@@ -242,8 +246,8 @@ func TestResumeGameGMUnavailableKeepsPaused(t *testing.T) {
|
||||
func TestResumeGameRejectsMissingRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
store := gamestub.NewStore()
|
||||
gm := newGMMock(t)
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -251,14 +255,13 @@ func TestResumeGameRejectsMissingRecord(t *testing.T) {
|
||||
GameID: common.GameID("game-missing"),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
assert.Equal(t, 0, gm.PingCalls())
|
||||
}
|
||||
|
||||
func TestResumeGameInvalidActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
store := gamestub.NewStore()
|
||||
gm := newGMMock(t)
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
@@ -272,8 +275,8 @@ func TestResumeGameInvalidActor(t *testing.T) {
|
||||
func TestResumeGameInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gm := gmclientstub.NewClient()
|
||||
store := gamestub.NewStore()
|
||||
gm := newGMMock(t)
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), resumegame.Input{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/service/retrystartgame"
|
||||
@@ -47,7 +47,7 @@ func newFailedGame(t *testing.T, gameType game.GameType, ownerID string) (game.G
|
||||
return record, now
|
||||
}
|
||||
|
||||
func newService(t *testing.T, games *gamestub.Store, at time.Time) *retrystartgame.Service {
|
||||
func newService(t *testing.T, games *gameinmem.Store, at time.Time) *retrystartgame.Service {
|
||||
t.Helper()
|
||||
service, err := retrystartgame.NewService(retrystartgame.Dependencies{
|
||||
Games: games,
|
||||
@@ -65,7 +65,7 @@ func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
|
||||
func TestRetryStartGameAdminHappyPath(t *testing.T) {
|
||||
record, now := newFailedGame(t, game.GameTypePublic, "")
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -79,7 +79,7 @@ func TestRetryStartGameAdminHappyPath(t *testing.T) {
|
||||
|
||||
func TestRetryStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
record, now := newFailedGame(t, game.GameTypePrivate, "user-owner")
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -93,7 +93,7 @@ func TestRetryStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
|
||||
func TestRetryStartGameRejectsNonOwnerUser(t *testing.T) {
|
||||
record, now := newFailedGame(t, game.GameTypePrivate, "user-owner")
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -109,7 +109,7 @@ func TestRetryStartGameRejectsWrongStatus(t *testing.T) {
|
||||
record.Status = game.StatusRunning
|
||||
startedAt := now.Add(30 * time.Minute)
|
||||
record.StartedAt = &startedAt
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
|
||||
service := newService(t, games, now.Add(time.Hour))
|
||||
@@ -121,7 +121,7 @@ func TestRetryStartGameRejectsWrongStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRetryStartGameRejectsMissingRecord(t *testing.T) {
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
service := newService(t, games, time.Now().UTC())
|
||||
|
||||
_, err := service.Handle(context.Background(), retrystartgame.Input{
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -31,16 +31,16 @@ func fixedClock(at time.Time) func() time.Time { return func() time.Time { retur
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
game game.Game
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-private",
|
||||
@@ -196,7 +196,7 @@ func TestRevokeGameNotFound(t *testing.T) {
|
||||
// game path is a defensive guard, but the surfaced error must be
|
||||
// subject_not_found rather than forbidden.
|
||||
svc, err := revokeinvite.NewService(revokeinvite.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
Invites: f.invites,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/inviteinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -30,20 +32,57 @@ const (
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
type closeFixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
game game.Game
|
||||
now time.Time
|
||||
games *gameinmem.Store
|
||||
invites *inviteinmem.Store
|
||||
intentRec *intentRec
|
||||
intents *mocks.MockIntentPublisher
|
||||
game game.Game
|
||||
}
|
||||
|
||||
func newCloseFixture(t *testing.T) *closeFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
intents := intentpubstub.NewPublisher()
|
||||
games := gameinmem.NewStore()
|
||||
invites := inviteinmem.NewStore()
|
||||
rec := &intentRec{}
|
||||
intents := newIntentMock(t, rec)
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: closeGameID,
|
||||
@@ -64,11 +103,12 @@ func newCloseFixture(t *testing.T) *closeFixture {
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
return &closeFixture{
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invites,
|
||||
intents: intents,
|
||||
game: gameRecord,
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invites,
|
||||
intentRec: rec,
|
||||
intents: intents,
|
||||
game: gameRecord,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +160,7 @@ func TestCloseEnrollmentTransitionsGameAndExpiresInvites(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusExpired, second.Status)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 2)
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intent.NotificationType)
|
||||
@@ -158,7 +198,7 @@ func TestCloseEnrollmentLeavesNonCreatedInvitesUntouched(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusDeclined, declinedAfter.Status)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intentRec.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
}
|
||||
|
||||
@@ -184,14 +224,14 @@ func TestCloseEnrollmentSurfacesGameConflict(t *testing.T) {
|
||||
stillCreated, err := f.invites.Get(context.Background(), "invite-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusCreated, stillCreated.Status)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
assert.Empty(t, f.intentRec.snapshot())
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentSwallowsIntentPublishFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
f.addCreatedInvite(t, "invite-1", "user-a")
|
||||
f.intents.SetError(errors.New("publisher offline"))
|
||||
f.intentRec.setErr(errors.New("publisher offline"))
|
||||
|
||||
updated, err := shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
@@ -221,7 +261,7 @@ func TestCloseEnrollmentIsIdempotentOnSecondCall(t *testing.T) {
|
||||
f.now.Add(time.Minute),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, f.intents.Published(), 1)
|
||||
assert.Len(t, f.intentRec.snapshot(), 1)
|
||||
|
||||
_, err = shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
@@ -231,7 +271,7 @@ func TestCloseEnrollmentIsIdempotentOnSecondCall(t *testing.T) {
|
||||
f.now.Add(2*time.Minute),
|
||||
)
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Len(t, f.intents.Published(), 1)
|
||||
assert.Len(t, f.intentRec.snapshot(), 1)
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentRejectsUnknownTrigger(t *testing.T) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/engineimage"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/logging"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -23,11 +24,12 @@ import (
|
||||
|
||||
// Service executes the start-game use case.
|
||||
type Service struct {
|
||||
games ports.GameStore
|
||||
runtimeManager ports.RuntimeManager
|
||||
clock func() time.Time
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
games ports.GameStore
|
||||
runtimeManager ports.RuntimeManager
|
||||
imageResolver *engineimage.Resolver
|
||||
clock func() time.Time
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// Dependencies groups the collaborators used by Service.
|
||||
@@ -38,6 +40,11 @@ type Dependencies struct {
|
||||
// RuntimeManager publishes the start job after the CAS succeeds.
|
||||
RuntimeManager ports.RuntimeManager
|
||||
|
||||
// ImageResolver substitutes a game's TargetEngineVersion into the
|
||||
// configured engine-image template to produce the `image_ref`
|
||||
// published on `runtime:start_jobs`.
|
||||
ImageResolver *engineimage.Resolver
|
||||
|
||||
// Clock supplies the wall-clock used for UpdatedAt. Defaults to
|
||||
// time.Now when nil.
|
||||
Clock func() time.Time
|
||||
@@ -58,6 +65,8 @@ func NewService(deps Dependencies) (*Service, error) {
|
||||
return nil, errors.New("new start game service: nil game store")
|
||||
case deps.RuntimeManager == nil:
|
||||
return nil, errors.New("new start game service: nil runtime manager")
|
||||
case deps.ImageResolver == nil:
|
||||
return nil, errors.New("new start game service: nil image resolver")
|
||||
}
|
||||
|
||||
clock := deps.Clock
|
||||
@@ -72,6 +81,7 @@ func NewService(deps Dependencies) (*Service, error) {
|
||||
return &Service{
|
||||
games: deps.Games,
|
||||
runtimeManager: deps.RuntimeManager,
|
||||
imageResolver: deps.ImageResolver,
|
||||
clock: clock,
|
||||
logger: logger.With("service", "lobby.startgame"),
|
||||
telemetry: deps.Telemetry,
|
||||
@@ -127,6 +137,11 @@ func (service *Service) Handle(ctx context.Context, input Input) (game.Game, err
|
||||
)
|
||||
}
|
||||
|
||||
imageRef, err := service.imageResolver.Resolve(record.TargetEngineVersion)
|
||||
if err != nil {
|
||||
return game.Game{}, fmt.Errorf("start game: resolve image ref: %w", err)
|
||||
}
|
||||
|
||||
at := service.clock().UTC()
|
||||
if err := service.games.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: input.GameID,
|
||||
@@ -144,7 +159,7 @@ func (service *Service) Handle(ctx context.Context, input Input) (game.Game, err
|
||||
string(game.TriggerCommand),
|
||||
)
|
||||
|
||||
if err := service.runtimeManager.PublishStartJob(ctx, input.GameID.String()); err != nil {
|
||||
if err := service.runtimeManager.PublishStartJob(ctx, input.GameID.String(), imageRef); err != nil {
|
||||
// Status is already `starting` and the domain forbids a direct
|
||||
// rollback to `ready_to_start`. We surface the publish error to
|
||||
// the caller; the game stays in `starting` until either a
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/runtimemanagerstub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/engineimage"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
@@ -18,8 +20,11 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
const testImageTemplate = "registry.example.com/galaxy/game:{engine_version}"
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
@@ -50,36 +55,113 @@ func newReadyGame(t *testing.T, gameType game.GameType, ownerID string) (game.Ga
|
||||
return record, now
|
||||
}
|
||||
|
||||
// runtimeRec captures every PublishStartJob/PublishStopJob call so tests
|
||||
// can assert which jobs ran. Per-test error injection sets startErr.
|
||||
type runtimeRec struct {
|
||||
mu sync.Mutex
|
||||
startIDs []string
|
||||
startRefs []string
|
||||
stopIDs []string
|
||||
stopReas []ports.StopReason
|
||||
startErr error
|
||||
}
|
||||
|
||||
func (r *runtimeRec) recordStart(_ context.Context, gameID, imageRef string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.startErr != nil {
|
||||
return r.startErr
|
||||
}
|
||||
r.startIDs = append(r.startIDs, gameID)
|
||||
r.startRefs = append(r.startRefs, imageRef)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeRec) recordStop(_ context.Context, gameID string, reason ports.StopReason) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopIDs = append(r.stopIDs, gameID)
|
||||
r.stopReas = append(r.stopReas, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeRec) startJobs() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.startIDs...)
|
||||
}
|
||||
|
||||
func (r *runtimeRec) startImageRefs() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.startRefs...)
|
||||
}
|
||||
|
||||
func (r *runtimeRec) stopJobs() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.stopIDs...)
|
||||
}
|
||||
|
||||
func newRuntimeMock(t *testing.T, rec *runtimeRec) *mocks.MockRuntimeManager {
|
||||
t.Helper()
|
||||
m := mocks.NewMockRuntimeManager(gomock.NewController(t))
|
||||
m.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordStart).AnyTimes()
|
||||
m.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(rec.recordStop).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
games *gamestub.Store
|
||||
runtime *runtimemanagerstub.Publisher
|
||||
games *gameinmem.Store
|
||||
rec *runtimeRec
|
||||
runtime *mocks.MockRuntimeManager
|
||||
service *startgame.Service
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, record game.Game, now time.Time) *fixture {
|
||||
t.Helper()
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
runtime := runtimemanagerstub.NewPublisher()
|
||||
rec := &runtimeRec{}
|
||||
runtime := newRuntimeMock(t, rec)
|
||||
resolver, err := engineimage.NewResolver(testImageTemplate)
|
||||
require.NoError(t, err)
|
||||
service, err := startgame.NewService(startgame.Dependencies{
|
||||
Games: games,
|
||||
RuntimeManager: runtime,
|
||||
ImageResolver: resolver,
|
||||
Clock: fixedClock(now.Add(time.Hour)),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return &fixture{games: games, runtime: runtime, service: service, now: now}
|
||||
return &fixture{games: games, rec: rec, runtime: runtime, service: service, now: now}
|
||||
}
|
||||
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
_, err := startgame.NewService(startgame.Dependencies{
|
||||
RuntimeManager: runtimemanagerstub.NewPublisher(),
|
||||
resolver, err := engineimage.NewResolver(testImageTemplate)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := &runtimeRec{}
|
||||
runtime := newRuntimeMock(t, rec)
|
||||
|
||||
_, err = startgame.NewService(startgame.Dependencies{
|
||||
RuntimeManager: runtime,
|
||||
ImageResolver: resolver,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = startgame.NewService(startgame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
Games: gameinmem.NewStore(),
|
||||
ImageResolver: resolver,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = startgame.NewService(startgame.Dependencies{
|
||||
Games: gameinmem.NewStore(),
|
||||
RuntimeManager: runtime,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -94,8 +176,13 @@ func TestStartGamePublicAdminHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusStarting, updated.Status)
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.runtime.StartJobs())
|
||||
assert.Empty(t, f.runtime.StopJobs())
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.rec.startJobs())
|
||||
assert.Equal(t,
|
||||
[]string{"registry.example.com/galaxy/game:" + record.TargetEngineVersion},
|
||||
f.rec.startImageRefs(),
|
||||
"resolved image_ref must propagate to publisher",
|
||||
)
|
||||
assert.Empty(t, f.rec.stopJobs())
|
||||
}
|
||||
|
||||
func TestStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
@@ -108,7 +195,7 @@ func TestStartGamePrivateOwnerHappyPath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusStarting, updated.Status)
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.runtime.StartJobs())
|
||||
assert.Equal(t, []string{record.GameID.String()}, f.rec.startJobs())
|
||||
}
|
||||
|
||||
func TestStartGameRejectsNonOwnerUser(t *testing.T) {
|
||||
@@ -120,7 +207,7 @@ func TestStartGameRejectsNonOwnerUser(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
assert.Empty(t, f.runtime.StartJobs(), "no start job published on forbidden")
|
||||
assert.Empty(t, f.rec.startJobs(), "no start job published on forbidden")
|
||||
|
||||
stored, err := f.games.Get(context.Background(), record.GameID)
|
||||
require.NoError(t, err)
|
||||
@@ -148,7 +235,7 @@ func TestStartGameRejectsWrongStatus(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Empty(t, f.runtime.StartJobs())
|
||||
assert.Empty(t, f.rec.startJobs())
|
||||
}
|
||||
|
||||
func TestStartGameRejectsCASLossOnRecentTransition(t *testing.T) {
|
||||
@@ -169,13 +256,13 @@ func TestStartGameRejectsCASLossOnRecentTransition(t *testing.T) {
|
||||
GameID: record.GameID,
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Empty(t, f.runtime.StartJobs())
|
||||
assert.Empty(t, f.rec.startJobs())
|
||||
}
|
||||
|
||||
func TestStartGamePublishFailureSurfacesUnavailable(t *testing.T) {
|
||||
record, now := newReadyGame(t, game.GameTypePublic, "")
|
||||
f := newFixture(t, record, now)
|
||||
f.runtime.SetStartError(errors.New("redis down"))
|
||||
f.rec.startErr = errors.New("redis down")
|
||||
|
||||
_, err := f.service.Handle(context.Background(), startgame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
@@ -191,11 +278,15 @@ func TestStartGamePublishFailureSurfacesUnavailable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStartGameRejectsMissingRecord(t *testing.T) {
|
||||
games := gamestub.NewStore()
|
||||
runtime := runtimemanagerstub.NewPublisher()
|
||||
games := gameinmem.NewStore()
|
||||
rec := &runtimeRec{}
|
||||
runtime := newRuntimeMock(t, rec)
|
||||
resolver, err := engineimage.NewResolver(testImageTemplate)
|
||||
require.NoError(t, err)
|
||||
service, err := startgame.NewService(startgame.Dependencies{
|
||||
Games: games,
|
||||
RuntimeManager: runtime,
|
||||
ImageResolver: resolver,
|
||||
Clock: fixedClock(time.Now().UTC()),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/applicationstub"
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/membershipstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/applicationinmem"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/membershipinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
@@ -25,8 +25,87 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type intentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *intentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
func (r *intentRec) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.err = err
|
||||
}
|
||||
|
||||
type userRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
defaultRaceName = "SolarPilot"
|
||||
otherRaceName = "VoidRunner"
|
||||
@@ -58,12 +137,14 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) {
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
memberships *membershipstub.Store
|
||||
applications *applicationstub.Store
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
games *gameinmem.Store
|
||||
memberships *membershipinmem.Store
|
||||
applications *applicationinmem.Store
|
||||
directory *racenameinmem.Directory
|
||||
users *userRec
|
||||
usersMock *mocks.MockUserService
|
||||
intents *intentRec
|
||||
intentsMock *mocks.MockIntentPublisher
|
||||
ids fixedIDs
|
||||
openPublicGameID common.GameID
|
||||
defaultUserID string
|
||||
@@ -72,13 +153,13 @@ type fixture struct {
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
users := userservicestub.NewService()
|
||||
users.SetEligibility("user-1", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: true})
|
||||
games := gamestub.NewStore()
|
||||
memberships := membershipstub.NewStore()
|
||||
applications := applicationstub.NewStore()
|
||||
users := &userRec{}
|
||||
users.setEligibility("user-1", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: true})
|
||||
games := gameinmem.NewStore()
|
||||
memberships := membershipinmem.NewStore()
|
||||
applications := applicationinmem.NewStore()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: "game-public",
|
||||
@@ -97,6 +178,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
intents := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
games: games,
|
||||
@@ -104,7 +186,9 @@ func newFixture(t *testing.T) *fixture {
|
||||
applications: applications,
|
||||
directory: dir,
|
||||
users: users,
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
usersMock: newUserMock(t, users),
|
||||
intents: intents,
|
||||
intentsMock: newIntentMock(t, intents),
|
||||
ids: fixedIDs{applicationID: "application-fixed", membershipID: "membership-fixed"},
|
||||
openPublicGameID: gameRecord.GameID,
|
||||
defaultUserID: "user-1",
|
||||
@@ -117,9 +201,9 @@ func newService(t *testing.T, f *fixture) *submitapplication.Service {
|
||||
Games: f.games,
|
||||
Memberships: f.memberships,
|
||||
Applications: f.applications,
|
||||
Users: f.users,
|
||||
Users: f.usersMock,
|
||||
Directory: f.directory,
|
||||
Intents: f.intents,
|
||||
Intents: f.intentsMock,
|
||||
IDs: f.ids,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
@@ -147,7 +231,7 @@ func TestHandleHappyPath(t *testing.T) {
|
||||
assert.Equal(t, common.ApplicationID("application-fixed"), got.ApplicationID)
|
||||
assert.Equal(t, defaultRaceName, got.RaceName)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyApplicationSubmitted, intents[0].NotificationType)
|
||||
assert.Equal(t, notificationintent.AudienceKindAdminEmail, intents[0].AudienceKind)
|
||||
@@ -236,7 +320,7 @@ func TestHandleUserMissingEligibilityDenied(t *testing.T) {
|
||||
func TestHandleCanJoinGameFalseEligibilityDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
f.users.SetEligibility("user-blocked", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: false})
|
||||
f.users.setEligibility("user-blocked", ports.Eligibility{Exists: true, CanLogin: true, CanJoinGame: false})
|
||||
svc := newService(t, f)
|
||||
input := defaultInput(f)
|
||||
input.Actor = shared.NewUserActor("user-blocked")
|
||||
@@ -248,7 +332,7 @@ func TestHandleCanJoinGameFalseEligibilityDenied(t *testing.T) {
|
||||
func TestHandleUserServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
f.users.SetFailure(f.defaultUserID, ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure(f.defaultUserID, ports.ErrUserServiceUnavailable)
|
||||
svc := newService(t, f)
|
||||
|
||||
_, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
@@ -322,7 +406,7 @@ func TestHandleDuplicateActiveApplicationConflict(t *testing.T) {
|
||||
func TestHandlePublishFailureDoesNotRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFixture(t)
|
||||
f.intents.SetError(errors.New("publish failed"))
|
||||
f.intents.setErr(errors.New("publish failed"))
|
||||
svc := newService(t, f)
|
||||
|
||||
got, err := svc.Handle(context.Background(), defaultInput(f))
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -29,7 +29,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
// returns the persisted record.
|
||||
func seedDraftGame(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
@@ -73,7 +73,7 @@ func TestHandleAdminFullEditInDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-a", game.GameTypePublic, "", now)
|
||||
|
||||
later := now.Add(30 * time.Minute)
|
||||
@@ -107,7 +107,7 @@ func TestHandleOwnerEditInDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-private", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -125,7 +125,7 @@ func TestHandleNonOwnerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-private", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -142,7 +142,7 @@ func TestHandleUserCannotEditPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-public", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -159,7 +159,7 @@ func TestHandleEnrollmentOpenDescriptionOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-open", game.GameTypePublic, "", now)
|
||||
|
||||
// Force status to enrollment_open via UpdateStatus.
|
||||
@@ -187,7 +187,7 @@ func TestHandleEnrollmentOpenNonDescriptionRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-open", game.GameTypePublic, "", now)
|
||||
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
@@ -212,7 +212,7 @@ func TestHandleTerminalStatusRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftGame(t, store, "game-cancel", game.GameTypePublic, "", now)
|
||||
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
@@ -236,7 +236,7 @@ func TestHandleTerminalStatusRejected(t *testing.T) {
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)))
|
||||
|
||||
_, err := service.Handle(context.Background(), updategame.Input{
|
||||
@@ -251,7 +251,7 @@ func TestHandleValidationFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftGame(t, store, "game-a", game.GameTypePublic, "", now)
|
||||
|
||||
service := newService(t, store, fixedClock(now.Add(time.Hour)))
|
||||
@@ -270,7 +270,7 @@ func TestHandleInvalidActorReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(now))
|
||||
|
||||
_, err := service.Handle(context.Background(), updategame.Input{
|
||||
@@ -286,7 +286,7 @@ func TestHandleInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
service := newService(t, store, fixedClock(now))
|
||||
|
||||
_, err := service.Handle(context.Background(), updategame.Input{
|
||||
|
||||
Reference in New Issue
Block a user