feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -6,16 +6,16 @@ import (
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"galaxy/lobby/internal/adapters/applicationstub"
"galaxy/lobby/internal/adapters/gamestub"
"galaxy/lobby/internal/adapters/intentpubstub"
"galaxy/lobby/internal/adapters/invitestub"
"galaxy/lobby/internal/adapters/membershipstub"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/adapters/runtimemanagerstub"
"galaxy/lobby/internal/adapters/applicationinmem"
"galaxy/lobby/internal/adapters/gameinmem"
"galaxy/lobby/internal/adapters/inviteinmem"
"galaxy/lobby/internal/adapters/membershipinmem"
"galaxy/lobby/internal/adapters/mocks"
"galaxy/lobby/internal/adapters/racenameinmem"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
@@ -27,18 +27,94 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
type intentRec struct {
mu sync.Mutex
published []notificationintent.Intent
}
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.published = append(r.published, intent)
return "1", nil
}
func (r *intentRec) snapshot() []notificationintent.Intent {
r.mu.Lock()
defer r.mu.Unlock()
return append([]notificationintent.Intent(nil), r.published...)
}
type runtimeRec struct {
mu sync.Mutex
stopIDs []string
stopReas []ports.StopReason
stopErr error
}
func (r *runtimeRec) recordStart(_ context.Context, _, _ string) error { return nil }
func (r *runtimeRec) recordStop(_ context.Context, gameID string, reason ports.StopReason) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.stopErr != nil {
return r.stopErr
}
r.stopIDs = append(r.stopIDs, gameID)
r.stopReas = append(r.stopReas, reason)
return nil
}
func (r *runtimeRec) stopJobs() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.stopIDs...)
}
func (r *runtimeRec) stopReasons() []ports.StopReason {
r.mu.Lock()
defer r.mu.Unlock()
return append([]ports.StopReason(nil), r.stopReas...)
}
func (r *runtimeRec) setStopErr(err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.stopErr = err
}
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
t.Helper()
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
return m
}
func newRuntimeMock(t *testing.T, rec *runtimeRec) *mocks.MockRuntimeManager {
t.Helper()
m := mocks.NewMockRuntimeManager(gomock.NewController(t))
m.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(rec.recordStart).AnyTimes()
m.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(rec.recordStop).AnyTimes()
return m
}
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
type fixture struct {
directory *racenamestub.Directory
memberships *membershipstub.Store
applications *applicationstub.Store
invites *invitestub.Store
games *gamestub.Store
runtimeManager *runtimemanagerstub.Publisher
intents *intentpubstub.Publisher
directory *racenameinmem.Directory
memberships *membershipinmem.Store
applications *applicationinmem.Store
invites *inviteinmem.Store
games *gameinmem.Store
runtimeRec *runtimeRec
runtimeManager *mocks.MockRuntimeManager
intentRec *intentRec
intents *mocks.MockIntentPublisher
worker *userlifecycle.Worker
now time.Time
}
@@ -46,18 +122,22 @@ type fixture struct {
func newFixture(t *testing.T) *fixture {
t.Helper()
directory, err := racenamestub.NewDirectory()
directory, err := racenameinmem.NewDirectory()
require.NoError(t, err)
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
rtRec := &runtimeRec{}
intRec := &intentRec{}
f := &fixture{
directory: directory,
memberships: membershipstub.NewStore(),
applications: applicationstub.NewStore(),
invites: invitestub.NewStore(),
games: gamestub.NewStore(),
runtimeManager: runtimemanagerstub.NewPublisher(),
intents: intentpubstub.NewPublisher(),
memberships: membershipinmem.NewStore(),
applications: applicationinmem.NewStore(),
invites: inviteinmem.NewStore(),
games: gameinmem.NewStore(),
runtimeRec: rtRec,
runtimeManager: newRuntimeMock(t, rtRec),
intentRec: intRec,
intents: newIntentMock(t, intRec),
now: now,
}
@@ -276,12 +356,16 @@ func TestHandleFullCascadePermanentBlock(t *testing.T) {
gotOwned2, err := f.games.Get(context.Background(), ownedDraft.GameID)
require.NoError(t, err)
assert.Equal(t, game.StatusCancelled, gotOwned2.Status)
stopJobs := f.runtimeManager.StopJobs()
stopJobs := f.runtimeRec.stopJobs()
require.Len(t, stopJobs, 1)
assert.Equal(t, ownedRunning.GameID.String(), stopJobs[0])
stopReasons := f.runtimeRec.stopReasons()
require.Len(t, stopReasons, 1)
assert.Equal(t, ports.StopReasonCancelled, stopReasons[0],
"user-lifecycle cascade must classify the stop job as cancelled")
// Notification published only for the third-party private game owner.
intents := f.intents.Published()
intents := f.intentRec.snapshot()
require.Len(t, intents, 1)
assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipBlocked, intents[0].NotificationType)
assert.Equal(t, []string{"owner-other"}, intents[0].RecipientUserIDs)
@@ -309,7 +393,7 @@ func TestHandleIsIdempotentOnReplay(t *testing.T) {
require.NoError(t, f.worker.Handle(context.Background(), event))
require.NoError(t, f.worker.Handle(context.Background(), event))
intents := f.intents.Published()
intents := f.intentRec.snapshot()
require.Len(t, intents, 1, "second pass must not double-publish")
assert.Contains(t, intents[0].PayloadJSON, `"reason":"deleted"`)
}
@@ -378,7 +462,7 @@ func TestHandleUnknownEventTypeIsNoop(t *testing.T) {
got, err := f.memberships.Get(context.Background(), member.MembershipID)
require.NoError(t, err)
assert.Equal(t, membership.StatusActive, got.Status)
assert.Empty(t, f.intents.Published())
assert.Empty(t, f.intentRec.snapshot())
}
func TestHandlePropagatesStopJobError(t *testing.T) {
@@ -386,7 +470,7 @@ func TestHandlePropagatesStopJobError(t *testing.T) {
f := newFixture(t)
f.seedGame(t, "game-owned-3", game.GameTypePrivate, "user-victim", game.StatusRunning)
f.runtimeManager.SetStopError(errors.New("runtime down"))
f.runtimeRec.setStopErr(errors.New("runtime down"))
err := f.worker.Handle(context.Background(), ports.UserLifecycleEvent{
EntryID: "1700000000000-0",
@@ -399,10 +483,10 @@ func TestHandlePropagatesStopJobError(t *testing.T) {
require.Error(t, err)
}
// flakyMembershipStore wraps membershipstub.Store with a one-shot
// flakyMembershipStore wraps membershipinmem.Store with a one-shot
// UpdateStatus failure injection used by the retry-after-error test.
type flakyMembershipStore struct {
*membershipstub.Store
*membershipinmem.Store
failOnce bool
failError error
}