Files

700 lines
31 KiB
Go
Raw Permalink Normal View History

// 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)
}
}