package reportget_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/reportget" "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 recordedReport struct { baseURL string raceName string turn int } type fakeEngine struct { mu sync.Mutex body json.RawMessage err error calls []recordedReport } func (f *fakeEngine) GetReport(_ context.Context, baseURL, raceName string, turn int) (json.RawMessage, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, recordedReport{baseURL: baseURL, raceName: raceName, turn: turn}) 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) PutOrders(context.Context, string, json.RawMessage) (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 *reportget.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 := reportget.NewService(reportget.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) seedRecordWithStatus(status runtime.Status) { 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 * * *", EngineHealth: "healthy", 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) } 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) input(turn int) reportget.Input { return reportget.Input{GameID: testGameID, UserID: testUserID, Turn: turn} } // --- 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 reportget.Dependencies }{ {"nil runtime records", reportget.Dependencies{PlayerMappings: newFakePlayerMappings(), Membership: cache, Engine: &fakeEngine{}, Telemetry: tel}}, {"nil player mappings", reportget.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), Membership: cache, Engine: &fakeEngine{}, Telemetry: tel}}, {"nil membership", reportget.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), Engine: &fakeEngine{}, Telemetry: tel}}, {"nil engine", reportget.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), Membership: cache, Telemetry: tel}}, {"nil telemetry", reportget.Dependencies{RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), Membership: cache, Engine: &fakeEngine{}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { svc, err := reportget.NewService(tc.deps) require.Error(t, err) assert.Nil(t, svc) }) } } func TestHandleHappyPath(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(runtime.StatusRunning) h.seedActiveMembership() h.seedPlayerMapping() h.engine.body = json.RawMessage(`{"version":1,"turn":3,"player":[]}`) result, err := h.service.Handle(context.Background(), h.input(3)) 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) assert.Equal(t, testRaceName, h.engine.calls[0].raceName) assert.Equal(t, 3, h.engine.calls[0].turn) } func TestHandleAcceptsAnyNonNotFoundStatus(t *testing.T) { for _, status := range []runtime.Status{ runtime.StatusStarting, runtime.StatusRunning, runtime.StatusGenerationInProgress, runtime.StatusGenerationFailed, runtime.StatusStopped, runtime.StatusEngineUnreachable, runtime.StatusFinished, } { t.Run(string(status), func(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(status) h.seedActiveMembership() h.seedPlayerMapping() h.engine.body = json.RawMessage(`{"version":1,"turn":0,"player":[]}`) result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeSuccess, result.Outcome, "reports must be served regardless of status; got %s", result.ErrorCode) }) } } func TestHandleInvalidRequest(t *testing.T) { cases := []struct { name string input reportget.Input message string }{ {"empty game id", reportget.Input{UserID: testUserID, Turn: 0}, "game id"}, {"empty user id", reportget.Input{GameID: testGameID, Turn: 0}, "user id"}, {"negative turn", reportget.Input{GameID: testGameID, UserID: testUserID, Turn: -1}, "turn"}, } 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, reportget.ErrorCodeInvalidRequest, result.ErrorCode) assert.Contains(t, result.ErrorMessage, tc.message) }) } } func TestHandleRuntimeNotFound(t *testing.T) { h := newHarness(t) result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.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.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeServiceUnavailable, result.ErrorCode) } 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.seedRecordWithStatus(runtime.StatusRunning) h.seedPlayerMapping() h.lobby.seed(testGameID, tc.members) result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeForbidden, result.ErrorCode) assert.Empty(t, h.engine.calls) }) } } func TestHandleForbiddenMissingPlayerMapping(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(runtime.StatusRunning) h.seedActiveMembership() result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeForbidden, result.ErrorCode) assert.Empty(t, h.engine.calls) } func TestHandleServiceUnavailableLobbyDown(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(runtime.StatusRunning) h.seedPlayerMapping() h.lobby.seedErr(testGameID, fmt.Errorf("dial: %w", ports.ErrLobbyUnavailable)) result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleServiceUnavailablePlayerMappingsError(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(runtime.StatusRunning) h.seedActiveMembership() h.mappings.getErr = errors.New("postgres down") result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleEngineUnreachable(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(runtime.StatusRunning) h.seedActiveMembership() h.seedPlayerMapping() h.engine.err = fmt.Errorf("dial: %w", ports.ErrEngineUnreachable) result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeEngineUnreachable, result.ErrorCode) } func TestHandleEngineValidationErrorForwardsBody(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(runtime.StatusRunning) h.seedActiveMembership() h.seedPlayerMapping() h.engine.body = json.RawMessage(`{"error":"unknown turn"}`) h.engine.err = fmt.Errorf("400: %w", ports.ErrEngineValidation) result, err := h.service.Handle(context.Background(), h.input(99)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeEngineValidationError, result.ErrorCode) assert.JSONEq(t, string(h.engine.body), string(result.RawResponse)) } func TestHandleEngineProtocolViolation(t *testing.T) { h := newHarness(t) h.seedRecordWithStatus(runtime.StatusRunning) h.seedActiveMembership() h.seedPlayerMapping() h.engine.err = fmt.Errorf("garbled: %w", ports.ErrEngineProtocolViolation) result, err := h.service.Handle(context.Background(), h.input(0)) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, reportget.ErrorCodeEngineProtocolViolation, result.ErrorCode) } func TestHandleNilContext(t *testing.T) { h := newHarness(t) var nilCtx context.Context _, err := h.service.Handle(nilCtx, h.input(0)) require.Error(t, err) } func TestHandleNilReceiver(t *testing.T) { var svc *reportget.Service _, err := svc.Handle(context.Background(), reportget.Input{}) require.Error(t, err) }