feat: gamemaster
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
// Package engineversionstore implements the PostgreSQL-backed adapter
|
||||
// for `ports.EngineVersionStore`.
|
||||
//
|
||||
// The package owns the on-disk shape of the `engine_versions` table
|
||||
// defined in
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
|
||||
// and translates the schema-agnostic `ports.EngineVersionStore`
|
||||
// interface declared in `internal/ports/engineversionstore.go` into
|
||||
// concrete go-jet/v2 statements driven by the pgx driver.
|
||||
//
|
||||
// Insert maps PostgreSQL unique violations to engineversion.ErrConflict;
|
||||
// Update applies a partial UPDATE driven by the non-nil pointer fields
|
||||
// of UpdateEngineVersionInput; Deprecate is idempotent on the
|
||||
// already-deprecated row; IsReferencedByActiveRuntime probes the
|
||||
// runtime_records table for non-finished references.
|
||||
package engineversionstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
|
||||
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// emptyOptionsJSON is the default value persisted when a caller hands
|
||||
// us an empty Options slice. It matches the SQL column default.
|
||||
var emptyOptionsJSON = []byte("{}")
|
||||
|
||||
// Config configures one PostgreSQL-backed engine-version store. The
|
||||
// store does not own the underlying *sql.DB lifecycle.
|
||||
type Config struct {
|
||||
DB *sql.DB
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Master engine-version registry rows in
|
||||
// PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed engine-version store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres engine version store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres engine version store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// engineVersionSelectColumns matches scanRow's column order.
|
||||
var engineVersionSelectColumns = pg.ColumnList{
|
||||
pgtable.EngineVersions.Version,
|
||||
pgtable.EngineVersions.ImageRef,
|
||||
pgtable.EngineVersions.Options,
|
||||
pgtable.EngineVersions.Status,
|
||||
pgtable.EngineVersions.CreatedAt,
|
||||
pgtable.EngineVersions.UpdatedAt,
|
||||
}
|
||||
|
||||
// Get returns the row identified by version. Returns
|
||||
// engineversion.ErrNotFound when no row exists.
|
||||
func (store *Store) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return engineversion.EngineVersion{}, errors.New("get engine version: nil store")
|
||||
}
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return engineversion.EngineVersion{}, fmt.Errorf("get engine version: version must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get engine version", store.operationTimeout)
|
||||
if err != nil {
|
||||
return engineversion.EngineVersion{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(engineVersionSelectColumns).
|
||||
FROM(pgtable.EngineVersions).
|
||||
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
got, err := scanRow(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return engineversion.EngineVersion{}, engineversion.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return engineversion.EngineVersion{}, fmt.Errorf("get engine version: %w", err)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// List returns every row whose status matches statusFilter (when
|
||||
// non-nil), ordered by version ASC.
|
||||
func (store *Store) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list engine versions: nil store")
|
||||
}
|
||||
if statusFilter != nil && !statusFilter.IsKnown() {
|
||||
return nil, fmt.Errorf("list engine versions: status %q is unsupported", *statusFilter)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list engine versions", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(engineVersionSelectColumns).
|
||||
FROM(pgtable.EngineVersions)
|
||||
if statusFilter != nil {
|
||||
stmt = stmt.WHERE(pgtable.EngineVersions.Status.EQ(pg.String(string(*statusFilter))))
|
||||
}
|
||||
stmt = stmt.ORDER_BY(pgtable.EngineVersions.Version.ASC())
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list engine versions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
versions := make([]engineversion.EngineVersion, 0)
|
||||
for rows.Next() {
|
||||
got, err := scanRow(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list engine versions: scan: %w", err)
|
||||
}
|
||||
versions = append(versions, got)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list engine versions: %w", err)
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// Insert installs record into the registry. Returns
|
||||
// engineversion.ErrConflict when a row with the same version already
|
||||
// exists.
|
||||
func (store *Store) Insert(ctx context.Context, record engineversion.EngineVersion) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("insert engine version: nil store")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("insert engine version: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "insert engine version", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
options := record.Options
|
||||
if len(options) == 0 {
|
||||
options = emptyOptionsJSON
|
||||
}
|
||||
|
||||
stmt := pgtable.EngineVersions.INSERT(
|
||||
pgtable.EngineVersions.Version,
|
||||
pgtable.EngineVersions.ImageRef,
|
||||
pgtable.EngineVersions.Options,
|
||||
pgtable.EngineVersions.Status,
|
||||
pgtable.EngineVersions.CreatedAt,
|
||||
pgtable.EngineVersions.UpdatedAt,
|
||||
).VALUES(
|
||||
record.Version,
|
||||
record.ImageRef,
|
||||
string(options),
|
||||
string(record.Status),
|
||||
record.CreatedAt.UTC(),
|
||||
record.UpdatedAt.UTC(),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
if sqlx.IsUniqueViolation(err) {
|
||||
return fmt.Errorf("insert engine version: %w", engineversion.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("insert engine version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update applies a partial update to one engine-version row.
|
||||
// updated_at is always refreshed from input.Now. Returns
|
||||
// engineversion.ErrNotFound when the row is absent.
|
||||
func (store *Store) Update(ctx context.Context, input ports.UpdateEngineVersionInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update engine version: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update engine version", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
now := input.Now.UTC()
|
||||
assignments := []any{
|
||||
pgtable.EngineVersions.UpdatedAt.SET(pg.TimestampzT(now)),
|
||||
}
|
||||
if input.ImageRef != nil {
|
||||
assignments = append(assignments,
|
||||
pgtable.EngineVersions.ImageRef.SET(pg.String(*input.ImageRef)))
|
||||
}
|
||||
if input.Options != nil {
|
||||
options := *input.Options
|
||||
if len(options) == 0 {
|
||||
options = emptyOptionsJSON
|
||||
}
|
||||
assignments = append(assignments,
|
||||
pgtable.EngineVersions.Options.SET(
|
||||
pg.StringExp(pg.CAST(pg.String(string(options))).AS("jsonb")),
|
||||
))
|
||||
}
|
||||
if input.Status != nil {
|
||||
assignments = append(assignments,
|
||||
pgtable.EngineVersions.Status.SET(pg.String(string(*input.Status))))
|
||||
}
|
||||
|
||||
stmt := pgtable.EngineVersions.UPDATE(pgtable.EngineVersions.UpdatedAt).
|
||||
SET(assignments[0], assignments[1:]...).
|
||||
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(input.Version)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update engine version: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update engine version: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return engineversion.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecate sets `status=deprecated` and refreshes `updated_at` for
|
||||
// version. Returns engineversion.ErrNotFound when no row exists.
|
||||
// Calling Deprecate on an already deprecated row succeeds with no
|
||||
// further mutation (idempotent).
|
||||
func (store *Store) Deprecate(ctx context.Context, version string, now time.Time) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("deprecate engine version: nil store")
|
||||
}
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return fmt.Errorf("deprecate engine version: version must not be empty")
|
||||
}
|
||||
if now.IsZero() {
|
||||
return fmt.Errorf("deprecate engine version: now must not be zero")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "deprecate engine version", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
// Pre-check the row's existence so we can surface a precise
|
||||
// ErrNotFound; a 0-row affected from the UPDATE alone could mean
|
||||
// "missing" or "already deprecated".
|
||||
current, err := store.Get(operationCtx, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Status == engineversion.StatusDeprecated {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt := pgtable.EngineVersions.UPDATE(pgtable.EngineVersions.Status).
|
||||
SET(
|
||||
pgtable.EngineVersions.Status.SET(pg.String(string(engineversion.StatusDeprecated))),
|
||||
pgtable.EngineVersions.UpdatedAt.SET(pg.TimestampzT(now.UTC())),
|
||||
).
|
||||
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
return fmt.Errorf("deprecate engine version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the row identified by version. Returns
|
||||
// engineversion.ErrNotFound when no row matches. The adapter does not
|
||||
// inspect runtime_records; the service layer guards against active
|
||||
// references through IsReferencedByActiveRuntime before issuing Delete.
|
||||
func (store *Store) Delete(ctx context.Context, version string) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("delete engine version: nil store")
|
||||
}
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return fmt.Errorf("delete engine version: version must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete engine version", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.EngineVersions.DELETE().
|
||||
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete engine version: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete engine version: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return engineversion.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsReferencedByActiveRuntime reports whether any non-finished and
|
||||
// non-stopped runtime row currently references version through
|
||||
// `current_engine_version`.
|
||||
func (store *Store) IsReferencedByActiveRuntime(ctx context.Context, version string) (bool, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return false, errors.New("is referenced by active runtime: nil store")
|
||||
}
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return false, fmt.Errorf("is referenced by active runtime: version must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "is referenced by active runtime", store.operationTimeout)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(pg.Int32(1).AS("present")).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pg.AND(
|
||||
pgtable.RuntimeRecords.CurrentEngineVersion.EQ(pg.String(version)),
|
||||
pgtable.RuntimeRecords.Status.NOT_IN(
|
||||
pg.String(string(runtime.StatusFinished)),
|
||||
pg.String(string(runtime.StatusStopped)),
|
||||
),
|
||||
)).
|
||||
LIMIT(1)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
var present int32
|
||||
if err := row.Scan(&present); err != nil {
|
||||
if sqlx.IsNoRows(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("is referenced by active runtime: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared
|
||||
// across single-row and iterated reads.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanRow scans one engine_versions row from rs.
|
||||
func scanRow(rs rowScanner) (engineversion.EngineVersion, error) {
|
||||
var (
|
||||
version string
|
||||
imageRef string
|
||||
options string
|
||||
status string
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
)
|
||||
if err := rs.Scan(&version, &imageRef, &options, &status, &createdAt, &updatedAt); err != nil {
|
||||
return engineversion.EngineVersion{}, err
|
||||
}
|
||||
return engineversion.EngineVersion{
|
||||
Version: version,
|
||||
ImageRef: imageRef,
|
||||
Options: []byte(options),
|
||||
Status: engineversion.Status(status),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.EngineVersionStore interface at
|
||||
// compile time.
|
||||
var _ ports.EngineVersionStore = (*Store)(nil)
|
||||
@@ -0,0 +1,403 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user