feat: runtime manager
This commit is contained in:
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -51,7 +51,7 @@ func fixedClock(at time.Time) func() time.Time {
|
||||
return func() time.Time { return at }
|
||||
}
|
||||
|
||||
func buildHandler(t *testing.T, store *gamestub.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
func buildHandler(t *testing.T, store *gameinmem.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := silentLogger()
|
||||
@@ -131,7 +131,7 @@ func TestAdminCreatesPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "game-public"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
@@ -158,7 +158,7 @@ func TestAdminCannotCreatePrivateGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-priv"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-priv"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "Private Lobby",
|
||||
@@ -181,7 +181,7 @@ func TestAdminValidationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-bad"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-bad"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "",
|
||||
@@ -204,7 +204,7 @@ func TestAdminUpdateAllFieldsInDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftForTest(t, store, "game-u", game.GameTypePublic, "", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
@@ -221,7 +221,7 @@ func TestAdminOpenEnrollment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePublic, "", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
@@ -236,7 +236,7 @@ func TestAdminCancelFromRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
record := seedDraftForTest(t, store, "game-run", game.GameTypePublic, "", now)
|
||||
// Force status to running to exercise the 409 conflict path.
|
||||
record.Status = game.StatusRunning
|
||||
@@ -257,7 +257,7 @@ func TestAdminUpdateNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
|
||||
desc := "x"
|
||||
body := updateGameRequest{Description: &desc}
|
||||
@@ -269,7 +269,7 @@ func TestAdminCreateUnknownFieldRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
|
||||
reqBody := map[string]any{
|
||||
"game_name": "x",
|
||||
@@ -289,7 +289,7 @@ func TestAdminCreateUnknownFieldRejected(t *testing.T) {
|
||||
|
||||
func seedDraftForTest(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
@@ -47,7 +47,7 @@ func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func buildHandler(t *testing.T, store *gamestub.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
func buildHandler(t *testing.T, store *gameinmem.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := silentLogger()
|
||||
@@ -134,7 +134,7 @@ func TestCreateGameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "game-first"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
@@ -164,7 +164,7 @@ func TestCreateGameMissingUserIDHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "x",
|
||||
@@ -189,7 +189,7 @@ func TestCreateGameUnknownJSONFieldRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
reqBody := map[string]any{
|
||||
"game_name": "x",
|
||||
@@ -211,7 +211,7 @@ func TestCreateGameUserCannotCreatePublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "x",
|
||||
@@ -234,7 +234,7 @@ func TestUpdateGameNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
handler := buildHandler(t, gameinmem.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
desc := "new"
|
||||
body := updateGameRequest{Description: &desc}
|
||||
@@ -248,7 +248,7 @@ func TestOpenEnrollmentHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestOpenEnrollmentForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
@@ -278,7 +278,7 @@ func TestOpenEnrollmentConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
@@ -301,7 +301,7 @@ func TestCancelGameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
store := gameinmem.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-cx", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
@@ -315,7 +315,7 @@ func TestCancelGameHappyPath(t *testing.T) {
|
||||
|
||||
func seedDraftForTest(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
store *gameinmem.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
|
||||
@@ -4,44 +4,114 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/gameinmem"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/listmyracenames"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
"galaxy/notificationintent"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
type publishedIntentRec struct {
|
||||
mu sync.Mutex
|
||||
published []notificationintent.Intent
|
||||
}
|
||||
|
||||
func (r *publishedIntentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.published = append(r.published, intent)
|
||||
return "1", nil
|
||||
}
|
||||
|
||||
func (r *publishedIntentRec) snapshot() []notificationintent.Intent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]notificationintent.Intent(nil), r.published...)
|
||||
}
|
||||
|
||||
type userEligibilityRec struct {
|
||||
mu sync.Mutex
|
||||
elig map[string]ports.Eligibility
|
||||
failures map[string]error
|
||||
}
|
||||
|
||||
func (r *userEligibilityRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err, ok := r.failures[userID]; ok {
|
||||
return ports.Eligibility{}, err
|
||||
}
|
||||
if e, ok := r.elig[userID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
}
|
||||
|
||||
func (r *userEligibilityRec) setEligibility(userID string, e ports.Eligibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.elig == nil {
|
||||
r.elig = make(map[string]ports.Eligibility)
|
||||
}
|
||||
r.elig[userID] = e
|
||||
}
|
||||
|
||||
func (r *userEligibilityRec) setFailure(userID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failures == nil {
|
||||
r.failures = make(map[string]error)
|
||||
}
|
||||
r.failures[userID] = err
|
||||
}
|
||||
|
||||
func newPublishedIntentMock(t *testing.T, rec *publishedIntentRec) *mocks.MockIntentPublisher {
|
||||
t.Helper()
|
||||
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
||||
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
func newUserEligibilityMock(t *testing.T, rec *userEligibilityRec) *mocks.MockUserService {
|
||||
t.Helper()
|
||||
m := mocks.NewMockUserService(gomock.NewController(t))
|
||||
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
||||
return m
|
||||
}
|
||||
|
||||
type raceNameFixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
directory *racenameinmem.Directory
|
||||
users *userEligibilityRec
|
||||
intents *publishedIntentRec
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func newRaceNameFixture(t *testing.T) *raceNameFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
users := userservicestub.NewService()
|
||||
intents := intentpubstub.NewPublisher()
|
||||
usersRec := &userEligibilityRec{}
|
||||
intentsRec := &publishedIntentRec{}
|
||||
|
||||
logger := silentLogger()
|
||||
svc, err := registerracename.NewService(registerracename.Dependencies{
|
||||
Directory: directory,
|
||||
Users: users,
|
||||
Intents: intents,
|
||||
Users: newUserEligibilityMock(t, usersRec),
|
||||
Intents: newPublishedIntentMock(t, intentsRec),
|
||||
Clock: func() time.Time { return now },
|
||||
Logger: logger,
|
||||
})
|
||||
@@ -50,8 +120,8 @@ func newRaceNameFixture(t *testing.T) *raceNameFixture {
|
||||
return &raceNameFixture{
|
||||
now: now,
|
||||
directory: directory,
|
||||
users: users,
|
||||
intents: intents,
|
||||
users: usersRec,
|
||||
intents: intentsRec,
|
||||
handler: newHandler(Dependencies{Logger: logger, RegisterRaceName: svc}, logger),
|
||||
}
|
||||
}
|
||||
@@ -66,7 +136,7 @@ func TestHandleRegisterRaceNameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(7*24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
@@ -82,7 +152,7 @@ func TestHandleRegisterRaceNameHappyPath(t *testing.T) {
|
||||
assert.Equal(t, f.now.UnixMilli(), resp.RegisteredAtMs)
|
||||
assert.NotEmpty(t, resp.CanonicalKey)
|
||||
|
||||
require.Len(t, f.intents.Published(), 1)
|
||||
require.Len(t, f.intents.snapshot(), 1)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNameRejectsMissingUserHeader(t *testing.T) {
|
||||
@@ -120,7 +190,7 @@ func TestHandleRegisterRaceNamePendingMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
@@ -137,7 +207,7 @@ func TestHandleRegisterRaceNamePendingExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(-time.Minute))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
@@ -155,7 +225,7 @@ func TestHandleRegisterRaceNameQuotaExceeded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 1})
|
||||
f.users.setEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 1})
|
||||
// pre-existing registered race name to exhaust quota
|
||||
f.seedPending(t, "game-old", "user-1", "OldName", f.now.Add(24*time.Hour))
|
||||
require.NoError(t, f.directory.Register(context.Background(), "game-old", "user-1", "OldName"))
|
||||
@@ -177,7 +247,7 @@ func TestHandleRegisterRaceNamePermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{
|
||||
f.users.setEligibility("user-1", ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
MaxRegisteredRaceNames: 2,
|
||||
@@ -199,7 +269,7 @@ func TestHandleRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
@@ -218,17 +288,17 @@ func TestHandleRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
// silent logger.
|
||||
type myRaceNamesFixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
games *gamestub.Store
|
||||
directory *racenameinmem.Directory
|
||||
games *gameinmem.Store
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func newMyRaceNamesFixture(t *testing.T) *myRaceNamesFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
games := gameinmem.NewStore()
|
||||
|
||||
logger := silentLogger()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
|
||||
Reference in New Issue
Block a user