package livenessreply_test import ( "context" "errors" "sync" "testing" "time" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/livenessreply" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) 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) 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") } 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 newService(t *testing.T, store *fakeRuntimeRecords) *livenessreply.Service { t.Helper() service, err := livenessreply.NewService(livenessreply.Dependencies{ RuntimeRecords: store, }) require.NoError(t, err) return service } func runningRecord(gameID string) runtime.RuntimeRecord { now := time.Date(2026, time.May, 1, 12, 0, 0, 0, time.UTC) 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, CreatedAt: now, UpdatedAt: now, } } func TestNewServiceRejectsNilRuntimeRecords(t *testing.T) { _, err := livenessreply.NewService(livenessreply.Dependencies{}) require.Error(t, err) } func TestHandleRunningReturnsReadyTrue(t *testing.T) { store := newFakeRuntimeRecords() store.seed(runningRecord("game-001")) service := newService(t, store) result, err := service.Handle(context.Background(), livenessreply.Input{GameID: "game-001"}) require.NoError(t, err) assert.True(t, result.Ready) assert.Equal(t, runtime.StatusRunning, result.Status) } func TestHandleNonRunningReturnsReadyFalseWithStatus(t *testing.T) { cases := []runtime.Status{ runtime.StatusStarting, runtime.StatusGenerationInProgress, runtime.StatusGenerationFailed, runtime.StatusEngineUnreachable, runtime.StatusStopped, runtime.StatusFinished, } for _, status := range cases { t.Run(string(status), func(t *testing.T) { store := newFakeRuntimeRecords() rec := runningRecord("game-001") rec.Status = status store.seed(rec) service := newService(t, store) result, err := service.Handle(context.Background(), livenessreply.Input{GameID: "game-001"}) require.NoError(t, err) assert.False(t, result.Ready) assert.Equal(t, status, result.Status) }) } } func TestHandleRuntimeNotFoundReturnsEmptyStatus(t *testing.T) { store := newFakeRuntimeRecords() service := newService(t, store) result, err := service.Handle(context.Background(), livenessreply.Input{GameID: "missing"}) require.NoError(t, err, "runtime_not_found is absorbed into 200 response per Stage 17 D5") assert.False(t, result.Ready) assert.Equal(t, runtime.Status(""), result.Status) } func TestHandleStoreReadFailureReturnsServiceUnavailable(t *testing.T) { store := newFakeRuntimeRecords() store.getErr = errors.New("connection refused") service := newService(t, store) _, err := service.Handle(context.Background(), livenessreply.Input{GameID: "game-001"}) require.Error(t, err) assert.Contains(t, err.Error(), livenessreply.ErrorCodeServiceUnavailable) } func TestHandleEmptyGameIDReturnsInvalidRequest(t *testing.T) { store := newFakeRuntimeRecords() service := newService(t, store) _, err := service.Handle(context.Background(), livenessreply.Input{GameID: ""}) require.Error(t, err) assert.Contains(t, err.Error(), livenessreply.ErrorCodeInvalidRequest) } func TestHandleNilContextReturnsError(t *testing.T) { store := newFakeRuntimeRecords() service := newService(t, store) _, err := service.Handle(nil, livenessreply.Input{GameID: "game-001"}) //nolint:staticcheck // guard test require.Error(t, err) }