Files
galaxy-game/rtmanager/contract_asyncapi_test.go
T
2026-04-28 20:39:18 +02:00

393 lines
12 KiB
Go

package rtmanager
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
var expectedStopReasonEnum = []string{
"orphan_cleanup",
"cancelled",
"finished",
"admin_request",
"timeout",
}
var expectedJobResultErrorCodeEnum = []string{
"",
"invalid_request",
"not_found",
"conflict",
"service_unavailable",
"internal_error",
"image_pull_failed",
"image_ref_not_semver",
"semver_patch_only",
"container_start_failed",
"start_config_invalid",
"docker_unavailable",
"replay_no_op",
}
var expectedHealthEventTypeEnum = []string{
"container_started",
"container_exited",
"container_oom",
"container_disappeared",
"inspect_unhealthy",
"probe_failed",
"probe_recovered",
}
var expectedHealthDetailsBranches = []struct {
schema string
required []string
}{
{schema: "ContainerStartedDetails", required: []string{"image_ref"}},
{schema: "ContainerExitedDetails", required: []string{"exit_code", "oom"}},
{schema: "ContainerOomDetails", required: []string{"exit_code"}},
{schema: "ContainerDisappearedDetails", required: nil},
{schema: "InspectUnhealthyDetails", required: []string{"restart_count", "state", "health"}},
{schema: "ProbeFailedDetails", required: []string{"consecutive_failures", "last_status", "last_error"}},
{schema: "ProbeRecoveredDetails", required: []string{"prior_failure_count"}},
}
func TestRuntimeJobsAsyncAPISpecLoads(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
require.Equal(t, "3.1.0", getStringValue(t, doc, "asyncapi"))
}
func TestRuntimeJobsSpecFreezesChannelAddresses(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
channels := getMapValue(t, doc, "channels")
require.Equal(t, "runtime:start_jobs",
getStringValue(t, getMapValue(t, channels, "startJobs"), "address"))
require.Equal(t, "runtime:stop_jobs",
getStringValue(t, getMapValue(t, channels, "stopJobs"), "address"))
require.Equal(t, "runtime:job_results",
getStringValue(t, getMapValue(t, channels, "jobResults"), "address"))
}
func TestRuntimeJobsSpecFreezesOperationActions(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
operations := getMapValue(t, doc, "operations")
cases := []struct {
operation string
action string
channel string
}{
{operation: "consumeStartJob", action: "receive", channel: "#/channels/startJobs"},
{operation: "consumeStopJob", action: "receive", channel: "#/channels/stopJobs"},
{operation: "publishJobResult", action: "send", channel: "#/channels/jobResults"},
}
for _, tc := range cases {
t.Run(tc.operation, func(t *testing.T) {
t.Parallel()
op := getMapValue(t, operations, tc.operation)
require.Equal(t, tc.action, getStringValue(t, op, "action"))
require.Equal(t, tc.channel,
getStringValue(t, getMapValue(t, op, "channel"), "$ref"))
})
}
}
func TestRuntimeJobsSpecFreezesMessageNames(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
messages := getMapValue(t, doc, "components", "messages")
for _, name := range []string{"RuntimeStartJob", "RuntimeStopJob", "RuntimeJobResult"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
message := getMapValue(t, messages, name)
require.Equal(t, name, getStringValue(t, message, "name"))
})
}
}
func TestRuntimeJobsSpecFreezesStartJobPayload(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
payload := getMapValue(t, doc, "components", "schemas", "RuntimeStartJobPayload")
require.ElementsMatch(t,
[]string{"game_id", "image_ref", "requested_at_ms"},
getStringSlice(t, payload, "required"))
require.False(t, getBoolValue(t, payload, "additionalProperties"),
"RuntimeStartJobPayload must reject unknown fields")
}
func TestRuntimeJobsSpecFreezesStopJobPayload(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
payload := getMapValue(t, doc, "components", "schemas", "RuntimeStopJobPayload")
require.ElementsMatch(t,
[]string{"game_id", "reason", "requested_at_ms"},
getStringSlice(t, payload, "required"))
require.False(t, getBoolValue(t, payload, "additionalProperties"),
"RuntimeStopJobPayload must reject unknown fields")
reason := getMapValue(t, payload, "properties", "reason")
require.Equal(t, "#/components/schemas/StopReason",
getStringValue(t, reason, "$ref"),
"RuntimeStopJobPayload.reason must reference StopReason")
stopReason := getMapValue(t, doc, "components", "schemas", "StopReason")
require.ElementsMatch(t, expectedStopReasonEnum,
getStringSlice(t, stopReason, "enum"))
}
func TestRuntimeJobsSpecFreezesJobResultPayload(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
payload := getMapValue(t, doc, "components", "schemas", "RuntimeJobResultPayload")
require.ElementsMatch(t,
[]string{"game_id", "outcome", "container_id", "engine_endpoint", "error_code", "error_message"},
getStringSlice(t, payload, "required"))
require.False(t, getBoolValue(t, payload, "additionalProperties"),
"RuntimeJobResultPayload must reject unknown fields")
outcome := getMapValue(t, payload, "properties", "outcome")
require.ElementsMatch(t, []string{"success", "failure"},
getStringSlice(t, outcome, "enum"))
errorCode := getMapValue(t, payload, "properties", "error_code")
require.Equal(t, "#/components/schemas/ErrorCode",
getStringValue(t, errorCode, "$ref"),
"RuntimeJobResultPayload.error_code must reference ErrorCode")
errorCodeSchema := getMapValue(t, doc, "components", "schemas", "ErrorCode")
require.ElementsMatch(t, expectedJobResultErrorCodeEnum,
getStringSlice(t, errorCodeSchema, "enum"))
}
func TestRuntimeHealthAsyncAPISpecLoads(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
require.Equal(t, "3.1.0", getStringValue(t, doc, "asyncapi"))
}
func TestRuntimeHealthSpecFreezesChannelAndOperation(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
channel := getMapValue(t, doc, "channels", "healthEvents")
require.Equal(t, "runtime:health_events", getStringValue(t, channel, "address"))
operation := getMapValue(t, doc, "operations", "publishHealthEvent")
require.Equal(t, "send", getStringValue(t, operation, "action"))
require.Equal(t, "#/channels/healthEvents",
getStringValue(t, getMapValue(t, operation, "channel"), "$ref"))
message := getMapValue(t, doc, "components", "messages", "RuntimeHealthEvent")
require.Equal(t, "RuntimeHealthEvent", getStringValue(t, message, "name"))
}
func TestRuntimeHealthSpecFreezesEnvelope(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
payload := getMapValue(t, doc, "components", "schemas", "RuntimeHealthEventPayload")
require.ElementsMatch(t,
[]string{"game_id", "container_id", "event_type", "occurred_at_ms", "details"},
getStringSlice(t, payload, "required"))
require.False(t, getBoolValue(t, payload, "additionalProperties"),
"RuntimeHealthEventPayload must reject unknown fields")
eventType := getMapValue(t, payload, "properties", "event_type")
require.Equal(t, "#/components/schemas/EventType",
getStringValue(t, eventType, "$ref"),
"RuntimeHealthEventPayload.event_type must reference EventType")
}
func TestRuntimeHealthSpecFreezesEventTypeEnum(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
schema := getMapValue(t, doc, "components", "schemas", "EventType")
require.ElementsMatch(t, expectedHealthEventTypeEnum,
getStringSlice(t, schema, "enum"))
}
func TestRuntimeHealthSpecFreezesDetailsOneOfBranches(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
details := getMapValue(t, doc, "components", "schemas", "RuntimeHealthEventPayload",
"properties", "details")
branches := getSliceValue(t, details, "oneOf")
require.Lenf(t, branches, len(expectedHealthDetailsBranches),
"details.oneOf must have %d branches", len(expectedHealthDetailsBranches))
gotRefs := make([]string, 0, len(branches))
for _, raw := range branches {
branch, ok := raw.(map[string]any)
require.True(t, ok, "details.oneOf entry must be a mapping")
gotRefs = append(gotRefs, getStringValue(t, branch, "$ref"))
}
wantRefs := make([]string, 0, len(expectedHealthDetailsBranches))
for _, branch := range expectedHealthDetailsBranches {
wantRefs = append(wantRefs, "#/components/schemas/"+branch.schema)
}
require.ElementsMatch(t, wantRefs, gotRefs)
for _, branch := range expectedHealthDetailsBranches {
t.Run(branch.schema, func(t *testing.T) {
t.Parallel()
schema := getMapValue(t, doc, "components", "schemas", branch.schema)
require.False(t, getBoolValue(t, schema, "additionalProperties"),
"%s must reject unknown fields", branch.schema)
if branch.required == nil {
_, hasRequired := schema["required"]
require.False(t, hasRequired,
"%s must not declare required fields", branch.schema)
return
}
require.ElementsMatch(t, branch.required,
getStringSlice(t, schema, "required"))
})
}
}
func loadAsyncAPISpec(t *testing.T, relativePath string) map[string]any {
t.Helper()
payload := loadTextFile(t, relativePath)
var doc map[string]any
if err := yaml.Unmarshal([]byte(payload), &doc); err != nil {
require.Failf(t, "test failed", "decode spec: %v", err)
}
return doc
}
func loadTextFile(t *testing.T, relativePath string) string {
t.Helper()
path := filepath.Join(moduleRoot(t), relativePath)
payload, err := os.ReadFile(path)
if err != nil {
require.Failf(t, "test failed", "read file %s: %v", path, err)
}
return string(payload)
}
func moduleRoot(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
require.FailNow(t, "runtime.Caller failed")
}
return filepath.Dir(thisFile)
}
func getMapValue(t *testing.T, value map[string]any, path ...string) map[string]any {
t.Helper()
current := value
for _, segment := range path {
raw, ok := current[segment]
if !ok {
require.Failf(t, "test failed", "missing map key %s", segment)
}
next, ok := raw.(map[string]any)
if !ok {
require.Failf(t, "test failed", "value at %s is not a map", segment)
}
current = next
}
return current
}
func getStringValue(t *testing.T, value map[string]any, key string) string {
t.Helper()
raw, ok := value[key]
if !ok {
require.Failf(t, "test failed", "missing key %s", key)
}
result, ok := raw.(string)
if !ok {
require.Failf(t, "test failed", "value at %s is not a string", key)
}
return result
}
func getBoolValue(t *testing.T, value map[string]any, key string) bool {
t.Helper()
raw, ok := value[key]
if !ok {
require.Failf(t, "test failed", "missing key %s", key)
}
result, ok := raw.(bool)
if !ok {
require.Failf(t, "test failed", "value at %s is not a bool", key)
}
return result
}
func getStringSlice(t *testing.T, value map[string]any, key string) []string {
t.Helper()
raw := getSliceValue(t, value, key)
result := make([]string, 0, len(raw))
for _, item := range raw {
text, ok := item.(string)
if !ok {
require.Failf(t, "test failed", "value at %s is not a string slice", key)
}
result = append(result, text)
}
return result
}
func getSliceValue(t *testing.T, value map[string]any, key string) []any {
t.Helper()
raw, ok := value[key]
if !ok {
require.Failf(t, "test failed", "missing key %s", key)
}
result, ok := raw.([]any)
if !ok {
require.Failf(t, "test failed", "value at %s is not a slice", key)
}
return result
}