feat: game lobby service
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user