404 lines
12 KiB
Go
404 lines
12 KiB
Go
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)
|
|
}
|