package healtheventsconsumer_test import ( "context" "errors" "strconv" "sync" "testing" "time" "galaxy/gamemaster/internal/adapters/mocks" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/telemetry" "galaxy/gamemaster/internal/worker/healtheventsconsumer" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) const ( testStream = "runtime:health_events" testLabel = "health_events" ) func newTestTelemetry(t *testing.T) *telemetry.Runtime { t.Helper() tm, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) return tm } // runningRecord builds a runtime_records row in `running` with a known // engine_health value. The seed simplifies expectations on Get reads. func runningRecord(gameID, health string) runtime.RuntimeRecord { created := time.Date(2026, time.May, 1, 12, 0, 0, 0, time.UTC) startedAt := created.Add(time.Second) nextGen := created.Add(time.Hour) return runtime.RuntimeRecord{ GameID: gameID, Status: runtime.StatusRunning, EngineEndpoint: "http://galaxy-game-" + gameID + ":8080", CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: "0 18 * * *", CurrentTurn: 5, NextGenerationAt: &nextGen, EngineHealth: health, CreatedAt: created, UpdatedAt: startedAt, StartedAt: &startedAt, } } func unreachableRecord(gameID, health string) runtime.RuntimeRecord { rec := runningRecord(gameID, health) rec.Status = runtime.StatusEngineUnreachable return rec } // withSummary returns a copy of rec with EngineHealth replaced. func withSummary(rec runtime.RuntimeRecord, summary string) runtime.RuntimeRecord { rec.EngineHealth = summary return rec } // withStatus returns a copy of rec with Status replaced. func withStatus(rec runtime.RuntimeRecord, status runtime.Status) runtime.RuntimeRecord { rec.Status = status return rec } // xMessage builds a redis.XMessage with the wire field layout used by // RTM's healtheventspublisher. func xMessage(id, gameID, eventType string, occurredAt time.Time) redis.XMessage { return redis.XMessage{ ID: id, Values: map[string]any{ "game_id": gameID, "event_type": eventType, "occurred_at_ms": strconv.FormatInt(occurredAt.UnixMilli(), 10), "details": "{}", }, } } // newWorker constructs a worker with mocked dependencies. The returned // pointers are mocks; gomock.Controller is owned by the test. type harness struct { worker *healtheventsconsumer.Worker store *mocks.MockRuntimeRecordStore publisher *mocks.MockLobbyEventsPublisher offsetStore *mocks.MockStreamOffsetStore now time.Time } func newHarness(t *testing.T, ctrl *gomock.Controller) *harness { t.Helper() now := time.Date(2026, time.May, 1, 13, 0, 0, 0, time.UTC) store := mocks.NewMockRuntimeRecordStore(ctrl) publisher := mocks.NewMockLobbyEventsPublisher(ctrl) offsetStore := mocks.NewMockStreamOffsetStore(ctrl) telem := newTestTelemetry(t) worker, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{ Client: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), Stream: testStream, StreamLabel: testLabel, BlockTimeout: 100 * time.Millisecond, OffsetStore: offsetStore, RuntimeRecords: store, LobbyEvents: publisher, Telemetry: telem, Clock: func() time.Time { return now }, }) require.NoError(t, err) return &harness{ worker: worker, store: store, publisher: publisher, offsetStore: offsetStore, now: now, } } // TestNewWorkerValidates exercises every required-dep branch. func TestNewWorkerValidates(t *testing.T) { telem := newTestTelemetry(t) client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}) cases := []struct { name string mut func(*healtheventsconsumer.Dependencies) }{ {"client", func(d *healtheventsconsumer.Dependencies) { d.Client = nil }}, {"stream", func(d *healtheventsconsumer.Dependencies) { d.Stream = " " }}, {"block timeout", func(d *healtheventsconsumer.Dependencies) { d.BlockTimeout = 0 }}, {"offset store", func(d *healtheventsconsumer.Dependencies) { d.OffsetStore = nil }}, {"runtime records", func(d *healtheventsconsumer.Dependencies) { d.RuntimeRecords = nil }}, {"lobby events", func(d *healtheventsconsumer.Dependencies) { d.LobbyEvents = nil }}, {"telemetry", func(d *healtheventsconsumer.Dependencies) { d.Telemetry = nil }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) deps := healtheventsconsumer.Dependencies{ Client: client, Stream: testStream, StreamLabel: testLabel, BlockTimeout: time.Second, OffsetStore: mocks.NewMockStreamOffsetStore(ctrl), RuntimeRecords: mocks.NewMockRuntimeRecordStore(ctrl), LobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), Telemetry: telem, } tc.mut(&deps) worker, err := healtheventsconsumer.NewWorker(deps) require.Error(t, err) require.Nil(t, worker) }) } } func TestNewWorkerDefaultsLabel(t *testing.T) { ctrl := gomock.NewController(t) telem := newTestTelemetry(t) worker, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{ Client: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), Stream: testStream, StreamLabel: "", BlockTimeout: time.Second, OffsetStore: mocks.NewMockStreamOffsetStore(ctrl), RuntimeRecords: mocks.NewMockRuntimeRecordStore(ctrl), LobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), Telemetry: telem, }) require.NoError(t, err) require.NotNil(t, worker) } // TestHandleMessage_ContainerExited covers a terminal event from a // healthy `running` record: status transitions to engine_unreachable // and a snapshot is published. func TestHandleMessage_ContainerExited(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" h.store.EXPECT().Get(gomock.Any(), gameID).Return(runningRecord(gameID, "healthy"), nil) h.store.EXPECT().UpdateStatus(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, input ports.UpdateStatusInput) error { require.Equal(t, runtime.StatusRunning, input.ExpectedFrom) require.Equal(t, runtime.StatusEngineUnreachable, input.To) require.NotNil(t, input.EngineHealthSummary) require.Equal(t, "exited", *input.EngineHealthSummary) return nil }, ) h.store.EXPECT().Get(gomock.Any(), gameID).Return( withStatus(withSummary(runningRecord(gameID, "healthy"), "exited"), runtime.StatusEngineUnreachable), nil, ) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, snap ports.RuntimeSnapshotUpdate) error { assert.Equal(t, gameID, snap.GameID) assert.Equal(t, runtime.StatusEngineUnreachable, snap.RuntimeStatus) assert.Equal(t, "exited", snap.EngineHealthSummary) assert.Nil(t, snap.PlayerTurnStats) assert.Equal(t, h.now, snap.OccurredAt) return nil }, ) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, "container_exited", h.now)) assert.True(t, advance) } // TestHandleMessage_ProbeRecovered_Recovers demonstrates the symmetric // recovery: engine_unreachable → running, summary set to healthy. func TestHandleMessage_ProbeRecovered_Recovers(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" h.store.EXPECT().Get(gomock.Any(), gameID).Return(unreachableRecord(gameID, "exited"), nil) h.store.EXPECT().UpdateStatus(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, input ports.UpdateStatusInput) error { require.Equal(t, runtime.StatusEngineUnreachable, input.ExpectedFrom) require.Equal(t, runtime.StatusRunning, input.To) require.NotNil(t, input.EngineHealthSummary) require.Equal(t, "healthy", *input.EngineHealthSummary) return nil }, ) h.store.EXPECT().Get(gomock.Any(), gameID).Return( withStatus(withSummary(unreachableRecord(gameID, "exited"), "healthy"), runtime.StatusRunning), nil, ) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, snap ports.RuntimeSnapshotUpdate) error { assert.Equal(t, runtime.StatusRunning, snap.RuntimeStatus) assert.Equal(t, "healthy", snap.EngineHealthSummary) return nil }, ) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, "probe_recovered", h.now)) assert.True(t, advance) } // TestHandleMessage_ContainerStarted_NoTransition asserts that // container_started writes summary `healthy` without status mutation. func TestHandleMessage_ContainerStarted_NoTransition(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" h.store.EXPECT().Get(gomock.Any(), gameID).Return(runningRecord(gameID, ""), nil) h.store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, input ports.UpdateEngineHealthInput) error { assert.Equal(t, gameID, input.GameID) assert.Equal(t, "healthy", input.EngineHealthSummary) return nil }, ) h.store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(runningRecord(gameID, ""), "healthy"), nil) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, "container_started", h.now)) assert.True(t, advance) } // TestHandleMessage_ProbeFailed covers the non-transitional path: // summary is updated; status stays running. func TestHandleMessage_ProbeFailed(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" h.store.EXPECT().Get(gomock.Any(), gameID).Return(runningRecord(gameID, "healthy"), nil) h.store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).Return(nil) h.store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(runningRecord(gameID, "healthy"), "probe_failed"), nil) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, snap ports.RuntimeSnapshotUpdate) error { assert.Equal(t, runtime.StatusRunning, snap.RuntimeStatus) assert.Equal(t, "probe_failed", snap.EngineHealthSummary) return nil }, ) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, "probe_failed", h.now)) assert.True(t, advance) } // TestHandleMessage_FallsBackOnCASConflict — record is in // generation_in_progress (not running); CAS rejects with ErrConflict and // the worker falls back to UpdateEngineHealth + publishes a snapshot // because the summary changed. func TestHandleMessage_FallsBackOnCASConflict(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" current := withStatus(runningRecord(gameID, "healthy"), runtime.StatusGenerationInProgress) h.store.EXPECT().Get(gomock.Any(), gameID).Return(current, nil) h.store.EXPECT().UpdateStatus(gomock.Any(), gomock.Any()).Return(runtime.ErrConflict) h.store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, input ports.UpdateEngineHealthInput) error { assert.Equal(t, "oom", input.EngineHealthSummary) return nil }, ) h.store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(current, "oom"), nil) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, snap ports.RuntimeSnapshotUpdate) error { assert.Equal(t, runtime.StatusGenerationInProgress, snap.RuntimeStatus, "status must reflect the unchanged record after fallback") assert.Equal(t, "oom", snap.EngineHealthSummary) return nil }, ) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, "container_oom", h.now)) assert.True(t, advance) } // TestHandleMessage_DebouncesUnchangedSummary — two consecutive // probe_failed events for the same game yield exactly one snapshot // publication. func TestHandleMessage_DebouncesUnchangedSummary(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" // First event: store update + reload + publish. h.store.EXPECT().Get(gomock.Any(), gameID).Return(runningRecord(gameID, "healthy"), nil) h.store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).Return(nil) h.store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(runningRecord(gameID, "healthy"), "probe_failed"), nil) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) // Second event: store update happens, but no second Get and no // publication since the summary is unchanged. h.store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(runningRecord(gameID, "probe_failed"), "probe_failed"), nil) h.store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).Return(nil) ctx := context.Background() require.True(t, h.worker.HandleMessage(ctx, xMessage("0-1", gameID, "probe_failed", h.now))) require.True(t, h.worker.HandleMessage(ctx, xMessage("0-2", gameID, "probe_failed", h.now))) } // TestHandleMessage_OrphanGameID — Get returns ErrNotFound, no further // store calls, no publish, offset advances. func TestHandleMessage_OrphanGameID(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "missing-001" h.store.EXPECT().Get(gomock.Any(), gameID).Return(runtime.RuntimeRecord{}, runtime.ErrNotFound) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, "probe_failed", h.now)) assert.True(t, advance) } // TestHandleMessage_UnknownEventType — unrecognised event type yields // no store calls and no publication, but offset advances. func TestHandleMessage_UnknownEventType(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", "game-001", "future_event", h.now)) assert.True(t, advance) } // TestHandleMessage_MalformedOccurredAtMS — malformed wire payload is // logged + skipped without store calls. func TestHandleMessage_MalformedOccurredAtMS(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) msg := redis.XMessage{ ID: "0-1", Values: map[string]any{ "game_id": "game-001", "event_type": "probe_failed", "occurred_at_ms": "not-a-number", }, } advance := h.worker.HandleMessage(context.Background(), msg) assert.True(t, advance) } // TestHandleMessage_MissingFields — missing required wire field is // logged + skipped. func TestHandleMessage_MissingFields(t *testing.T) { cases := []struct { name string msg redis.XMessage }{ {"missing game_id", redis.XMessage{ID: "0-1", Values: map[string]any{"event_type": "probe_failed", "occurred_at_ms": "1"}}}, {"missing event_type", redis.XMessage{ID: "0-1", Values: map[string]any{"game_id": "g", "occurred_at_ms": "1"}}}, {"missing occurred_at_ms", redis.XMessage{ID: "0-1", Values: map[string]any{"game_id": "g", "event_type": "probe_failed"}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) advance := h.worker.HandleMessage(context.Background(), tc.msg) assert.True(t, advance) }) } } // TestHandleMessage_PublishErrorAdvancesOffset — a publisher error is // logged and absorbed; the offset still advances so a transient hiccup // does not stall the consumer. func TestHandleMessage_PublishErrorAdvancesOffset(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" h.store.EXPECT().Get(gomock.Any(), gameID).Return(runningRecord(gameID, "healthy"), nil) h.store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).Return(nil) h.store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(runningRecord(gameID, "healthy"), "probe_failed"), nil) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(errors.New("redis down")) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, "probe_failed", h.now)) assert.True(t, advance) } // TestHandleMessage_AllEventTypes_RouteSummaries asserts the event-type // → summary mapping for the four non-CAS event types, plus that // container_started is non-CAS too. The CAS variants are covered by // dedicated tests above. func TestHandleMessage_AllEventTypes_RouteSummaries(t *testing.T) { type expectation struct { eventType string wantSummary string wantsCASCall bool } cases := []expectation{ {"container_started", "healthy", false}, {"probe_failed", "probe_failed", false}, {"inspect_unhealthy", "inspect_unhealthy", false}, } for _, tc := range cases { t.Run(tc.eventType, func(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) gameID := "game-001" h.store.EXPECT().Get(gomock.Any(), gameID).Return(runningRecord(gameID, ""), nil) h.store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, input ports.UpdateEngineHealthInput) error { assert.Equal(t, tc.wantSummary, input.EngineHealthSummary) return nil }, ) h.store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(runningRecord(gameID, ""), tc.wantSummary), nil) h.publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) advance := h.worker.HandleMessage(context.Background(), xMessage("0-1", gameID, tc.eventType, h.now)) assert.True(t, advance) }) } } // TestRun_LoadsOffsetAndAdvances drives a real XREAD loop against a // miniredis instance. After XADD-ing one entry and observing the loop // exit on context cancellation, the persisted offset must equal the // consumed entry's ID. func TestRun_LoadsOffsetAndAdvances(t *testing.T) { server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) ctrl := gomock.NewController(t) store := mocks.NewMockRuntimeRecordStore(ctrl) publisher := mocks.NewMockLobbyEventsPublisher(ctrl) telem := newTestTelemetry(t) gameID := "game-001" rec := runningRecord(gameID, "healthy") var ( mu sync.Mutex offset string offsetSet bool ) offsetStore := mocks.NewMockStreamOffsetStore(ctrl) offsetStore.EXPECT().Load(gomock.Any(), testLabel).Return("", false, nil) offsetStore.EXPECT().Save(gomock.Any(), testLabel, gomock.Any()).DoAndReturn( func(_ context.Context, _ string, entryID string) error { mu.Lock() defer mu.Unlock() offset = entryID offsetSet = true return nil }, ).MinTimes(1) store.EXPECT().Get(gomock.Any(), gameID).Return(rec, nil) store.EXPECT().UpdateEngineHealth(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().Get(gomock.Any(), gameID).Return(withSummary(rec, "probe_failed"), nil) publisher.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) worker, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{ Client: client, Stream: testStream, StreamLabel: testLabel, BlockTimeout: 100 * time.Millisecond, OffsetStore: offsetStore, RuntimeRecords: store, LobbyEvents: publisher, Telemetry: telem, }) require.NoError(t, err) occurredMS := strconv.FormatInt(time.Date(2026, time.May, 1, 12, 0, 0, 0, time.UTC).UnixMilli(), 10) entryID, err := client.XAdd(context.Background(), &redis.XAddArgs{ Stream: testStream, Values: map[string]any{ "game_id": gameID, "event_type": "probe_failed", "occurred_at_ms": occurredMS, "details": "{}", }, }).Result() require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- worker.Run(ctx) }() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { mu.Lock() set := offsetSet mu.Unlock() if set { break } time.Sleep(20 * time.Millisecond) } cancel() select { case err := <-done: assert.True(t, errors.Is(err, context.Canceled), "run must exit with context.Canceled, got %v", err) case <-time.After(2 * time.Second): t.Fatal("worker did not exit within deadline") } mu.Lock() defer mu.Unlock() require.True(t, offsetSet, "offset must be persisted at least once") assert.Equal(t, entryID, offset) } // TestRun_ContextCancel — Run returns context.Canceled on cancel even // when no stream entry is available. func TestRun_ContextCancel(t *testing.T) { server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) ctrl := gomock.NewController(t) store := mocks.NewMockRuntimeRecordStore(ctrl) publisher := mocks.NewMockLobbyEventsPublisher(ctrl) offsetStore := mocks.NewMockStreamOffsetStore(ctrl) offsetStore.EXPECT().Load(gomock.Any(), testLabel).Return("0-0", true, nil) worker, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{ Client: client, Stream: testStream, StreamLabel: testLabel, BlockTimeout: 50 * time.Millisecond, OffsetStore: offsetStore, RuntimeRecords: store, LobbyEvents: publisher, Telemetry: newTestTelemetry(t), }) require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- worker.Run(ctx) }() time.Sleep(150 * time.Millisecond) cancel() select { case err := <-done: assert.True(t, errors.Is(err, context.Canceled), "want context.Canceled, got %v", err) case <-time.After(2 * time.Second): t.Fatal("worker did not exit within deadline") } } // TestRun_FailsOnOffsetLoadError covers the bootstrap failure: a load // error is fatal and surfaces from Run. func TestRun_FailsOnOffsetLoadError(t *testing.T) { server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) ctrl := gomock.NewController(t) offsetStore := mocks.NewMockStreamOffsetStore(ctrl) offsetStore.EXPECT().Load(gomock.Any(), testLabel).Return("", false, errors.New("redis down")) worker, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{ Client: client, Stream: testStream, StreamLabel: testLabel, BlockTimeout: 50 * time.Millisecond, OffsetStore: offsetStore, RuntimeRecords: mocks.NewMockRuntimeRecordStore(ctrl), LobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), Telemetry: newTestTelemetry(t), }) require.NoError(t, err) err = worker.Run(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "load offset") } // TestShutdown_Noop confirms Shutdown returns nil for a non-nil ctx // and rejects a nil one. func TestShutdown_Noop(t *testing.T) { ctrl := gomock.NewController(t) h := newHarness(t, ctrl) require.NoError(t, h.worker.Shutdown(context.Background())) //nolint:staticcheck // Deliberate nil context to verify guard. require.Error(t, h.worker.Shutdown(nil)) }