feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,36 @@
package engineversion
// Stable error codes returned alongside service-level errors. The values
// match the vocabulary frozen by `gamemaster/README.md §Error Model` and
// `gamemaster/api/internal-openapi.yaml`. The handler layer (Stage 19)
// maps the wrapped sentinel error to one of these codes; tests compare
// against the constant.
const (
// ErrorCodeInvalidRequest reports that the request envelope failed
// structural validation (empty required fields, malformed JSON
// options, malformed semver, malformed Docker reference, partial
// Update with no fields set, unsupported status enum).
ErrorCodeInvalidRequest = "invalid_request"
// ErrorCodeConflict reports that an Insert was rejected because a
// row with the same `version` already exists.
ErrorCodeConflict = "conflict"
// ErrorCodeEngineVersionNotFound reports that the requested
// version is not present in the registry. Returned by Get,
// Update, Deprecate, Delete, and ResolveImageRef.
ErrorCodeEngineVersionNotFound = "engine_version_not_found"
// ErrorCodeEngineVersionInUse reports that a hard-delete attempt
// was rejected because the version is still referenced by a
// non-finished `runtime_records` row.
ErrorCodeEngineVersionInUse = "engine_version_in_use"
// ErrorCodeServiceUnavailable reports that a steady-state
// dependency (PostgreSQL) was unreachable for this call.
ErrorCodeServiceUnavailable = "service_unavailable"
// ErrorCodeInternal reports an unexpected error not classified by
// the other codes.
ErrorCodeInternal = "internal_error"
)
@@ -0,0 +1,752 @@
// Package engineversion implements the engine version registry service
// owned by Game Master. The service backs the
// `/api/v1/internal/engine-versions/*` REST surface (Stage 19) and the
// hot-path `image_ref` resolve called synchronously by Game Lobby's
// start flow.
//
// Responsibilities and stable error codes are frozen by
// `gamemaster/README.md §Engine Version Registry` and
// `gamemaster/api/internal-openapi.yaml`. Design rationale for stage 14
// is captured in `gamemaster/docs/stage14-engine-version-registry.md`.
package engineversion
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/logging"
"galaxy/gamemaster/internal/ports"
"github.com/distribution/reference"
)
// Sentinel errors returned by the service. Handlers translate these
// into the stable `ErrorCode...` constants from `errors.go`. The
// adapter-level sentinels (`engineversion.ErrNotFound`,
// `engineversion.ErrConflict`, `engineversion.ErrInUse`,
// `engineversion.ErrInvalidSemver`) are wrapped with one of the
// service-level sentinels below before crossing the package boundary.
var (
// ErrInvalidRequest reports that the input envelope failed
// structural validation.
ErrInvalidRequest = errors.New("invalid request")
// ErrNotFound reports that the requested version does not exist
// in the registry.
ErrNotFound = errors.New("engine version not found")
// ErrConflict reports that an Insert was rejected because a row
// with the same version already exists.
ErrConflict = errors.New("engine version already exists")
// ErrInUse reports that a hard-delete attempt was rejected
// because a non-finished runtime references the version.
ErrInUse = errors.New("engine version in use")
// ErrServiceUnavailable reports that a steady-state dependency
// was unreachable for this call.
ErrServiceUnavailable = errors.New("service unavailable")
)
// CreateInput stores the per-call arguments for one Create operation.
// Mirrors `CreateEngineVersionRequest` plus the audit-only OpSource /
// SourceRef pair.
type CreateInput struct {
// Version stores the canonical semver (with or without the leading
// "v"; ParseSemver normalises it).
Version string
// ImageRef stores the Docker reference of the engine image.
// Validated against `github.com/distribution/reference` before
// the row is persisted.
ImageRef string
// Options stores the engine-side options document as raw JSON.
// Empty means "use the schema default `{}`". When non-empty the
// service validates the bytes parse as a JSON object.
Options []byte
// OpSource classifies how the request entered Game Master.
// Defaults to `admin_rest` when missing or unknown.
OpSource operation.OpSource
// SourceRef stores the optional opaque per-source reference.
SourceRef string
}
// UpdateInput stores the per-call arguments for one Update operation.
// Pointer fields communicate "leave alone" (nil) vs. "write the value"
// (non-nil); at least one must be set.
type UpdateInput struct {
// Version identifies the row to mutate.
Version string
// ImageRef is the new image reference. Nil leaves the column
// unchanged; non-nil must be a valid Docker reference.
ImageRef *string
// Options is the new options document. Nil leaves the column
// unchanged; non-nil must be a JSON object (possibly the empty
// object).
Options *[]byte
// Status is the new registry status. Nil leaves the column
// unchanged; non-nil must be a known status value.
Status *engineversion.Status
// OpSource classifies how the request entered Game Master.
OpSource operation.OpSource
// SourceRef stores the optional opaque per-source reference.
SourceRef string
}
// DeprecateInput stores the per-call arguments for one Deprecate
// operation.
type DeprecateInput struct {
// Version identifies the row to deprecate.
Version string
// OpSource classifies how the request entered Game Master.
OpSource operation.OpSource
// SourceRef stores the optional opaque per-source reference.
SourceRef string
}
// DeleteInput stores the per-call arguments for one hard Delete
// operation.
type DeleteInput struct {
// Version identifies the row to delete.
Version string
// OpSource classifies how the request entered Game Master.
OpSource operation.OpSource
// SourceRef stores the optional opaque per-source reference.
SourceRef string
}
// Dependencies groups the collaborators required by Service.
type Dependencies struct {
// EngineVersions persists the registry rows. Required.
EngineVersions ports.EngineVersionStore
// OperationLogs records the audit entry for every mutation
// (Create, Update, Deprecate, Delete). Required.
OperationLogs ports.OperationLogStore
// Logger records structured service-level events. Defaults to
// slog.Default when nil.
Logger *slog.Logger
// Clock supplies the wall-clock used for created_at / updated_at
// and audit timestamps. Defaults to time.Now when nil.
Clock func() time.Time
}
// Service implements the engine version registry operations.
type Service struct {
versions ports.EngineVersionStore
operationLogs ports.OperationLogStore
logger *slog.Logger
clock func() time.Time
}
// NewService constructs one Service from deps.
func NewService(deps Dependencies) (*Service, error) {
switch {
case deps.EngineVersions == nil:
return nil, errors.New("new engine version service: nil engine version store")
case deps.OperationLogs == nil:
return nil, errors.New("new engine version service: nil operation log store")
}
clock := deps.Clock
if clock == nil {
clock = time.Now
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
logger = logger.With("service", "gamemaster.engineversion")
return &Service{
versions: deps.EngineVersions,
operationLogs: deps.OperationLogs,
logger: logger,
clock: clock,
}, nil
}
// List returns every registry row, optionally filtered by status. A
// non-nil statusFilter must reference a known engineversion.Status.
func (service *Service) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
if service == nil {
return nil, errors.New("engine version list: nil service")
}
if ctx == nil {
return nil, errors.New("engine version list: nil context")
}
if statusFilter != nil && !statusFilter.IsKnown() {
return nil, fmt.Errorf("%w: status %q is unsupported", ErrInvalidRequest, *statusFilter)
}
versions, err := service.versions.List(ctx, statusFilter)
if err != nil {
return nil, fmt.Errorf("%w: list engine versions: %s", ErrServiceUnavailable, err.Error())
}
return versions, nil
}
// Get returns the registry row identified by version. Returns
// ErrNotFound when no row matches.
func (service *Service) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
if service == nil {
return engineversion.EngineVersion{}, errors.New("engine version get: nil service")
}
if ctx == nil {
return engineversion.EngineVersion{}, errors.New("engine version get: nil context")
}
if strings.TrimSpace(version) == "" {
return engineversion.EngineVersion{}, fmt.Errorf("%w: version must not be empty", ErrInvalidRequest)
}
got, err := service.versions.Get(ctx, version)
switch {
case errors.Is(err, engineversion.ErrNotFound):
return engineversion.EngineVersion{}, fmt.Errorf("%w: %q", ErrNotFound, version)
case err != nil:
return engineversion.EngineVersion{}, fmt.Errorf("%w: get engine version: %s", ErrServiceUnavailable, err.Error())
}
return got, nil
}
// ResolveImageRef returns the image_ref of the requested version. This
// is the hot path used by Game Lobby's start flow synchronously per
// register-runtime envelope.
func (service *Service) ResolveImageRef(ctx context.Context, version string) (string, error) {
got, err := service.Get(ctx, version)
if err != nil {
return "", err
}
return got.ImageRef, nil
}
// Create installs a fresh registry row. Validates the semver shape and
// Docker reference before touching the store. On success appends a
// success entry to operation_log; on classified failure (validation,
// conflict, store error) appends a failure entry.
func (service *Service) Create(ctx context.Context, input CreateInput) (engineversion.EngineVersion, error) {
if service == nil {
return engineversion.EngineVersion{}, errors.New("engine version create: nil service")
}
if ctx == nil {
return engineversion.EngineVersion{}, errors.New("engine version create: nil context")
}
startedAt := service.clock().UTC()
canonicalVersion, err := engineversion.ParseSemver(input.Version)
if err != nil {
return engineversion.EngineVersion{}, service.recordCreateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, fmt.Sprintf("parse semver: %s", err.Error()),
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
)
}
if err := validateImageRef(input.ImageRef); err != nil {
return engineversion.EngineVersion{}, service.recordCreateFailure(
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, fmt.Sprintf("validate image_ref: %s", err.Error()),
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
)
}
options, err := normalizeOptions(input.Options)
if err != nil {
return engineversion.EngineVersion{}, service.recordCreateFailure(
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, fmt.Sprintf("validate options: %s", err.Error()),
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
)
}
record := engineversion.EngineVersion{
Version: canonicalVersion,
ImageRef: strings.TrimSpace(input.ImageRef),
Options: options,
Status: engineversion.StatusActive,
CreatedAt: startedAt,
UpdatedAt: startedAt,
}
if err := service.versions.Insert(ctx, record); err != nil {
switch {
case errors.Is(err, engineversion.ErrConflict):
return engineversion.EngineVersion{}, service.recordCreateFailure(
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
ErrorCodeConflict, "engine version already exists",
fmt.Errorf("%w: %s", ErrConflict, canonicalVersion),
)
default:
return engineversion.EngineVersion{}, service.recordCreateFailure(
ctx, startedAt, canonicalVersion, input.OpSource, input.SourceRef,
ErrorCodeServiceUnavailable, fmt.Sprintf("insert engine version: %s", err.Error()),
fmt.Errorf("%w: insert engine version: %s", ErrServiceUnavailable, err.Error()),
)
}
}
service.appendSuccess(ctx, operation.OpKindEngineVersionCreate, canonicalVersion, input.OpSource, input.SourceRef, startedAt)
logArgs := []any{
"version", canonicalVersion,
"image_ref", record.ImageRef,
"op_source", string(fallbackOpSource(input.OpSource)),
}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "engine version created", logArgs...)
return record, nil
}
// Update applies a partial update to one registry row. At least one of
// ImageRef, Options, Status must be non-nil.
func (service *Service) Update(ctx context.Context, input UpdateInput) (engineversion.EngineVersion, error) {
if service == nil {
return engineversion.EngineVersion{}, errors.New("engine version update: nil service")
}
if ctx == nil {
return engineversion.EngineVersion{}, errors.New("engine version update: nil context")
}
startedAt := service.clock().UTC()
if strings.TrimSpace(input.Version) == "" {
return engineversion.EngineVersion{}, service.recordUpdateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, "version must not be empty",
fmt.Errorf("%w: version must not be empty", ErrInvalidRequest),
)
}
if input.ImageRef == nil && input.Options == nil && input.Status == nil {
return engineversion.EngineVersion{}, service.recordUpdateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, "at least one field must be set",
fmt.Errorf("%w: at least one field must be set", ErrInvalidRequest),
)
}
if input.ImageRef != nil {
if err := validateImageRef(*input.ImageRef); err != nil {
return engineversion.EngineVersion{}, service.recordUpdateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, fmt.Sprintf("validate image_ref: %s", err.Error()),
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
)
}
}
if input.Status != nil && !input.Status.IsKnown() {
return engineversion.EngineVersion{}, service.recordUpdateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, fmt.Sprintf("status %q is unsupported", *input.Status),
fmt.Errorf("%w: status %q is unsupported", ErrInvalidRequest, *input.Status),
)
}
var normalizedOptions *[]byte
if input.Options != nil {
opts, err := normalizeOptions(*input.Options)
if err != nil {
return engineversion.EngineVersion{}, service.recordUpdateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, fmt.Sprintf("validate options: %s", err.Error()),
fmt.Errorf("%w: %s", ErrInvalidRequest, err.Error()),
)
}
normalizedOptions = &opts
}
storeInput := ports.UpdateEngineVersionInput{
Version: input.Version,
Options: normalizedOptions,
Status: input.Status,
Now: startedAt,
}
if input.ImageRef != nil {
trimmed := strings.TrimSpace(*input.ImageRef)
storeInput.ImageRef = &trimmed
}
if err := service.versions.Update(ctx, storeInput); err != nil {
switch {
case errors.Is(err, engineversion.ErrNotFound):
return engineversion.EngineVersion{}, service.recordUpdateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeEngineVersionNotFound, fmt.Sprintf("engine version %q not found", input.Version),
fmt.Errorf("%w: %q", ErrNotFound, input.Version),
)
default:
return engineversion.EngineVersion{}, service.recordUpdateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeServiceUnavailable, fmt.Sprintf("update engine version: %s", err.Error()),
fmt.Errorf("%w: update engine version: %s", ErrServiceUnavailable, err.Error()),
)
}
}
persisted, err := service.versions.Get(ctx, input.Version)
if err != nil {
// The Update succeeded but the post-read failed. Surface the
// store error; the audit entry still records the successful
// mutation against operation_log.
service.appendSuccess(ctx, operation.OpKindEngineVersionUpdate, input.Version, input.OpSource, input.SourceRef, startedAt)
return engineversion.EngineVersion{}, fmt.Errorf("%w: reload engine version: %s", ErrServiceUnavailable, err.Error())
}
service.appendSuccess(ctx, operation.OpKindEngineVersionUpdate, input.Version, input.OpSource, input.SourceRef, startedAt)
logArgs := []any{
"version", input.Version,
"op_source", string(fallbackOpSource(input.OpSource)),
}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "engine version updated", logArgs...)
return persisted, nil
}
// Deprecate marks one registry row as deprecated. Idempotent: the call
// succeeds even when the row is already deprecated. Returns ErrNotFound
// when no row matches.
func (service *Service) Deprecate(ctx context.Context, input DeprecateInput) error {
if service == nil {
return errors.New("engine version deprecate: nil service")
}
if ctx == nil {
return errors.New("engine version deprecate: nil context")
}
startedAt := service.clock().UTC()
if strings.TrimSpace(input.Version) == "" {
return service.recordDeprecateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, "version must not be empty",
fmt.Errorf("%w: version must not be empty", ErrInvalidRequest),
)
}
if err := service.versions.Deprecate(ctx, input.Version, startedAt); err != nil {
switch {
case errors.Is(err, engineversion.ErrNotFound):
return service.recordDeprecateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeEngineVersionNotFound, fmt.Sprintf("engine version %q not found", input.Version),
fmt.Errorf("%w: %q", ErrNotFound, input.Version),
)
default:
return service.recordDeprecateFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeServiceUnavailable, fmt.Sprintf("deprecate engine version: %s", err.Error()),
fmt.Errorf("%w: deprecate engine version: %s", ErrServiceUnavailable, err.Error()),
)
}
}
service.appendSuccess(ctx, operation.OpKindEngineVersionDeprecate, input.Version, input.OpSource, input.SourceRef, startedAt)
logArgs := []any{
"version", input.Version,
"op_source", string(fallbackOpSource(input.OpSource)),
}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "engine version deprecated", logArgs...)
return nil
}
// Delete hard-deletes one registry row. Rejected with ErrInUse when any
// non-finished runtime still references the version. The reference
// probe runs first so the conflict is surfaced before the row is
// removed.
func (service *Service) Delete(ctx context.Context, input DeleteInput) error {
if service == nil {
return errors.New("engine version delete: nil service")
}
if ctx == nil {
return errors.New("engine version delete: nil context")
}
startedAt := service.clock().UTC()
if strings.TrimSpace(input.Version) == "" {
return service.recordDeleteFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeInvalidRequest, "version must not be empty",
fmt.Errorf("%w: version must not be empty", ErrInvalidRequest),
)
}
referenced, err := service.versions.IsReferencedByActiveRuntime(ctx, input.Version)
if err != nil {
return service.recordDeleteFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeServiceUnavailable, fmt.Sprintf("is referenced by active runtime: %s", err.Error()),
fmt.Errorf("%w: is referenced by active runtime: %s", ErrServiceUnavailable, err.Error()),
)
}
if referenced {
return service.recordDeleteFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeEngineVersionInUse, fmt.Sprintf("engine version %q is referenced by an active runtime", input.Version),
fmt.Errorf("%w: %q", ErrInUse, input.Version),
)
}
if err := service.versions.Delete(ctx, input.Version); err != nil {
switch {
case errors.Is(err, engineversion.ErrNotFound):
return service.recordDeleteFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeEngineVersionNotFound, fmt.Sprintf("engine version %q not found", input.Version),
fmt.Errorf("%w: %q", ErrNotFound, input.Version),
)
default:
return service.recordDeleteFailure(
ctx, startedAt, input.Version, input.OpSource, input.SourceRef,
ErrorCodeServiceUnavailable, fmt.Sprintf("delete engine version: %s", err.Error()),
fmt.Errorf("%w: delete engine version: %s", ErrServiceUnavailable, err.Error()),
)
}
}
service.appendSuccess(ctx, operation.OpKindEngineVersionDelete, input.Version, input.OpSource, input.SourceRef, startedAt)
logArgs := []any{
"version", input.Version,
"op_source", string(fallbackOpSource(input.OpSource)),
}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "engine version deleted", logArgs...)
return nil
}
// validateImageRef enforces the Docker reference shape required by
// `engine_versions.image_ref`: non-empty trimmed, parseable through
// `distribution/reference.ParseNormalizedNamed`. The check is the same
// one Runtime Manager applies in startruntime so the registry never
// stores a value the runtime cannot pull.
func validateImageRef(imageRef string) error {
trimmed := strings.TrimSpace(imageRef)
if trimmed == "" {
return fmt.Errorf("image_ref must not be empty")
}
if _, err := reference.ParseNormalizedNamed(trimmed); err != nil {
return fmt.Errorf("parse image reference %q: %w", trimmed, err)
}
return nil
}
// normalizeOptions validates that raw is a JSON document encoding a
// single object. Empty input is treated as `{}` and stored verbatim by
// the adapter (see stage 11 D5).
func normalizeOptions(raw []byte) ([]byte, error) {
trimmed := bytesTrim(raw)
if len(trimmed) == 0 {
return nil, nil
}
var probe map[string]any
if err := json.Unmarshal(trimmed, &probe); err != nil {
return nil, fmt.Errorf("options must be a JSON object: %w", err)
}
return trimmed, nil
}
// bytesTrim returns raw with surrounding ASCII whitespace removed. The
// helper avoids the round-trip through `string` for raw JSON inputs.
func bytesTrim(raw []byte) []byte {
start, end := 0, len(raw)
for start < end && isASCIISpace(raw[start]) {
start++
}
for end > start && isASCIISpace(raw[end-1]) {
end--
}
return raw[start:end]
}
func isASCIISpace(b byte) bool {
switch b {
case ' ', '\t', '\n', '\r':
return true
default:
return false
}
}
// recordCreateFailure appends an audit failure entry for a Create call
// and returns the original sentinel error wrapped with the failure
// reason. The audit entry is written best-effort; storage failures are
// logged and discarded.
func (service *Service) recordCreateFailure(
ctx context.Context,
startedAt time.Time,
subject string,
source operation.OpSource,
sourceRef string,
errorCode string,
errorMessage string,
wrappedErr error,
) error {
service.appendFailure(ctx, operation.OpKindEngineVersionCreate, subject, source, sourceRef, startedAt, errorCode, errorMessage)
service.logFailure(ctx, "engine version create failed", subject, source, errorCode, errorMessage)
return wrappedErr
}
func (service *Service) recordUpdateFailure(
ctx context.Context,
startedAt time.Time,
subject string,
source operation.OpSource,
sourceRef string,
errorCode string,
errorMessage string,
wrappedErr error,
) error {
service.appendFailure(ctx, operation.OpKindEngineVersionUpdate, subject, source, sourceRef, startedAt, errorCode, errorMessage)
service.logFailure(ctx, "engine version update failed", subject, source, errorCode, errorMessage)
return wrappedErr
}
func (service *Service) recordDeprecateFailure(
ctx context.Context,
startedAt time.Time,
subject string,
source operation.OpSource,
sourceRef string,
errorCode string,
errorMessage string,
wrappedErr error,
) error {
service.appendFailure(ctx, operation.OpKindEngineVersionDeprecate, subject, source, sourceRef, startedAt, errorCode, errorMessage)
service.logFailure(ctx, "engine version deprecate failed", subject, source, errorCode, errorMessage)
return wrappedErr
}
func (service *Service) recordDeleteFailure(
ctx context.Context,
startedAt time.Time,
subject string,
source operation.OpSource,
sourceRef string,
errorCode string,
errorMessage string,
wrappedErr error,
) error {
service.appendFailure(ctx, operation.OpKindEngineVersionDelete, subject, source, sourceRef, startedAt, errorCode, errorMessage)
service.logFailure(ctx, "engine version delete failed", subject, source, errorCode, errorMessage)
return wrappedErr
}
// appendSuccess writes a success entry to operation_log. Subject is the
// canonical version string; the entry's GameID column doubles as the
// audit subject for engine-version operations (stage 14 decision —
// the registry is global, not per-game).
func (service *Service) appendSuccess(
ctx context.Context,
kind operation.OpKind,
subject string,
source operation.OpSource,
sourceRef string,
startedAt time.Time,
) {
finishedAt := service.clock().UTC()
service.bestEffortAppend(ctx, operation.OperationEntry{
GameID: subject,
OpKind: kind,
OpSource: fallbackOpSource(source),
SourceRef: sourceRef,
Outcome: operation.OutcomeSuccess,
StartedAt: startedAt,
FinishedAt: &finishedAt,
})
}
// appendFailure writes a failure entry to operation_log. Subject and
// the GameID column overload follow the same rule as appendSuccess.
func (service *Service) appendFailure(
ctx context.Context,
kind operation.OpKind,
subject string,
source operation.OpSource,
sourceRef string,
startedAt time.Time,
errorCode string,
errorMessage string,
) {
finishedAt := service.clock().UTC()
service.bestEffortAppend(ctx, operation.OperationEntry{
GameID: subject,
OpKind: kind,
OpSource: fallbackOpSource(source),
SourceRef: sourceRef,
Outcome: operation.OutcomeFailure,
ErrorCode: errorCode,
ErrorMessage: errorMessage,
StartedAt: startedAt,
FinishedAt: &finishedAt,
})
}
// bestEffortAppend writes one operation_log entry. A failure is logged
// and discarded; the registry mutation (or its absence) remains the
// source of truth.
func (service *Service) bestEffortAppend(ctx context.Context, entry operation.OperationEntry) {
if _, err := service.operationLogs.Append(ctx, entry); err != nil {
service.logger.ErrorContext(ctx, "append operation log",
"subject", entry.GameID,
"op_kind", string(entry.OpKind),
"outcome", string(entry.Outcome),
"error_code", entry.ErrorCode,
"err", err.Error(),
)
}
}
// logFailure emits one structured warn-level entry per service-level
// failure, mirroring registerruntime's log shape.
func (service *Service) logFailure(
ctx context.Context,
message string,
subject string,
source operation.OpSource,
errorCode string,
errorMessage string,
) {
logArgs := []any{
"version", subject,
"op_source", string(fallbackOpSource(source)),
"error_code", errorCode,
"error_message", errorMessage,
}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.WarnContext(ctx, message, logArgs...)
}
// fallbackOpSource defaults to admin_rest when source is missing or
// unrecognised. Mirrors `gamemaster/README.md §Trusted Surfaces`.
func fallbackOpSource(source operation.OpSource) operation.OpSource {
if source.IsKnown() {
return source
}
return operation.OpSourceAdminRest
}
@@ -0,0 +1,631 @@
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)
}