// Package contract holds the conformance gate for the checked-in playback // protocol v3 JSON Schemas. // // The schemas under docs/design/schemas/playback-v3 are what Android, Apple and // web vendor to prove conformance, so they are only worth anything while they // still describe the Go types that actually serve traffic. Nothing else forces // that: a schema is data, and data does not fail to compile when a struct tag // or a bound moves. These tests are the force — they compile every schema, // validate the server-generated golden bodies against it, and pin the enums and // required-field sets to the Go contract they were derived from. package contract import ( "bytes" "encoding/json" "net/http" "os" "path/filepath" "reflect" "slices" "sort" "strconv" "strings" "testing" "github.com/santhosh-tekuri/jsonschema/v6" "github.com/Silo-Server/silo-server/internal/playback" ) const ( schemaRootV3 = "../../../docs/design/schemas/playback-v3" goldenRootV3 = "../testdata/protocol_v3" ) // fixtureSchemasV3 dispatches a fixture to its schema by filename suffix, which // is what lets an invalid fixture carry a descriptive prefix // ("bandwidth-below-minimum-start_request.json") without a second index to keep // in step. var fixtureSchemasV3 = map[string]string{ "start_request.json": "start-request.schema.json", "replan_request.json": "replan-request.schema.json", "decision_response.json": "decision-response.schema.json", "capability_response.json": "capability-response.schema.json", "error_response.json": "error-response.schema.json", "route_event.json": "route-event.schema.json", } // nonWireGoldenFixturesV3 are generator outputs that pin cross-message // behavior rather than a single request or response body: opaque attempt-key // echo scenarios and the combined-ordinal subtitle inventory. They travel with // the wire fixtures because clients consume them, but no endpoint carries the // has no schema. Listing them explicitly keeps the golden sweep exhaustive: a // new wire body added to the generator without a schema fails rather than being // skipped. var nonWireGoldenFixturesV3 = []string{ "attempt_keys.json", "conformance_matrix.json", "subtitle_inventory.json", } // Enum members the schemas publish, sourced from the Go constants that produce // them so a value change breaks this package rather than a client. var ( deliveriesV3 = []string{ string(playback.DeliveryOriginalHTTPV3), string(playback.DeliveryRemuxProgressiveV3), string(playback.DeliveryRemuxHLSV3), string(playback.DeliveryTranscodeHLSV3), } outcomesV3 = []string{ string(playback.OutcomePlayableV3), string(playback.OutcomeAdaptationUnavailableV3), } executorsV3 = []string{playback.ExecutorClientV3, playback.ExecutorServerV3} evidenceV3 = []string{ string(playback.EvidenceExactV3), string(playback.EvidencePlatformAttestedV3), string(playback.EvidenceDeclaredV3), } streamProtocolsV3 = []string{ string(playback.StreamHTTPProgressiveV3), string(playback.StreamHLSV3), } headerRefreshModesV3 = []string{ string(playback.HeaderRefreshNoneV3), string(playback.HeaderRefreshSessionV3), } subtitleModesV3 = []string{ string(playback.SubtitleOffV3), string(playback.SubtitleRenderV3), string(playback.SubtitleConvertV3), string(playback.SubtitleBurnInV3), } subtitleFidelityV3 = []string{ string(playback.SubtitleFidelityPreserveV3), string(playback.SubtitleFidelityCompatibleV3), } progressPersistenceV3 = []string{ string(playback.ProgressPersistenceServerV3), string(playback.ProgressPersistenceClientV3), } subtitleSourcesV3 = []string{ playback.SubtitleSourceExternalV3, playback.SubtitleSourceEmbeddedV3, playback.SubtitleSourceDownloadedV3, } subtitleDeliveriesV3 = []string{ playback.SubtitleDeliverySidecarV3, playback.SubtitleDeliveryBurnInOnlyV3, } enhancementLayersV3 = []string{ string(playback.EnhancementNoneV3), string(playback.EnhancementMELV3), string(playback.EnhancementFELV3), string(playback.EnhancementUnknownV3), } replanOperationsV3 = []string{ string(playback.ReplanOperationFailureRecoveryV3), string(playback.ReplanOperationSeekReanchorV3), string(playback.ReplanOperationSeekFailureRecoveryV3), string(playback.ReplanOperationTrackChangeV3), string(playback.ReplanOperationQualityChangeV3), } // The two operations ReplanRequestV3.Validate rejects without a failure // classification. The schema expresses the same rule as a conditional. classificationRequiredOperationsV3 = []string{ string(playback.ReplanOperationFailureRecoveryV3), string(playback.ReplanOperationSeekFailureRecoveryV3), } // Written as literals because the planner writes them as literals too: // subtitlePolicyNameV3 in plan_v3.go, and the timeline assignments in // plan_v3.go and internal/api/handlers/playback_v3.go. subtitleFidelityPoliciesV3 = []string{"require_authored_fidelity", "allow_simplified_rendering"} seekRestorationsV3 = []string{"player_position", "source_position"} ) func TestValidFixtures(t *testing.T) { schemas := compileSchemasV3(t) fixtures := mustGlob(t, filepath.Join(schemaRootV3, "v3", "fixtures", "valid", "*.json")) if len(fixtures) != len(fixtureSchemasV3) { t.Fatalf("valid fixtures = %d, want one per schema (%d)", len(fixtures), len(fixtureSchemasV3)) } for _, fixture := range fixtures { t.Run(filepath.Base(fixture), func(t *testing.T) { if err := validateFixture(t, schemas, fixture); err != nil { t.Fatalf("validate: %v", err) } }) } } func TestClientProgressPersistenceRequiresExplicitStartPosition(t *testing.T) { schemas := compileSchemasV3(t) request := decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "start_request.json"))).(map[string]any) delete(request, "start_position") if err := schemas["start-request.schema.json"].Validate(request); err == nil { t.Fatal("client progress_persistence without start_position satisfied the schema") } } // TestGoldenFixturesSatisfyTheSchemas closes the loop the schemas exist for: a // schema that no longer accepts what cmd/playbackfixtures emits is a schema // that describes a server nobody runs. func TestGoldenFixturesSatisfyTheSchemas(t *testing.T) { schemas := compileSchemasV3(t) fixtures := mustGlob(t, filepath.Join(goldenRootV3, "*.json")) if len(fixtures) == 0 { t.Fatal("golden fixtures missing; run make playback-fixtures") } for _, fixture := range fixtures { name := filepath.Base(fixture) t.Run(name, func(t *testing.T) { if slices.Contains(nonWireGoldenFixturesV3, name) { t.Skip("not a wire body") } if err := validateFixture(t, schemas, fixture); err != nil { t.Fatalf("validate: %v", err) } }) } } // The conformance matrix embeds complete wire requests and responses inside a // larger cross-message document. Validate those nested bodies too: decoding // through Go structs first would normalize JSON null arrays to nil and conceal // a corpus that strict client decoders cannot consume. func TestConformanceMatrixEmbeddedWireBodiesSatisfySchemas(t *testing.T) { schemas := compileSchemasV3(t) matrix := decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "conformance_matrix.json"))).(map[string]any) validate := func(scenarioName, schemaName string, value any) { t.Helper() if err := schemas[schemaName].Validate(value); err != nil { t.Errorf("scenario %q embedded %s: %v", scenarioName, schemaName, err) } } reject := func(scenarioName, schemaName string, value any) { t.Helper() if err := schemas[schemaName].Validate(value); err == nil { t.Errorf("scenario %q embedded %s is expected to be rejected", scenarioName, schemaName) } } for _, raw := range matrix["planner_scenarios"].([]any) { scenario := raw.(map[string]any) validate(scenario["name"].(string), "start-request.schema.json", scenario["request"]) } for _, raw := range matrix["replan_scenarios"].([]any) { scenario := raw.(map[string]any) name := scenario["name"].(string) request := scenario["request"].(map[string]any) validate(name, "replan-request.schema.json", request) switch request["operation"] { case string(playback.ReplanOperationTrackChangeV3), string(playback.ReplanOperationQualityChangeV3), string(playback.ReplanOperationSeekReanchorV3): if failure, ok := request["failure"]; ok { t.Errorf("scenario %q intent-only replan carries failure = %#v", name, failure) } } } protocolSchemas := map[string]string{ "start_request": "start-request.schema.json", "replan_request": "replan-request.schema.json", "route_event": "route-event.schema.json", "persisted_decision": "decision-response.schema.json", } for _, raw := range matrix["protocol_scenarios"].([]any) { scenario := raw.(map[string]any) input := scenario["input"].(map[string]any) expected := scenario["expected"].(map[string]any) expectsRejection := expected["error"] != nil && expected["http_status"].(float64) >= http.StatusBadRequest for field, schemaName := range protocolSchemas { if value, ok := input[field]; ok { if expectsRejection { reject(scenario["name"].(string), schemaName, value) } else { validate(scenario["name"].(string), schemaName, value) } } } } } // TestVendoredFixturesMatchGolden keeps the copies clients vendor honest. They // are published as server output, so a hand-edit here would hand every client a // body the server never produced. func TestVendoredFixturesMatchGolden(t *testing.T) { for name := range fixtureSchemasV3 { t.Run(name, func(t *testing.T) { vendored := mustReadFile(t, filepath.Join(schemaRootV3, "v3", "fixtures", "valid", name)) golden := mustReadFile(t, filepath.Join(goldenRootV3, name)) if !bytes.Equal(vendored, golden) { t.Fatalf("vendored fixture differs from %s; run make playback-fixtures", filepath.Join(goldenRootV3, name)) } }) } } // TestInvalidFixturesAreRejected requires each negative fixture to fail for its // own reason. Distinct messages are the point: the corpus is how a client // proves its validator rejects the same bodies for the same causes, and two // fixtures collapsing onto one error would let a whole class of violation go // untested on every platform. func TestInvalidFixturesAreRejected(t *testing.T) { schemas := compileSchemasV3(t) fixtures := mustGlob(t, filepath.Join(schemaRootV3, "v3", "fixtures", "invalid", "*.json")) // A floor rather than an exact count: adding a negative case is free, // while quietly deleting the corpus would leave this test passing on // nothing. if len(fixtures) < 12 { t.Fatalf("invalid fixtures = %d, want at least 12", len(fixtures)) } seenErrors := map[string]string{} for _, fixture := range fixtures { name := filepath.Base(fixture) t.Run(name, func(t *testing.T) { err := validateFixture(t, schemas, fixture) if err == nil { t.Fatal("validate error = nil, want failure") } msg := err.Error() if prior := seenErrors[msg]; prior != "" { t.Fatalf("error %q also used by %s", msg, prior) } seenErrors[msg] = name }) } } func TestSchemaEnumsStayInSync(t *testing.T) { start := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "start-request.schema.json")) assertConstInt(t, "start.protocol_version.const", schemaValue(t, start, "properties", "protocol_version", "const"), playback.ProtocolV3) assertConstInt(t, "start.client_playback_context.protocol_version.const", schemaValue(t, start, "$defs", "client_playback_context", "properties", "protocol_version", "const"), playback.ProtocolV3) assertStringsEqual(t, "start.subtitle_fidelity_preference.enum", schemaStrings(t, start, "properties", "subtitle_fidelity_preference", "enum"), subtitleFidelityV3) assertStringsEqual(t, "start.progress_persistence.enum", schemaStrings(t, start, "properties", "progress_persistence", "enum"), progressPersistenceV3) assertStringsEqual(t, "start.capability_evidence.enum", schemaStrings(t, start, "$defs", "capability_evidence", "enum"), evidenceV3) assertStringsEqual(t, "start.transformation_capability.executor.enum", schemaStrings(t, start, "$defs", "transformation_capability", "properties", "executor", "enum"), executorsV3) replan := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "replan-request.schema.json")) assertConstInt(t, "replan.protocol_version.const", schemaValue(t, replan, "properties", "protocol_version", "const"), playback.ProtocolV3) assertConstInt(t, "replan.client_playback_context.protocol_version.const", schemaValue(t, replan, "$defs", "client_playback_context", "properties", "protocol_version", "const"), playback.ProtocolV3) assertStringsEqual(t, "replan.operation.enum", schemaStrings(t, replan, "properties", "operation", "enum"), replanOperationsV3) assertStringsEqual(t, "replan.capability_evidence.enum", schemaStrings(t, replan, "$defs", "capability_evidence", "enum"), evidenceV3) assertStringsEqual(t, "replan.transformation_capability.executor.enum", schemaStrings(t, replan, "$defs", "transformation_capability", "properties", "executor", "enum"), executorsV3) assertStringsEqual(t, "replan.classification-required operations", schemaStrings(t, replan, "allOf", "0", "if", "anyOf", "1", "properties", "operation", "enum"), classificationRequiredOperationsV3) if got := schemaValue(t, replan, "allOf", "1", "if", "properties", "operation", "const"); got != string(playback.ReplanOperationQualityChangeV3) { t.Fatalf("replan quality-change conditional = %v, want %q", got, playback.ReplanOperationQualityChangeV3) } decision := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "decision-response.schema.json")) assertConstInt(t, "decision.protocol_version.const", schemaValue(t, decision, "properties", "protocol_version", "const"), playback.ProtocolV3) assertConstInt(t, "decision.plan.protocol_version.const", schemaValue(t, decision, "$defs", "plan", "properties", "protocol_version", "const"), playback.ProtocolV3) assertStringsEqual(t, "decision.outcome.enum", schemaStrings(t, decision, "properties", "outcome", "enum"), outcomesV3) assertStringsEqual(t, "decision.transformation.executor.enum", schemaStrings(t, decision, "$defs", "transformation", "properties", "executor", "enum"), executorsV3) assertStringsEqual(t, "decision.plan.delivery.enum", schemaStrings(t, decision, "$defs", "plan", "properties", "delivery", "enum"), deliveriesV3) assertStringsEqual(t, "decision.plan.subtitle_fidelity_policy.enum", schemaStrings(t, decision, "$defs", "plan", "properties", "subtitle_fidelity_policy", "enum"), subtitleFidelityPoliciesV3) assertStringsEqual(t, "decision.stream.protocol.enum", schemaStrings(t, decision, "$defs", "stream", "properties", "protocol", "enum"), streamProtocolsV3) assertStringsEqual(t, "decision.stream.header_refresh.enum", schemaStrings(t, decision, "$defs", "stream", "properties", "header_refresh", "enum"), headerRefreshModesV3) assertStringsEqual(t, "decision.timeline.seek_restoration.enum", schemaStrings(t, decision, "$defs", "timeline", "properties", "seek_restoration", "enum"), seekRestorationsV3) assertStringsEqual(t, "decision.subtitle_decision.mode.enum", schemaStrings(t, decision, "$defs", "subtitle_decision", "properties", "mode", "enum"), subtitleModesV3) assertStringsEqual(t, "decision.subtitle_inventory_item.source.enum", schemaStrings(t, decision, "$defs", "subtitle_inventory_item", "properties", "source", "enum"), subtitleSourcesV3) assertStringsEqual(t, "decision.subtitle_inventory_item.delivery.enum", schemaStrings(t, decision, "$defs", "subtitle_inventory_item", "properties", "delivery", "enum"), subtitleDeliveriesV3) assertStringsEqual(t, "decision.source_descriptor.dv_enhancement_layer.enum", schemaStrings(t, decision, "$defs", "source_descriptor", "properties", "dv_enhancement_layer", "enum"), enhancementLayersV3) capability := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "capability-response.schema.json")) if got := schemaValue(t, capability, "properties", "enabled", "const"); got != true { t.Fatalf("capability enabled.const = %v, want true", got) } assertStringsEqual(t, "capability.deliveries.enum", schemaStrings(t, capability, "properties", "deliveries", "items", "enum"), deliveriesV3) if got := schemaValue(t, capability, "$defs", "transformation", "properties", "executor", "const"); got != playback.ExecutorServerV3 { t.Fatalf("capability transformation executor.const = %v, want %q", got, playback.ExecutorServerV3) } routeEvent := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "route-event.schema.json")) assertConstInt(t, "route_event.protocol_version.const", schemaValue(t, routeEvent, "properties", "protocol_version", "const"), playback.ProtocolV3) assertStringsEqual(t, "route_event.event.enum", schemaStrings(t, routeEvent, "properties", "event", "enum"), playback.RouteEventNamesV3()) } // TestAdvertisedListsMatchTheGoldenFixtures covers the lists the schemas // deliberately leave open. server_features and features carry no enum because a // client must tolerate entries a newer server adds; the fixtures are therefore // the only published record of what this server advertises, and clients read // them as exactly that. func TestAdvertisedListsMatchTheGoldenFixtures(t *testing.T) { var capability struct { ProtocolVersions []int `json:"protocol_versions"` Features []string `json:"features"` Deliveries []string `json:"deliveries"` } mustUnmarshalGolden(t, "capability_response.json", &capability) if !slices.Equal(capability.ProtocolVersions, []int{playback.ProtocolV3}) { t.Fatalf("capability protocol_versions = %v, want [%d]", capability.ProtocolVersions, playback.ProtocolV3) } assertStringsEqual(t, "capability fixture features", capability.Features, playback.ServerFeaturesV3()) assertStringsEqual(t, "capability fixture deliveries", capability.Deliveries, deliveriesV3) var decision struct { ServerFeatures []string `json:"server_features"` } mustUnmarshalGolden(t, "decision_response.json", &decision) assertStringsEqual(t, "decision fixture server_features", decision.ServerFeatures, playback.ServerFeaturesV3()) } // TestResponseSchemaRequiredFieldsMatchGoTags derives the required set from the // Go structs instead of restating it. A response field without `omitempty` is // always on the wire, so it must be required; one with `omitempty` can vanish, // so it must not be. That equivalence is what makes adding a field to a // response struct a compile-clean but test-failing change until the schema // catches up. func TestResponseSchemaRequiredFieldsMatchGoTags(t *testing.T) { cases := []struct { schema string path []string value any }{ {"decision-response.schema.json", nil, playback.DecisionResponseV3{}}, {"decision-response.schema.json", []string{"$defs", "terminal"}, playback.TerminalV3{}}, {"decision-response.schema.json", []string{"$defs", "track_identity"}, playback.TrackIdentityV3{}}, {"decision-response.schema.json", []string{"$defs", "transformation"}, playback.TransformationV3{}}, {"decision-response.schema.json", []string{"$defs", "plan"}, playback.PlanV3{}}, {"decision-response.schema.json", []string{"$defs", "plan", "properties", "selected_tracks"}, playback.SelectedTracksV3{}}, {"decision-response.schema.json", []string{"$defs", "stream"}, playback.StreamV3{}}, {"decision-response.schema.json", []string{"$defs", "timeline"}, playback.TimelineV3{}}, {"decision-response.schema.json", []string{"$defs", "effective_recipe"}, playback.EffectiveRecipeV3{}}, {"decision-response.schema.json", []string{"$defs", "claims"}, playback.ValidationClaimsV3{}}, {"decision-response.schema.json", []string{"$defs", "claims", "properties", "video"}, playback.VideoClaimsV3{}}, {"decision-response.schema.json", []string{"$defs", "claims", "properties", "audio"}, playback.AudioClaimsV3{}}, {"decision-response.schema.json", []string{"$defs", "claims", "properties", "subtitles"}, playback.SubtitleClaimsV3{}}, {"decision-response.schema.json", []string{"$defs", "subtitle_decision"}, playback.SubtitleDecisionV3{}}, {"decision-response.schema.json", []string{"$defs", "subtitle_decision", "properties", "artifact"}, playback.SubtitleArtifactV3{}}, {"decision-response.schema.json", []string{"$defs", "subtitle_inventory_item"}, playback.SubtitleInventoryItemV3{}}, {"decision-response.schema.json", []string{"$defs", "applied_quirk"}, playback.AppliedQuirkV3{}}, {"decision-response.schema.json", []string{"$defs", "available_quality"}, playback.AvailableQualityV3{}}, {"decision-response.schema.json", []string{"$defs", "degradation_warning"}, playback.DegradationWarningV3{}}, {"decision-response.schema.json", []string{"$defs", "source_descriptor"}, playback.SourceDescriptorV3{}}, {"capability-response.schema.json", nil, playback.CapabilityResponseV3{}}, {"capability-response.schema.json", []string{"$defs", "transformation"}, playback.TransformationV3{}}, {"error-response.schema.json", nil, playback.ErrorResponseV3{}}, } for _, tc := range cases { label := tc.schema if len(tc.path) > 0 { label += ":" + strings.Join(tc.path, ".") } t.Run(label, func(t *testing.T) { node := schemaValue(t, mustReadObject(t, filepath.Join(schemaRootV3, "v3", tc.schema)), tc.path...) assertStringsEqual(t, label+".required", optionalSchemaStrings(t, node, "required"), alwaysSerializedFields(t, tc.value)) }) } } func TestResponseSchemasEnforcePublishedInvariants(t *testing.T) { schemas := compileSchemasV3(t) capability := decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "capability_response.json"))).(map[string]any) capability["protocol_versions"] = []any{} if err := schemas["capability-response.schema.json"].Validate(capability); err == nil { t.Fatal("capability schema accepted a response that omitted protocol v3") } capability = decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "capability_response.json"))).(map[string]any) features := capability["features"].([]any) capability["features"] = features[:len(features)-1] if err := schemas["capability-response.schema.json"].Validate(capability); err == nil { t.Fatal("capability schema accepted a response that omitted a baseline feature") } capability = decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "capability_response.json"))).(map[string]any) capability["deliveries"] = []any{"original_http"} if err := schemas["capability-response.schema.json"].Validate(capability); err == nil { t.Fatal("capability schema accepted a partial delivery registry") } capability = decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "capability_response.json"))).(map[string]any) transformations := capability["transformations"].([]any) transformations[0].(map[string]any)["executor"] = "client" if err := schemas["capability-response.schema.json"].Validate(capability); err == nil { t.Fatal("capability schema accepted a client-executed server transformation") } decision := decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "decision_response.json"))).(map[string]any) plan := decision["playback_plan"].(map[string]any) plan["plan_id"] = "plan:opaque-but-not-the-server-shape" if err := schemas["decision-response.schema.json"].Validate(decision); err == nil { t.Fatal("decision schema accepted a malformed server plan identity") } decision = decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "decision_response.json"))).(map[string]any) plan = decision["playback_plan"].(map[string]any) plan["available_qualities"] = []any{} if err := schemas["decision-response.schema.json"].Validate(decision); err == nil { t.Fatal("decision schema accepted a playable plan with no source quality rung") } } // TestRequestSchemaRequiredFieldsMatchValidators restates the required sets the // request validators enforce. They cannot be derived from struct tags the way // response sets can: a request field is required because a validator rejects // its absence, not because Go would marshal it. func TestRequestSchemaRequiredFieldsMatchValidators(t *testing.T) { start := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "start-request.schema.json")) assertStringsEqual(t, "start.required", schemaStrings(t, start, "required"), []string{ "protocol_version", "file_id", "profile_id", "playback_attempt_id", "subtitle_fidelity_preference", "client_capabilities", "client_playback_context", }) assertStringsEqual(t, "start.client_capabilities.required", schemaStrings(t, start, "$defs", "client_capabilities", "required"), []string{"video_evidence", "audio_evidence"}) assertStringsEqual(t, "start.client_playback_context.required", schemaStrings(t, start, "$defs", "client_playback_context", "required"), []string{"protocol_version"}) replan := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "replan-request.schema.json")) assertStringsEqual(t, "replan.required", schemaStrings(t, replan, "required"), []string{ "protocol_version", "playback_attempt_id", "replan_request_id", "failed_plan_id", "plan_attempt_id", "plan_attempt_key", "attempt_count", "client_capabilities", "client_playback_context", }) assertStringsEqual(t, "replan.client_capabilities.required", schemaStrings(t, replan, "$defs", "client_capabilities", "required"), []string{"video_evidence", "audio_evidence"}) routeEvent := mustReadObject(t, filepath.Join(schemaRootV3, "v3", "route-event.schema.json")) assertStringsEqual(t, "route_event.required", schemaStrings(t, routeEvent, "required"), []string{"protocol_version", "playback_attempt_id", "event"}) } func compileSchemasV3(t *testing.T) map[string]*jsonschema.Schema { t.Helper() paths := mustGlob(t, filepath.Join(schemaRootV3, "v3", "*.schema.json")) if len(paths) != len(fixtureSchemasV3) { t.Fatalf("schemas = %d, want %d", len(paths), len(fixtureSchemasV3)) } compiled := make(map[string]*jsonschema.Schema, len(paths)) for _, path := range paths { name := filepath.Base(path) compiler := jsonschema.NewCompiler() doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(mustReadFile(t, path))) if err != nil { t.Fatalf("parse %s: %v", name, err) } if err := compiler.AddResource(name, doc); err != nil { t.Fatalf("register %s: %v", name, err) } schema, err := compiler.Compile(name) if err != nil { t.Fatalf("compile %s: %v", name, err) } compiled[name] = schema } return compiled } func validateFixture(t *testing.T, schemas map[string]*jsonschema.Schema, path string) error { t.Helper() name := filepath.Base(path) schemaName := "" for suffix, candidate := range fixtureSchemasV3 { if strings.HasSuffix(name, suffix) { schemaName = candidate break } } if schemaName == "" { t.Fatalf("no schema dispatches %s", name) } schema, ok := schemas[schemaName] if !ok { t.Fatalf("schema %s was not compiled", schemaName) } instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(mustReadFile(t, path))) if err != nil { t.Fatalf("parse %s: %v", name, err) } return schema.Validate(instance) } // alwaysSerializedFields returns the JSON names encoding/json always writes for // value: every tagged field without `omitempty`. func alwaysSerializedFields(t *testing.T, value any) []string { t.Helper() typ := reflect.TypeOf(value) if typ.Kind() != reflect.Struct { t.Fatalf("%T is not a struct", value) } names := make([]string, 0, typ.NumField()) for i := range typ.NumField() { field := typ.Field(i) if !field.IsExported() { continue } parts := strings.Split(field.Tag.Get("json"), ",") if parts[0] == "-" || parts[0] == "" { t.Fatalf("%s.%s has no json name", typ.Name(), field.Name) } if slices.Contains(parts[1:], "omitempty") { continue } names = append(names, parts[0]) } return names } func mustGlob(t *testing.T, pattern string) []string { t.Helper() matches, err := filepath.Glob(pattern) if err != nil { t.Fatalf("glob %s: %v", pattern, err) } sort.Strings(matches) return matches } func mustReadFile(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } return data } func decodeJSONValue(t *testing.T, body []byte) any { t.Helper() var value any if err := json.Unmarshal(body, &value); err != nil { t.Fatal(err) } return value } func mustReadObject(t *testing.T, path string) map[string]any { t.Helper() var obj map[string]any if err := json.Unmarshal(mustReadFile(t, path), &obj); err != nil { t.Fatalf("parse %s: %v", path, err) } return obj } func mustUnmarshalGolden(t *testing.T, name string, target any) { t.Helper() if err := json.Unmarshal(mustReadFile(t, filepath.Join(goldenRootV3, name)), target); err != nil { t.Fatalf("parse %s: %v", name, err) } } func schemaStrings(t *testing.T, root any, path ...string) []string { t.Helper() value := schemaValue(t, root, path...) items, ok := value.([]any) if !ok { t.Fatalf("%s is %T, want array", strings.Join(path, "."), value) } out := make([]string, 0, len(items)) for _, item := range items { s, ok := item.(string) if !ok { t.Fatalf("%s item is %T, want string", strings.Join(path, "."), item) } out = append(out, s) } return out } // optionalSchemaStrings reads a string array that may be absent, so a schema // object with no required fields compares as an empty set rather than failing // the lookup. func optionalSchemaStrings(t *testing.T, root any, key string) []string { t.Helper() obj, ok := root.(map[string]any) if !ok { t.Fatalf("schema node is %T, want object", root) } if _, present := obj[key]; !present { return nil } return schemaStrings(t, root, key) } // schemaValue walks a schema document by literal keys. Array positions are // addressed by their decimal index ("allOf", "0"), which keeps conditional // subschemas reachable without a second traversal shape. func schemaValue(t *testing.T, root any, path ...string) any { t.Helper() current := root for i, key := range path { switch node := current.(type) { case map[string]any: next, ok := node[key] if !ok { t.Fatalf("missing schema path %s", strings.Join(path[:i+1], ".")) } current = next case []any: index, err := strconv.Atoi(key) if err != nil || index < 0 || index >= len(node) { t.Fatalf("schema path %s does not index an array of %d", strings.Join(path[:i+1], "."), len(node)) } current = node[index] default: t.Fatalf("schema path %s traverses a %T", strings.Join(path[:i+1], "."), current) } } return current } func assertConstInt(t *testing.T, label string, got any, want int) { t.Helper() gotFloat, ok := got.(float64) if !ok { t.Fatalf("%s = %T, want JSON number", label, got) } if int(gotFloat) != want || gotFloat != float64(want) { t.Fatalf("%s = %v, want %d", label, got, want) } } func assertStringsEqual(t *testing.T, label string, got, want []string) { t.Helper() got = append([]string(nil), got...) want = append([]string(nil), want...) sort.Strings(got) sort.Strings(want) if !slices.Equal(got, want) { t.Fatalf("%s mismatch\ngot: %v\nwant: %v", label, got, want) } }