package runtimejobresult_test import ( "context" "errors" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/gmclientstub" "galaxy/lobby/internal/adapters/intentpubstub" "galaxy/lobby/internal/adapters/runtimemanagerstub" "galaxy/lobby/internal/adapters/streamoffsetstub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/worker/runtimejobresult" "galaxy/notificationintent" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type harness struct { games *gamestub.Store runtime *runtimemanagerstub.Publisher gm *gmclientstub.Client intents *intentpubstub.Publisher offsets *streamoffsetstub.Store consumer *runtimejobresult.Consumer server *miniredis.Miniredis clientRedis *redis.Client stream string at time.Time gameRecord game.Game } func newHarness(t *testing.T) *harness { t.Helper() server := miniredis.RunT(t) clientRedis := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = clientRedis.Close() }) games := gamestub.NewStore() runtime := runtimemanagerstub.NewPublisher() gm := gmclientstub.NewClient() intents := intentpubstub.NewPublisher() offsets := streamoffsetstub.NewStore() at := time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC) h := &harness{ games: games, runtime: runtime, gm: gm, intents: intents, offsets: offsets, server: server, clientRedis: clientRedis, stream: "runtime:job_results", at: at, } now := at.Add(-time.Hour) record, err := game.New(game.NewGameInput{ GameID: common.GameID("game-w"), GameName: "test worker game", GameType: game.GameTypePublic, MinPlayers: 4, MaxPlayers: 8, StartGapHours: 12, StartGapPlayers: 2, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 18 * * *", TargetEngineVersion: "v1.0.0", Now: now, }) require.NoError(t, err) record.Status = game.StatusStarting require.NoError(t, games.Save(context.Background(), record)) h.gameRecord = record consumer, err := runtimejobresult.NewConsumer(runtimejobresult.Config{ Client: clientRedis, Stream: h.stream, BlockTimeout: 100 * time.Millisecond, Games: games, RuntimeManager: runtime, GMClient: gm, Intents: intents, OffsetStore: offsets, Clock: func() time.Time { return at }, Logger: silentLogger(), }) require.NoError(t, err) h.consumer = consumer return h } func successMessage(t *testing.T, h *harness, id string) redis.XMessage { t.Helper() return redis.XMessage{ ID: id, Values: map[string]any{ "game_id": h.gameRecord.GameID.String(), "outcome": "success", "container_id": "container-1", "engine_endpoint": "engine.local:9000", "completed_at_ms": "1745581200000", }, } } func failureMessage(t *testing.T, h *harness, id string) redis.XMessage { t.Helper() return redis.XMessage{ ID: id, Values: map[string]any{ "game_id": h.gameRecord.GameID.String(), "outcome": "failure", "error_code": "image_pull_failed", "error_message": "registry unreachable", "completed_at_ms": "1745581200000", }, } } func TestNewConsumerRejectsMissingDeps(t *testing.T) { server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) _, err := runtimejobresult.NewConsumer(runtimejobresult.Config{ Stream: "runtime:job_results", BlockTimeout: time.Second, }) require.Error(t, err) _, err = runtimejobresult.NewConsumer(runtimejobresult.Config{ Client: client, BlockTimeout: time.Second, }) require.Error(t, err) } func TestHandleSuccessTransitionsToRunning(t *testing.T) { h := newHarness(t) h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000000-0")) got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusRunning, got.Status) require.NotNil(t, got.RuntimeBinding) assert.Equal(t, "container-1", got.RuntimeBinding.ContainerID) assert.Equal(t, "engine.local:9000", got.RuntimeBinding.EngineEndpoint) assert.Equal(t, "1700000000000-0", got.RuntimeBinding.RuntimeJobID) require.NotNil(t, got.StartedAt) assert.True(t, got.StartedAt.Equal(h.at)) require.Len(t, h.gm.Requests(), 1) req := h.gm.Requests()[0] assert.Equal(t, h.gameRecord.GameID, req.GameID) assert.Equal(t, "container-1", req.ContainerID) assert.Equal(t, "engine.local:9000", req.EngineEndpoint) assert.Equal(t, h.gameRecord.TargetEngineVersion, req.TargetEngineVersion) assert.Equal(t, h.gameRecord.TurnSchedule, req.TurnSchedule) assert.Empty(t, h.runtime.StopJobs()) assert.Empty(t, h.intents.Published()) } func TestHandleSuccessGMUnavailableMovesToPausedAndPublishesIntent(t *testing.T) { h := newHarness(t) h.gm.SetError(ports.ErrGMUnavailable) h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000001-0")) got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusPaused, got.Status) require.NotNil(t, got.RuntimeBinding, "binding still persisted before paused") published := h.intents.Published() require.Len(t, published, 1) assert.Equal(t, notificationintent.NotificationTypeLobbyRuntimePausedAfterStart, published[0].NotificationType) assert.Empty(t, h.runtime.StopJobs()) } func TestHandleFailureTransitionsToStartFailed(t *testing.T) { h := newHarness(t) h.consumer.HandleMessage(context.Background(), failureMessage(t, h, "1700000000002-0")) got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusStartFailed, got.Status) assert.Nil(t, got.RuntimeBinding) assert.Empty(t, h.runtime.StopJobs()) assert.Empty(t, h.gm.Requests()) assert.Empty(t, h.intents.Published()) } func TestHandleSuccessOrphanContainerWhenBindingFails(t *testing.T) { h := newHarness(t) // Force binding update to fail by removing the game record from // the store before the message lands. require.NoError(t, h.games.Save(context.Background(), h.gameRecord)) failingGames := &fakeBindingFailer{Store: h.games, err: errors.New("redis tx failed")} consumer, err := runtimejobresult.NewConsumer(runtimejobresult.Config{ Client: h.clientRedis, Stream: h.stream, BlockTimeout: 100 * time.Millisecond, Games: failingGames, RuntimeManager: h.runtime, GMClient: h.gm, Intents: h.intents, OffsetStore: h.offsets, Clock: func() time.Time { return h.at }, Logger: silentLogger(), }) require.NoError(t, err) consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000003-0")) got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusStartFailed, got.Status, "orphan path must move game to start_failed") assert.Nil(t, got.RuntimeBinding, "binding never persisted") assert.Equal(t, []string{h.gameRecord.GameID.String()}, h.runtime.StopJobs()) assert.Empty(t, h.gm.Requests()) assert.Empty(t, h.intents.Published()) } func TestHandleSuccessReplayIsNoOp(t *testing.T) { h := newHarness(t) h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000004-0")) require.Len(t, h.gm.Requests(), 1) got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) originalUpdatedAt := got.UpdatedAt // Replay the same event: status is already running, so the early // status check exits before any side-effect call (no binding // overwrite, no GM call, no transition). h.gm.SetError(errors.New("must not be called again")) h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000004-0")) require.Len(t, h.gm.Requests(), 1, "GM register-game is invoked once across replays") got, err = h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusRunning, got.Status) assert.True(t, got.UpdatedAt.Equal(originalUpdatedAt), "no further mutations on replay") assert.Empty(t, h.intents.Published()) } func TestHandleFailureReplayIsNoOp(t *testing.T) { h := newHarness(t) h.consumer.HandleMessage(context.Background(), failureMessage(t, h, "1700000000005-0")) got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusStartFailed, got.Status) originalUpdatedAt := got.UpdatedAt h.consumer.HandleMessage(context.Background(), failureMessage(t, h, "1700000000005-0")) got, err = h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusStartFailed, got.Status) assert.True(t, got.UpdatedAt.Equal(originalUpdatedAt), "no further mutations on replay") } func TestHandleMalformedEvents(t *testing.T) { h := newHarness(t) cases := []redis.XMessage{ {ID: "1-0", Values: map[string]any{"outcome": "success"}}, // missing game_id {ID: "1-1", Values: map[string]any{"game_id": "bogus", "outcome": "success"}}, // invalid game_id format {ID: "1-2", Values: map[string]any{"game_id": h.gameRecord.GameID.String(), "outcome": "weird"}}, // bad outcome {ID: "1-3", Values: map[string]any{"game_id": h.gameRecord.GameID.String(), "outcome": "success"}}, // missing container_id {ID: "1-4", Values: map[string]any{"game_id": h.gameRecord.GameID.String(), "outcome": "success", "container_id": "c"}}, // missing engine_endpoint } for _, msg := range cases { h.consumer.HandleMessage(context.Background(), msg) } got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusStarting, got.Status, "malformed events leave game untouched") assert.Empty(t, h.runtime.StopJobs()) assert.Empty(t, h.gm.Requests()) } // fakeBindingFailer wraps gamestub.Store and forces UpdateRuntimeBinding // to fail; everything else delegates to the embedded store. type fakeBindingFailer struct { *gamestub.Store err error } func (f *fakeBindingFailer) UpdateRuntimeBinding(_ context.Context, _ ports.UpdateRuntimeBindingInput) error { return f.err } var _ ports.GameStore = (*fakeBindingFailer)(nil) func TestRunDrainsStreamUntilCancelled(t *testing.T) { h := newHarness(t) // Pre-publish a success message into the real miniredis stream // before Run starts. _, err := h.clientRedis.XAdd(context.Background(), &redis.XAddArgs{ Stream: h.stream, Values: map[string]any{ "game_id": h.gameRecord.GameID.String(), "outcome": "success", "container_id": "container-2", "engine_endpoint": "engine.local:9001", "completed_at_ms": "1745581200000", }, }).Result() require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() done := make(chan error, 1) go func() { done <- h.consumer.Run(ctx) }() // Poll for the running transition; once observed cancel context. deadline := time.Now().Add(1500 * time.Millisecond) for time.Now().Before(deadline) { got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) if got.Status == game.StatusRunning { break } time.Sleep(20 * time.Millisecond) } cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("consumer did not stop") } got, err := h.games.Get(context.Background(), h.gameRecord.GameID) require.NoError(t, err) assert.Equal(t, game.StatusRunning, got.Status) require.NotNil(t, got.RuntimeBinding) assert.Equal(t, "container-2", got.RuntimeBinding.ContainerID) // Offset must have been persisted at least once. id, found, err := h.offsets.Load(context.Background(), "runtime_results") require.NoError(t, err) assert.True(t, found) assert.NotEmpty(t, id) }