package engineversionstore_test import ( "context" "database/sql" "errors" "testing" "time" "galaxy/gamemaster/internal/adapters/postgres/engineversionstore" "galaxy/gamemaster/internal/adapters/postgres/internal/pgtest" "galaxy/gamemaster/internal/domain/engineversion" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { pgtest.RunMain(m) } func newStore(t *testing.T) *engineversionstore.Store { t.Helper() pgtest.TruncateAll(t) store, err := engineversionstore.New(engineversionstore.Config{ DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout, }) require.NoError(t, err) return store } // poolOnly returns the shared pool for tests that have to seed // runtime_records directly (e.g. TestIsReferencedByActiveRuntime). func poolOnly(t *testing.T) *sql.DB { t.Helper() pgtest.TruncateAll(t) return pgtest.Ensure(t).Pool() } func validVersion(version string, createdAt time.Time, status engineversion.Status) engineversion.EngineVersion { return engineversion.EngineVersion{ Version: version, ImageRef: "ghcr.io/galaxy/game:" + version, Options: []byte(`{"max_planets":120}`), Status: status, CreatedAt: createdAt, UpdatedAt: createdAt, } } func TestNewRejectsInvalidConfig(t *testing.T) { _, err := engineversionstore.New(engineversionstore.Config{}) require.Error(t, err) store, err := engineversionstore.New(engineversionstore.Config{ DB: pgtest.Ensure(t).Pool(), OperationTimeout: 0, }) require.Error(t, err) require.Nil(t, store) } func TestInsertGetRoundTrip(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) record := validVersion("v1.2.3", now, engineversion.StatusActive) require.NoError(t, store.Insert(ctx, record)) got, err := store.Get(ctx, "v1.2.3") require.NoError(t, err) assert.Equal(t, record.Version, got.Version) assert.Equal(t, record.ImageRef, got.ImageRef) assert.JSONEq(t, `{"max_planets":120}`, string(got.Options)) assert.Equal(t, engineversion.StatusActive, got.Status) assert.True(t, got.CreatedAt.Equal(now)) assert.True(t, got.UpdatedAt.Equal(now)) assert.Equal(t, time.UTC, got.CreatedAt.Location()) assert.Equal(t, time.UTC, got.UpdatedAt.Location()) } func TestInsertEmptyOptionsDefaultsToObject(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) record := validVersion("v1.2.3", now, engineversion.StatusActive) record.Options = nil require.NoError(t, store.Insert(ctx, record)) got, err := store.Get(ctx, "v1.2.3") require.NoError(t, err) assert.JSONEq(t, `{}`, string(got.Options)) } func TestInsertConflict(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) record := validVersion("v1.2.3", now, engineversion.StatusActive) require.NoError(t, store.Insert(ctx, record)) err := store.Insert(ctx, record) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrConflict), "want ErrConflict, got %v", err) } func TestGetNotFound(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.Get(ctx, "v9.9.9") require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) } func TestListNoFilter(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.0", now, engineversion.StatusDeprecated))) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive))) require.NoError(t, store.Insert(ctx, validVersion("v1.3.0", now, engineversion.StatusActive))) all, err := store.List(ctx, nil) require.NoError(t, err) require.Len(t, all, 3) assert.Equal(t, "v1.2.0", all[0].Version) assert.Equal(t, "v1.2.3", all[1].Version) assert.Equal(t, "v1.3.0", all[2].Version) } func TestListByStatusFilter(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.0", now, engineversion.StatusDeprecated))) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive))) require.NoError(t, store.Insert(ctx, validVersion("v1.3.0", now, engineversion.StatusActive))) active := engineversion.StatusActive got, err := store.List(ctx, &active) require.NoError(t, err) require.Len(t, got, 2) assert.Equal(t, "v1.2.3", got[0].Version) assert.Equal(t, "v1.3.0", got[1].Version) deprecated := engineversion.StatusDeprecated got, err = store.List(ctx, &deprecated) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, "v1.2.0", got[0].Version) } func TestListUnknownStatusRejected(t *testing.T) { ctx := context.Background() store := newStore(t) exotic := engineversion.Status("exotic") _, err := store.List(ctx, &exotic) require.Error(t, err) } func TestUpdateImageRefOnly(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive))) newRef := "ghcr.io/galaxy/game:v1.2.4" updateAt := now.Add(time.Minute) require.NoError(t, store.Update(ctx, ports.UpdateEngineVersionInput{ Version: "v1.2.3", ImageRef: &newRef, Now: updateAt, })) got, err := store.Get(ctx, "v1.2.3") require.NoError(t, err) assert.Equal(t, newRef, got.ImageRef) assert.Equal(t, engineversion.StatusActive, got.Status) assert.True(t, got.UpdatedAt.Equal(updateAt)) } func TestUpdateAllFields(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive))) newRef := "ghcr.io/galaxy/game:v1.2.4" newOptions := []byte(`{"max_planets":240,"hot_seat":true}`) deprecated := engineversion.StatusDeprecated updateAt := now.Add(time.Minute) require.NoError(t, store.Update(ctx, ports.UpdateEngineVersionInput{ Version: "v1.2.3", ImageRef: &newRef, Options: &newOptions, Status: &deprecated, Now: updateAt, })) got, err := store.Get(ctx, "v1.2.3") require.NoError(t, err) assert.Equal(t, newRef, got.ImageRef) assert.JSONEq(t, string(newOptions), string(got.Options)) assert.Equal(t, engineversion.StatusDeprecated, got.Status) assert.True(t, got.UpdatedAt.Equal(updateAt)) } func TestUpdateNotFound(t *testing.T) { ctx := context.Background() store := newStore(t) newRef := "ghcr.io/galaxy/game:v1.2.4" err := store.Update(ctx, ports.UpdateEngineVersionInput{ Version: "v9.9.9", ImageRef: &newRef, Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC), }) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) } func TestDeprecateHappy(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive))) deprecateAt := now.Add(time.Hour) require.NoError(t, store.Deprecate(ctx, "v1.2.3", deprecateAt)) got, err := store.Get(ctx, "v1.2.3") require.NoError(t, err) assert.Equal(t, engineversion.StatusDeprecated, got.Status) assert.True(t, got.UpdatedAt.Equal(deprecateAt)) } func TestDeprecateIdempotent(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusDeprecated))) require.NoError(t, store.Deprecate(ctx, "v1.2.3", now.Add(time.Hour))) got, err := store.Get(ctx, "v1.2.3") require.NoError(t, err) assert.Equal(t, engineversion.StatusDeprecated, got.Status) // updated_at must remain at the original insert value because the // idempotent path performs no UPDATE. assert.True(t, got.UpdatedAt.Equal(now)) } func TestDeprecateNotFound(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.Deprecate(ctx, "v9.9.9", time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)) require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) } func TestDeprecateRejectsZeroNow(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.Deprecate(ctx, "v1.2.3", time.Time{}) require.Error(t, err) } func TestDeleteHappy(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive))) require.NoError(t, store.Delete(ctx, "v1.2.3")) _, err := store.Get(ctx, "v1.2.3") require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) } func TestDeleteNotFound(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.Delete(ctx, "v9.9.9") require.Error(t, err) require.True(t, errors.Is(err, engineversion.ErrNotFound)) } func TestDeleteRejectsEmptyVersion(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.Delete(ctx, "") require.Error(t, err) } // TestIsReferencedByActiveRuntime exercises the join between // engine_versions and runtime_records. The runtime rows are seeded by // inserting directly through the shared pool, since the // runtimerecordstore adapter lives in a sibling package. func TestIsReferencedByActiveRuntime(t *testing.T) { ctx := context.Background() pool := poolOnly(t) store, err := engineversionstore.New(engineversionstore.Config{ DB: pool, OperationTimeout: pgtest.OperationTimeout, }) require.NoError(t, err) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive))) require.NoError(t, store.Insert(ctx, validVersion("v1.2.4", now, engineversion.StatusActive))) insertRuntime(t, pool, "game-running", runtime.StatusRunning, "v1.2.3", now) insertRuntime(t, pool, "game-finished", runtime.StatusFinished, "v1.2.3", now) insertRuntime(t, pool, "game-stopped", runtime.StatusStopped, "v1.2.3", now) used, err := store.IsReferencedByActiveRuntime(ctx, "v1.2.3") require.NoError(t, err) assert.True(t, used, "v1.2.3 must be reported referenced (game-running uses it)") unused, err := store.IsReferencedByActiveRuntime(ctx, "v1.2.4") require.NoError(t, err) assert.False(t, unused, "v1.2.4 has no active runtime reference") missing, err := store.IsReferencedByActiveRuntime(ctx, "v9.9.9") require.NoError(t, err) assert.False(t, missing) } // insertRuntime seeds one runtime_records row directly via raw SQL. The // adapter under test is engineversionstore; using the runtimerecordstore // here would couple two adapter test suites unnecessarily. func insertRuntime(t *testing.T, pool *sql.DB, gameID string, status runtime.Status, engineVersion string, createdAt time.Time) { t.Helper() at := createdAt.UTC() var stoppedAt, finishedAt any switch status { case runtime.StatusStopped: stoppedAt = at case runtime.StatusFinished: finishedAt = at } const stmt = ` INSERT INTO runtime_records ( game_id, status, engine_endpoint, current_image_ref, current_engine_version, turn_schedule, current_turn, next_generation_at, skip_next_tick, engine_health, created_at, updated_at, started_at, stopped_at, finished_at ) VALUES ( $1, $2, 'http://galaxy-game-' || $1 || ':8080', 'ghcr.io/galaxy/game:' || $3, $3, '0 18 * * *', 0, NULL, false, '', $4, $5, $6, $7, $8 )` _, err := pool.ExecContext(context.Background(), stmt, gameID, string(status), engineVersion, at, at, at, stoppedAt, finishedAt, ) require.NoError(t, err) } func TestIsReferencedRejectsEmptyVersion(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.IsReferencedByActiveRuntime(ctx, "") require.Error(t, err) } func TestGetRejectsEmpty(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.Get(ctx, "") require.Error(t, err) } func TestUpdateRejectsInvalidInput(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.Update(ctx, ports.UpdateEngineVersionInput{Version: "v1.2.3"}) require.Error(t, err) }