package engineversion_test import ( "context" "errors" "sync" "testing" "time" "galaxy/gamemaster/internal/adapters/mocks" domainengineversion "galaxy/gamemaster/internal/domain/engineversion" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/engineversion" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // fakeOperationLogs is a thread-safe stub recorder for the few // operation_log entries the engine-version service writes per call. // Using a stub keeps the operation_log assertions table-driven without // introducing the verbosity of a gomock recorder for every entry. type fakeOperationLogs struct { mu sync.Mutex entries []operation.OperationEntry err error } func newFakeOperationLogs() *fakeOperationLogs { return &fakeOperationLogs{} } func (s *fakeOperationLogs) Append(_ context.Context, entry operation.OperationEntry) (int64, error) { s.mu.Lock() defer s.mu.Unlock() if s.err != nil { return 0, s.err } s.entries = append(s.entries, entry) return int64(len(s.entries)), nil } func (s *fakeOperationLogs) ListByGame(_ context.Context, _ string, _ int) ([]operation.OperationEntry, error) { return nil, errors.New("not used in engineversion tests") } func (s *fakeOperationLogs) snapshot() []operation.OperationEntry { s.mu.Lock() defer s.mu.Unlock() out := make([]operation.OperationEntry, len(s.entries)) copy(out, s.entries) return out } type harness struct { ctrl *gomock.Controller store *mocks.MockEngineVersionStore oplog *fakeOperationLogs clock time.Time service *engineversion.Service } func newHarness(t *testing.T) *harness { t.Helper() ctrl := gomock.NewController(t) store := mocks.NewMockEngineVersionStore(ctrl) oplog := newFakeOperationLogs() clock := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC) service, err := engineversion.NewService(engineversion.Dependencies{ EngineVersions: store, OperationLogs: oplog, Clock: func() time.Time { return clock }, }) require.NoError(t, err) return &harness{ ctrl: ctrl, store: store, oplog: oplog, clock: clock, service: service, } } func TestNewServiceRejectsMissingDeps(t *testing.T) { ctrl := gomock.NewController(t) store := mocks.NewMockEngineVersionStore(ctrl) oplog := newFakeOperationLogs() tests := []struct { name string deps engineversion.Dependencies }{ {"nil store", engineversion.Dependencies{OperationLogs: oplog}}, {"nil oplog", engineversion.Dependencies{EngineVersions: store}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s, err := engineversion.NewService(tc.deps) require.Error(t, err) require.Nil(t, s) }) } } func TestNewServiceDefaultsClockAndLogger(t *testing.T) { ctrl := gomock.NewController(t) service, err := engineversion.NewService(engineversion.Dependencies{ EngineVersions: mocks.NewMockEngineVersionStore(ctrl), OperationLogs: newFakeOperationLogs(), }) require.NoError(t, err) require.NotNil(t, service) } // --- List ------------------------------------------------------------ func TestListNoFilter(t *testing.T) { h := newHarness(t) rows := []domainengineversion.EngineVersion{ {Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: domainengineversion.StatusActive}, {Version: "v1.3.0", ImageRef: "ghcr.io/galaxy/game:v1.3.0", Status: domainengineversion.StatusDeprecated}, } h.store.EXPECT().List(gomock.Any(), nil).Return(rows, nil) got, err := h.service.List(context.Background(), nil) require.NoError(t, err) assert.Equal(t, rows, got) } func TestListWithStatusFilter(t *testing.T) { h := newHarness(t) active := domainengineversion.StatusActive expected := []domainengineversion.EngineVersion{ {Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: active}, } h.store.EXPECT().List(gomock.Any(), &active).Return(expected, nil) got, err := h.service.List(context.Background(), &active) require.NoError(t, err) assert.Equal(t, expected, got) } func TestListRejectsUnknownStatusFilter(t *testing.T) { h := newHarness(t) exotic := domainengineversion.Status("exotic") got, err := h.service.List(context.Background(), &exotic) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) assert.Nil(t, got) } func TestListWrapsStoreErrorAsServiceUnavailable(t *testing.T) { h := newHarness(t) storeErr := errors.New("pg down") h.store.EXPECT().List(gomock.Any(), nil).Return(nil, storeErr) _, err := h.service.List(context.Background(), nil) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable)) } // --- Get ------------------------------------------------------------- func TestGetHappyPath(t *testing.T) { h := newHarness(t) row := domainengineversion.EngineVersion{ Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: domainengineversion.StatusActive, } h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(row, nil) got, err := h.service.Get(context.Background(), "v1.2.3") require.NoError(t, err) assert.Equal(t, row, got) } func TestGetNotFound(t *testing.T) { h := newHarness(t) h.store.EXPECT().Get(gomock.Any(), "v9.9.9").Return(domainengineversion.EngineVersion{}, domainengineversion.ErrNotFound) _, err := h.service.Get(context.Background(), "v9.9.9") require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) } func TestGetRejectsEmptyVersion(t *testing.T) { h := newHarness(t) _, err := h.service.Get(context.Background(), " ") require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestGetWrapsStoreError(t *testing.T) { h := newHarness(t) h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(domainengineversion.EngineVersion{}, errors.New("pg down")) _, err := h.service.Get(context.Background(), "v1.2.3") require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable)) } // --- ResolveImageRef ------------------------------------------------- func TestResolveImageRefHappyPath(t *testing.T) { h := newHarness(t) h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(domainengineversion.EngineVersion{ Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Status: domainengineversion.StatusActive, }, nil) got, err := h.service.ResolveImageRef(context.Background(), "v1.2.3") require.NoError(t, err) assert.Equal(t, "ghcr.io/galaxy/game:v1.2.3", got) } func TestResolveImageRefSeededTable(t *testing.T) { tests := []struct { name string seedVersion string seedRef string }{ {"v1.0.0", "v1.0.0", "ghcr.io/galaxy/game:v1.0.0"}, {"v1.2.3 with prerelease metadata", "v1.2.3-rc1", "ghcr.io/galaxy/game:v1.2.3-rc1"}, {"v2.0.0 fully-qualified", "v2.0.0", "registry.galaxy.local/game:v2.0.0"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) h.store.EXPECT().Get(gomock.Any(), tc.seedVersion).Return(domainengineversion.EngineVersion{ Version: tc.seedVersion, ImageRef: tc.seedRef, Status: domainengineversion.StatusActive, }, nil) got, err := h.service.ResolveImageRef(context.Background(), tc.seedVersion) require.NoError(t, err) assert.Equal(t, tc.seedRef, got) }) } } func TestResolveImageRefNotFound(t *testing.T) { h := newHarness(t) h.store.EXPECT().Get(gomock.Any(), "v9.9.9").Return(domainengineversion.EngineVersion{}, domainengineversion.ErrNotFound) _, err := h.service.ResolveImageRef(context.Background(), "v9.9.9") require.True(t, errors.Is(err, engineversion.ErrNotFound)) } // --- Create ---------------------------------------------------------- func TestCreateHappyPath(t *testing.T) { h := newHarness(t) h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, record domainengineversion.EngineVersion) error { assert.Equal(t, "v1.2.3", record.Version) assert.Equal(t, "ghcr.io/galaxy/game:v1.2.3", record.ImageRef) assert.Equal(t, domainengineversion.StatusActive, record.Status) assert.Equal(t, h.clock, record.CreatedAt) assert.Equal(t, h.clock, record.UpdatedAt) return nil }, ) got, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: "1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Options: []byte(`{"max_planets":120}`), OpSource: operation.OpSourceAdminRest, SourceRef: "request-1", }) require.NoError(t, err) assert.Equal(t, "v1.2.3", got.Version) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OpKindEngineVersionCreate, entries[0].OpKind) assert.Equal(t, "v1.2.3", entries[0].GameID) assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome) assert.Equal(t, operation.OpSourceAdminRest, entries[0].OpSource) assert.Equal(t, "request-1", entries[0].SourceRef) } func TestCreateRejectsInvalidSemver(t *testing.T) { tests := []string{"", " ", "not-a-version", "v1.2", "1.2"} for _, version := range tests { t.Run(version, func(t *testing.T) { h := newHarness(t) _, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: version, ImageRef: "ghcr.io/galaxy/game:v1.2.3", }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) }) } } func TestCreateAuditFailureForBadImageRef(t *testing.T) { h := newHarness(t) _, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: "v1.2.3", ImageRef: " ", }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OpKindEngineVersionCreate, entries[0].OpKind) assert.Equal(t, "v1.2.3", entries[0].GameID) assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome) assert.Equal(t, engineversion.ErrorCodeInvalidRequest, entries[0].ErrorCode) } func TestCreateRejectsBadDockerReference(t *testing.T) { h := newHarness(t) _, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: "v1.2.3", ImageRef: "BAD//Ref::", }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestCreateRejectsNonObjectOptions(t *testing.T) { h := newHarness(t) _, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Options: []byte(`[1,2,3]`), }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestCreateAcceptsEmptyOptionsAsNil(t *testing.T) { h := newHarness(t) h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, record domainengineversion.EngineVersion) error { assert.Empty(t, record.Options, "expected empty options pass-through (adapter writes default {})") return nil }, ) _, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", Options: nil, }) require.NoError(t, err) } func TestCreateConflict(t *testing.T) { h := newHarness(t) h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(domainengineversion.ErrConflict) _, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrConflict)) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome) assert.Equal(t, engineversion.ErrorCodeConflict, entries[0].ErrorCode) } func TestCreateUnknownStoreError(t *testing.T) { h := newHarness(t) h.store.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(errors.New("pg down")) _, err := h.service.Create(context.Background(), engineversion.CreateInput{ Version: "v1.2.3", ImageRef: "ghcr.io/galaxy/game:v1.2.3", }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable)) } // --- Update ---------------------------------------------------------- func TestUpdateHappyPath(t *testing.T) { h := newHarness(t) newRef := "ghcr.io/galaxy/game:v1.2.4" deprecated := domainengineversion.StatusDeprecated gomock.InOrder( h.store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, input ports.UpdateEngineVersionInput) error { require.NotNil(t, input.ImageRef) assert.Equal(t, newRef, *input.ImageRef) require.NotNil(t, input.Status) assert.Equal(t, deprecated, *input.Status) assert.Equal(t, h.clock, input.Now) return nil }, ), h.store.EXPECT().Get(gomock.Any(), "v1.2.3").Return(domainengineversion.EngineVersion{ Version: "v1.2.3", ImageRef: newRef, Status: deprecated, UpdatedAt: h.clock, }, nil), ) got, err := h.service.Update(context.Background(), engineversion.UpdateInput{ Version: "v1.2.3", ImageRef: &newRef, Status: &deprecated, }) require.NoError(t, err) assert.Equal(t, deprecated, got.Status) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OpKindEngineVersionUpdate, entries[0].OpKind) assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome) } func TestUpdateRejectsEmptyVersion(t *testing.T) { h := newHarness(t) newRef := "ghcr.io/galaxy/game:v1.2.4" _, err := h.service.Update(context.Background(), engineversion.UpdateInput{ Version: " ", ImageRef: &newRef, }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestUpdateRejectsEmptyPatch(t *testing.T) { h := newHarness(t) _, err := h.service.Update(context.Background(), engineversion.UpdateInput{Version: "v1.2.3"}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestUpdateRejectsBadImageRef(t *testing.T) { h := newHarness(t) bad := "BAD//Ref::" _, err := h.service.Update(context.Background(), engineversion.UpdateInput{ Version: "v1.2.3", ImageRef: &bad, }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestUpdateRejectsUnknownStatus(t *testing.T) { h := newHarness(t) bad := domainengineversion.Status("exotic") _, err := h.service.Update(context.Background(), engineversion.UpdateInput{ Version: "v1.2.3", Status: &bad, }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestUpdateRejectsBadOptions(t *testing.T) { h := newHarness(t) bad := []byte(`"not-an-object"`) _, err := h.service.Update(context.Background(), engineversion.UpdateInput{ Version: "v1.2.3", Options: &bad, }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestUpdateNotFound(t *testing.T) { h := newHarness(t) newRef := "ghcr.io/galaxy/game:v1.2.4" h.store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(domainengineversion.ErrNotFound) _, err := h.service.Update(context.Background(), engineversion.UpdateInput{ Version: "v1.2.3", ImageRef: &newRef, }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, engineversion.ErrorCodeEngineVersionNotFound, entries[0].ErrorCode) } // --- Deprecate ------------------------------------------------------- func TestDeprecateHappyPath(t *testing.T) { h := newHarness(t) h.store.EXPECT().Deprecate(gomock.Any(), "v1.2.3", h.clock).Return(nil) err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{Version: "v1.2.3"}) require.NoError(t, err) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OpKindEngineVersionDeprecate, entries[0].OpKind) assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome) } func TestDeprecateRejectsEmptyVersion(t *testing.T) { h := newHarness(t) err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestDeprecateNotFound(t *testing.T) { h := newHarness(t) h.store.EXPECT().Deprecate(gomock.Any(), "v9.9.9", h.clock).Return(domainengineversion.ErrNotFound) err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{Version: "v9.9.9"}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome) assert.Equal(t, engineversion.ErrorCodeEngineVersionNotFound, entries[0].ErrorCode) } func TestDeprecateUnknownStoreError(t *testing.T) { h := newHarness(t) h.store.EXPECT().Deprecate(gomock.Any(), "v1.2.3", h.clock).Return(errors.New("pg down")) err := h.service.Deprecate(context.Background(), engineversion.DeprecateInput{Version: "v1.2.3"}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable)) } // --- Delete ---------------------------------------------------------- func TestDeleteHappyPath(t *testing.T) { h := newHarness(t) gomock.InOrder( h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(false, nil), h.store.EXPECT().Delete(gomock.Any(), "v1.2.3").Return(nil), ) err := h.service.Delete(context.Background(), engineversion.DeleteInput{ Version: "v1.2.3", OpSource: operation.OpSourceAdminRest, SourceRef: "ticket-42", }) require.NoError(t, err) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OpKindEngineVersionDelete, entries[0].OpKind) assert.Equal(t, operation.OutcomeSuccess, entries[0].Outcome) assert.Equal(t, "ticket-42", entries[0].SourceRef) } func TestDeleteRejectsEmptyVersion(t *testing.T) { h := newHarness(t) err := h.service.Delete(context.Background(), engineversion.DeleteInput{}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInvalidRequest)) } func TestDeleteRejectedWhenReferenced(t *testing.T) { h := newHarness(t) h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(true, nil) // Delete must not be called when the row is referenced. err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v1.2.3"}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrInUse)) entries := h.oplog.snapshot() require.Len(t, entries, 1) assert.Equal(t, operation.OutcomeFailure, entries[0].Outcome) assert.Equal(t, engineversion.ErrorCodeEngineVersionInUse, entries[0].ErrorCode) } func TestDeleteIsReferencedProbeError(t *testing.T) { h := newHarness(t) h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(false, errors.New("pg down")) err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v1.2.3"}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable)) } func TestDeleteNotFound(t *testing.T) { h := newHarness(t) gomock.InOrder( h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v9.9.9").Return(false, nil), h.store.EXPECT().Delete(gomock.Any(), "v9.9.9").Return(domainengineversion.ErrNotFound), ) err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v9.9.9"}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) } func TestDeleteUnknownStoreError(t *testing.T) { h := newHarness(t) gomock.InOrder( h.store.EXPECT().IsReferencedByActiveRuntime(gomock.Any(), "v1.2.3").Return(false, nil), h.store.EXPECT().Delete(gomock.Any(), "v1.2.3").Return(errors.New("pg down")), ) err := h.service.Delete(context.Background(), engineversion.DeleteInput{Version: "v1.2.3"}) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrServiceUnavailable)) } // --- guard rails ----------------------------------------------------- func TestNilContextReturnsError(t *testing.T) { h := newHarness(t) t.Run("List", func(t *testing.T) { _, err := h.service.List(nil, nil) //nolint:staticcheck // intentional nil context require.Error(t, err) }) t.Run("Get", func(t *testing.T) { _, err := h.service.Get(nil, "v1.2.3") //nolint:staticcheck // intentional nil context require.Error(t, err) }) t.Run("Create", func(t *testing.T) { _, err := h.service.Create(nil, engineversion.CreateInput{}) //nolint:staticcheck // intentional nil context require.Error(t, err) }) t.Run("Update", func(t *testing.T) { _, err := h.service.Update(nil, engineversion.UpdateInput{}) //nolint:staticcheck // intentional nil context require.Error(t, err) }) t.Run("Deprecate", func(t *testing.T) { err := h.service.Deprecate(nil, engineversion.DeprecateInput{}) //nolint:staticcheck // intentional nil context require.Error(t, err) }) t.Run("Delete", func(t *testing.T) { err := h.service.Delete(nil, engineversion.DeleteInput{}) //nolint:staticcheck // intentional nil context require.Error(t, err) }) } func TestNilServiceReturnsError(t *testing.T) { var s *engineversion.Service _, err := s.Get(context.Background(), "v1.2.3") require.Error(t, err) _, err = s.Create(context.Background(), engineversion.CreateInput{}) require.Error(t, err) }