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 }