Files
galaxy-game/lobby/internal/worker/runtimejobresult/consumer_test.go
T
2026-04-28 20:39:18 +02:00

468 lines
15 KiB
Go

package runtimejobresult_test
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"galaxy/lobby/internal/adapters/gameinmem"
"galaxy/lobby/internal/adapters/mocks"
"galaxy/lobby/internal/adapters/streamoffsetinmem"
"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"
"go.uber.org/mock/gomock"
)
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// recorder captures every call passed through the mocks. The harness
// installs a default EXPECT().AnyTimes() that funnels every call into
// the recorder so individual tests can assert on observed calls.
// Per-test error injection uses recorder.gmErr/intentsErr.
type recorder struct {
mu sync.Mutex
stopGameIDs []string
stopReasons []ports.StopReason
gmRequests []ports.RegisterGameRequest
publishedIntents []notificationintent.Intent
gmErr error
intentsErr error
}
func (r *recorder) recordStop(_ context.Context, gameID string, reason ports.StopReason) error {
r.mu.Lock()
defer r.mu.Unlock()
r.stopGameIDs = append(r.stopGameIDs, gameID)
r.stopReasons = append(r.stopReasons, reason)
return nil
}
func (r *recorder) recordGM(_ context.Context, request ports.RegisterGameRequest) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.gmErr != nil {
return r.gmErr
}
r.gmRequests = append(r.gmRequests, request)
return nil
}
func (r *recorder) recordIntent(_ context.Context, intent notificationintent.Intent) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.intentsErr != nil {
return "", r.intentsErr
}
r.publishedIntents = append(r.publishedIntents, intent)
return "1", nil
}
func (r *recorder) stopGameIDsSnapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.stopGameIDs...)
}
func (r *recorder) stopReasonsSnapshot() []ports.StopReason {
r.mu.Lock()
defer r.mu.Unlock()
return append([]ports.StopReason(nil), r.stopReasons...)
}
func (r *recorder) gmRequestsSnapshot() []ports.RegisterGameRequest {
r.mu.Lock()
defer r.mu.Unlock()
return append([]ports.RegisterGameRequest(nil), r.gmRequests...)
}
func (r *recorder) publishedSnapshot() []notificationintent.Intent {
r.mu.Lock()
defer r.mu.Unlock()
return append([]notificationintent.Intent(nil), r.publishedIntents...)
}
func (r *recorder) setGMErr(err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.gmErr = err
}
type harness struct {
games *gameinmem.Store
runtime *mocks.MockRuntimeManager
gm *mocks.MockGMClient
intents *mocks.MockIntentPublisher
rec *recorder
offsets *streamoffsetinmem.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() })
ctrl := gomock.NewController(t)
rec := &recorder{}
games := gameinmem.NewStore()
runtime := mocks.NewMockRuntimeManager(ctrl)
runtime.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, _, _ string) error { return nil }).AnyTimes()
runtime.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(rec.recordStop).AnyTimes()
gm := mocks.NewMockGMClient(ctrl)
gm.EXPECT().RegisterGame(gomock.Any(), gomock.Any()).
DoAndReturn(rec.recordGM).AnyTimes()
gm.EXPECT().Ping(gomock.Any()).Return(nil).AnyTimes()
intents := mocks.NewMockIntentPublisher(ctrl)
intents.EXPECT().Publish(gomock.Any(), gomock.Any()).
DoAndReturn(rec.recordIntent).AnyTimes()
offsets := streamoffsetinmem.NewStore()
at := time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC)
h := &harness{
games: games,
runtime: runtime,
gm: gm,
intents: intents,
rec: rec,
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))
gmRequests := h.rec.gmRequestsSnapshot()
require.Len(t, gmRequests, 1)
req := gmRequests[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.rec.stopGameIDsSnapshot())
assert.Empty(t, h.rec.publishedSnapshot())
}
func TestHandleSuccessGMUnavailableMovesToPausedAndPublishesIntent(t *testing.T) {
h := newHarness(t)
h.rec.setGMErr(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.rec.publishedSnapshot()
require.Len(t, published, 1)
assert.Equal(t, notificationintent.NotificationTypeLobbyRuntimePausedAfterStart, published[0].NotificationType)
assert.Empty(t, h.rec.stopGameIDsSnapshot())
}
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.rec.stopGameIDsSnapshot())
assert.Empty(t, h.rec.gmRequestsSnapshot())
assert.Empty(t, h.rec.publishedSnapshot())
}
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.rec.stopGameIDsSnapshot())
assert.Equal(t,
[]ports.StopReason{ports.StopReasonOrphanCleanup},
h.rec.stopReasonsSnapshot(),
"orphan path must classify the stop job as orphan_cleanup",
)
assert.Empty(t, h.rec.gmRequestsSnapshot())
assert.Empty(t, h.rec.publishedSnapshot())
}
func TestHandleSuccessReplayIsNoOp(t *testing.T) {
h := newHarness(t)
h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000004-0"))
require.Len(t, h.rec.gmRequestsSnapshot(), 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.rec.setGMErr(errors.New("must not be called again"))
h.consumer.HandleMessage(context.Background(), successMessage(t, h, "1700000000004-0"))
require.Len(t, h.rec.gmRequestsSnapshot(), 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.rec.publishedSnapshot())
}
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.rec.stopGameIDsSnapshot())
assert.Empty(t, h.rec.gmRequestsSnapshot())
}
// fakeBindingFailer wraps gameinmem.Store and forces UpdateRuntimeBinding
// to fail; everything else delegates to the embedded store.
type fakeBindingFailer struct {
*gameinmem.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)
}