package orderput_test import ( "context" "encoding/json" "errors" "fmt" "sync" "testing" "time" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/domain/playermapping" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/membership" "galaxy/gamemaster/internal/service/orderput" "galaxy/gamemaster/internal/telemetry" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- fakes ------------------------------------------------------------ type fakeRuntimeRecords struct { mu sync.Mutex stored map[string]runtime.RuntimeRecord getErr error } func newFakeRuntimeRecords() *fakeRuntimeRecords { return &fakeRuntimeRecords{stored: map[string]runtime.RuntimeRecord{}} } func (s *fakeRuntimeRecords) seed(record runtime.RuntimeRecord) { s.mu.Lock() defer s.mu.Unlock() s.stored[record.GameID] = record } func (s *fakeRuntimeRecords) Get(_ context.Context, gameID string) (runtime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() if s.getErr != nil { return runtime.RuntimeRecord{}, s.getErr } record, ok := s.stored[gameID] if !ok { return runtime.RuntimeRecord{}, runtime.ErrNotFound } return record, nil } func (s *fakeRuntimeRecords) Insert(context.Context, runtime.RuntimeRecord) error { return errors.New("not used") } func (s *fakeRuntimeRecords) UpdateStatus(context.Context, ports.UpdateStatusInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) UpdateScheduling(context.Context, ports.UpdateSchedulingInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) ListDueRunning(context.Context, time.Time) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used") } func (s *fakeRuntimeRecords) ListByStatus(context.Context, runtime.Status) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used") } func (s *fakeRuntimeRecords) List(context.Context) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used") } func (s *fakeRuntimeRecords) UpdateImage(context.Context, ports.UpdateImageInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) UpdateEngineHealth(context.Context, ports.UpdateEngineHealthInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) Delete(context.Context, string) error { return errors.New("not used") } type fakePlayerMappings struct { mu sync.Mutex stored map[string]map[string]playermapping.PlayerMapping getErr error } func newFakePlayerMappings() *fakePlayerMappings { return &fakePlayerMappings{stored: map[string]map[string]playermapping.PlayerMapping{}} } func (s *fakePlayerMappings) seed(record playermapping.PlayerMapping) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.stored[record.GameID]; !ok { s.stored[record.GameID] = map[string]playermapping.PlayerMapping{} } s.stored[record.GameID][record.UserID] = record } func (s *fakePlayerMappings) Get(_ context.Context, gameID, userID string) (playermapping.PlayerMapping, error) { s.mu.Lock() defer s.mu.Unlock() if s.getErr != nil { return playermapping.PlayerMapping{}, s.getErr } record, ok := s.stored[gameID][userID] if !ok { return playermapping.PlayerMapping{}, playermapping.ErrNotFound } return record, nil } func (s *fakePlayerMappings) BulkInsert(context.Context, []playermapping.PlayerMapping) error { return errors.New("not used") } func (s *fakePlayerMappings) GetByRace(context.Context, string, string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used") } func (s *fakePlayerMappings) ListByGame(context.Context, string) ([]playermapping.PlayerMapping, error) { return nil, errors.New("not used") } func (s *fakePlayerMappings) DeleteByGame(context.Context, string) error { return errors.New("not used") } type recordedCall struct { baseURL string payload json.RawMessage } type fakeEngine struct { mu sync.Mutex body json.RawMessage err error calls []recordedCall } func (f *fakeEngine) PutOrders(_ context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) { f.mu.Lock() defer f.mu.Unlock() stored := append(json.RawMessage(nil), payload...) f.calls = append(f.calls, recordedCall{baseURL: baseURL, payload: stored}) return f.body, f.err } func (f *fakeEngine) Init(context.Context, string, ports.InitRequest) (ports.StateResponse, error) { return ports.StateResponse{}, errors.New("not used") } func (f *fakeEngine) Status(context.Context, string) (ports.StateResponse, error) { return ports.StateResponse{}, errors.New("not used") } func (f *fakeEngine) Turn(context.Context, string) (ports.StateResponse, error) { return ports.StateResponse{}, errors.New("not used") } func (f *fakeEngine) BanishRace(context.Context, string, string) error { return errors.New("not used") } func (f *fakeEngine) ExecuteCommands(context.Context, string, json.RawMessage) (json.RawMessage, error) { return nil, errors.New("not used") } func (f *fakeEngine) GetReport(context.Context, string, string, int) (json.RawMessage, error) { return nil, errors.New("not used") } type fakeLobby struct { mu sync.Mutex answers map[string][]ports.Membership errs map[string]error } func newFakeLobby() *fakeLobby { return &fakeLobby{ answers: map[string][]ports.Membership{}, errs: map[string]error{}, } } func (f *fakeLobby) seed(gameID string, members []ports.Membership) { f.mu.Lock() defer f.mu.Unlock() f.answers[gameID] = members } func (f *fakeLobby) seedErr(gameID string, err error) { f.mu.Lock() defer f.mu.Unlock() f.errs[gameID] = err } func (f *fakeLobby) GetMemberships(_ context.Context, gameID string) ([]ports.Membership, error) { f.mu.Lock() defer f.mu.Unlock() if err, ok := f.errs[gameID]; ok { return nil, err } return append([]ports.Membership(nil), f.answers[gameID]...), nil } func (f *fakeLobby) GetGameSummary(context.Context, string) (ports.GameSummary, error) { return ports.GameSummary{}, errors.New("not used") } // --- harness ---------------------------------------------------------- type harness struct { t *testing.T now time.Time runtimes *fakeRuntimeRecords mappings *fakePlayerMappings engine *fakeEngine lobby *fakeLobby cache *membership.Cache service *orderput.Service } const ( testGameID = "game-001" testUserID = "user-1" testRaceName = "Aelinari" testEngineEndpoint = "http://galaxy-game-game-001:8080" ) func newHarness(t *testing.T) *harness { t.Helper() tel, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) h := &harness{ t: t, now: now, runtimes: newFakeRuntimeRecords(), mappings: newFakePlayerMappings(), engine: &fakeEngine{}, lobby: newFakeLobby(), } cache, err := membership.NewCache(membership.Dependencies{ Lobby: h.lobby, Telemetry: tel, TTL: time.Minute, MaxGames: 16, Clock: func() time.Time { return h.now }, }) require.NoError(t, err) h.cache = cache svc, err := orderput.NewService(orderput.Dependencies{ RuntimeRecords: h.runtimes, PlayerMappings: h.mappings, Membership: h.cache, Engine: h.engine, Telemetry: tel, Clock: func() time.Time { return h.now }, }) require.NoError(t, err) h.service = svc return h } func (h *harness) seedRunningRecord() { startedAt := h.now.Add(-1 * time.Hour) h.runtimes.seed(runtime.RuntimeRecord{ GameID: testGameID, Status: runtime.StatusRunning, EngineEndpoint: testEngineEndpoint, CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: "0 18 * * *", EngineHealth: "healthy", CreatedAt: h.now.Add(-2 * time.Hour), UpdatedAt: h.now.Add(-2 * time.Hour), StartedAt: &startedAt, }) } func (h *harness) seedActiveMembership() { h.lobby.seed(testGameID, []ports.Membership{{ UserID: testUserID, RaceName: testRaceName, Status: "active", JoinedAt: h.now.Add(-2 * time.Hour), }}) } func (h *harness) seedPlayerMapping() { h.mappings.seed(playermapping.PlayerMapping{ GameID: testGameID, UserID: testUserID, RaceName: testRaceName, EnginePlayerUUID: "uuid-1", CreatedAt: h.now.Add(-2 * time.Hour), }) } func (h *harness) inputWithCommands(payload string) orderput.Input { return orderput.Input{ GameID: testGameID, UserID: testUserID, Payload: json.RawMessage(payload), } } func basicOrdersPayload() string { return `{"commands":[{"@type":"BUILD_SHIP","cmdId":"00000000-0000-0000-0000-000000000001"}]}` } // --- tests ------------------------------------------------------------ func TestNewServiceRejectsBadDependencies(t *testing.T) { tel, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) cache, err := membership.NewCache(membership.Dependencies{ Lobby: newFakeLobby(), Telemetry: tel, TTL: time.Minute, MaxGames: 1, }) require.NoError(t, err) cases := []struct { name string deps orderput.Dependencies }{ {"nil runtime records", orderput.Dependencies{PlayerMappings: newFakePlayerMappings(), Membership: cache, Engine: &fakeEngine{}, Telemetry: tel}}, {"nil player mappings", orderput.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), Membership: cache, Engine: &fakeEngine{}, Telemetry: tel}}, {"nil membership", orderput.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), Engine: &fakeEngine{}, Telemetry: tel}}, {"nil engine", orderput.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), Membership: cache, Telemetry: tel}}, {"nil telemetry", orderput.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), Membership: cache, Engine: &fakeEngine{}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { svc, err := orderput.NewService(tc.deps) require.Error(t, err) assert.Nil(t, svc) }) } } func TestHandleHappyPath(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() h.seedPlayerMapping() h.engine.body = json.RawMessage(`{"results":[{"cmd_id":"00000000-0000-0000-0000-000000000001","cmd_applied":true}]}`) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeSuccess, result.Outcome) assert.JSONEq(t, string(h.engine.body), string(result.RawResponse)) require.Len(t, h.engine.calls, 1) assert.Equal(t, testEngineEndpoint, h.engine.calls[0].baseURL) var sentToEngine map[string]json.RawMessage require.NoError(t, json.Unmarshal(h.engine.calls[0].payload, &sentToEngine)) assert.Contains(t, sentToEngine, "actor") assert.Contains(t, sentToEngine, "cmd") assert.NotContains(t, sentToEngine, "commands", "GM must rewrite the field name") var actor string require.NoError(t, json.Unmarshal(sentToEngine["actor"], &actor)) assert.Equal(t, testRaceName, actor) } func TestHandleHappyPathDoesNotTrustCallerActor(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() h.seedPlayerMapping() h.engine.body = json.RawMessage(`{}`) payload := `{"actor":"Hacker","commands":[{"@type":"BUILD_SHIP","cmdId":"00000000-0000-0000-0000-000000000001"}]}` result, err := h.service.Handle(context.Background(), h.inputWithCommands(payload)) require.NoError(t, err) assert.Equal(t, operation.OutcomeSuccess, result.Outcome) var sentToEngine map[string]json.RawMessage require.NoError(t, json.Unmarshal(h.engine.calls[0].payload, &sentToEngine)) var actor string require.NoError(t, json.Unmarshal(sentToEngine["actor"], &actor)) assert.Equal(t, testRaceName, actor, "GM must override caller-supplied actor") } func TestHandleInvalidRequest(t *testing.T) { cases := []struct { name string input orderput.Input message string }{ {"empty game id", orderput.Input{UserID: testUserID, Payload: json.RawMessage(basicOrdersPayload())}, "game id"}, {"empty user id", orderput.Input{GameID: testGameID, Payload: json.RawMessage(basicOrdersPayload())}, "user id"}, {"empty payload", orderput.Input{GameID: testGameID, UserID: testUserID}, "payload"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) result, err := h.service.Handle(context.Background(), tc.input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeInvalidRequest, result.ErrorCode) assert.Contains(t, result.ErrorMessage, tc.message) }) } } func TestHandleMalformedPayload(t *testing.T) { cases := []struct { name string payload string }{ {"non-object", `[1,2,3]`}, {"missing commands", `{"orders":[]}`}, {"commands not array", `{"commands":"oops"}`}, {"non-json", `not json`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() h.seedPlayerMapping() result, err := h.service.Handle(context.Background(), h.inputWithCommands(tc.payload)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeInvalidRequest, result.ErrorCode) assert.Empty(t, h.engine.calls) }) } } func TestHandleRuntimeNotFound(t *testing.T) { h := newHarness(t) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeRuntimeNotFound, result.ErrorCode) } func TestHandleRuntimeStoreError(t *testing.T) { h := newHarness(t) h.runtimes.getErr = errors.New("postgres down") result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleRuntimeNotRunning(t *testing.T) { for _, status := range []runtime.Status{ runtime.StatusStarting, runtime.StatusGenerationInProgress, runtime.StatusGenerationFailed, runtime.StatusStopped, runtime.StatusEngineUnreachable, runtime.StatusFinished, } { t.Run(string(status), func(t *testing.T) { h := newHarness(t) startedAt := h.now.Add(-1 * time.Hour) finishedAt := h.now record := runtime.RuntimeRecord{ GameID: testGameID, Status: status, EngineEndpoint: testEngineEndpoint, CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: "0 18 * * *", CreatedAt: h.now.Add(-2 * time.Hour), UpdatedAt: h.now.Add(-2 * time.Hour), } if status != runtime.StatusStarting { record.StartedAt = &startedAt } if status == runtime.StatusStopped { record.StoppedAt = &finishedAt } if status == runtime.StatusFinished { record.FinishedAt = &finishedAt } h.runtimes.seed(record) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeRuntimeNotRunning, result.ErrorCode) assert.Empty(t, h.engine.calls) }) } } func TestHandleForbiddenInactiveMembership(t *testing.T) { cases := []struct { name string members []ports.Membership }{ {"removed", []ports.Membership{{UserID: testUserID, RaceName: testRaceName, Status: "removed"}}}, {"blocked", []ports.Membership{{UserID: testUserID, RaceName: testRaceName, Status: "blocked"}}}, {"unknown user", []ports.Membership{{UserID: "ghost", RaceName: "Ghost", Status: "active"}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedPlayerMapping() h.lobby.seed(testGameID, tc.members) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeForbidden, result.ErrorCode) assert.Empty(t, h.engine.calls) }) } } func TestHandleForbiddenMissingPlayerMapping(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeForbidden, result.ErrorCode) assert.Empty(t, h.engine.calls) } func TestHandleServiceUnavailableLobbyDown(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedPlayerMapping() h.lobby.seedErr(testGameID, fmt.Errorf("dial: %w", ports.ErrLobbyUnavailable)) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleServiceUnavailablePlayerMappingsError(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() h.mappings.getErr = errors.New("postgres down") result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleEngineUnreachable(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() h.seedPlayerMapping() h.engine.err = fmt.Errorf("dial: %w", ports.ErrEngineUnreachable) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeEngineUnreachable, result.ErrorCode) } func TestHandleEngineValidationErrorForwardsBody(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() h.seedPlayerMapping() h.engine.body = json.RawMessage(`{"results":[{"cmd_id":"x","cmd_error_code":"INVALID_TARGET"}]}`) h.engine.err = fmt.Errorf("400: %w", ports.ErrEngineValidation) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeEngineValidationError, result.ErrorCode) assert.JSONEq(t, string(h.engine.body), string(result.RawResponse)) } func TestHandleEngineProtocolViolation(t *testing.T) { h := newHarness(t) h.seedRunningRecord() h.seedActiveMembership() h.seedPlayerMapping() h.engine.err = fmt.Errorf("garbled: %w", ports.ErrEngineProtocolViolation) result, err := h.service.Handle(context.Background(), h.inputWithCommands(basicOrdersPayload())) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, orderput.ErrorCodeEngineProtocolViolation, result.ErrorCode) } func TestHandleNilContext(t *testing.T) { h := newHarness(t) var nilCtx context.Context _, err := h.service.Handle(nilCtx, h.inputWithCommands(basicOrdersPayload())) require.Error(t, err) } func TestHandleNilReceiver(t *testing.T) { var svc *orderput.Service _, err := svc.Handle(context.Background(), orderput.Input{}) require.Error(t, err) }