* feat(activity): report exact client app version, build, and channel
The admin Activity page could not name the build a session was streaming
from. Android already sent X-Silo-Client-Version and the server already
stored it intact, but playbackClientDisplayName routed it through
shortPlaybackClientVersion, which strips non-numeric runes, truncates to
two components, and drops a trailing ".0" — so a client reporting "1.0.0"
rendered as "Silo Android TV 1". The Apple clients sent no client name or
version at all and fell back to user-agent sniffing.
Adds two additive, opaque wire fields alongside the existing client
headers — X-Silo-Client-Build (<=64) and X-Silo-Client-Channel (<=32) —
with client_playback_context.app_build/app_channel as the v3 fallback,
which is also where the previously discarded app_version now gets used.
The server never parses, compares, or enum-validates either value: Apple
uses a per-platform TestFlight sequence and Android a per-marketing-
version counter, and keeping them opaque lets both coexist without a
shared scheme. Any future minimum-version gating belongs on
client_version, which is semver.
Only the named-client branch of playbackClientDisplayName stops
truncating; the user-agent branch keeps shortPlaybackClientVersion, so
browser labels stay "Chrome 120" rather than a full UA version string.
The compact session row is unchanged in width — it is shared with
AdminDashboard, AdminStats, and HouseholdStreamsPanel — and the exact
string lands in the row tooltip and a new Client card in the expanded
panel.
Diagnostic logs carry client_name/version/build/channel on both
"playback plan decided" lines and on session expiry. opslog stores an
open attrs JSONB, so this needs no migration. activity_log is
deliberately untouched: it is the highest-volume table and the value is
constant per device.
Jellyfin compat sessions keep an empty build — the MediaBrowser auth
header vocabulary has no build concept, and synthesizing one from a user
agent would be a guess.
Part of the client-version-visibility work spanning silo-android and
silo-apple.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): resolve client identity without polluting client_version
Review follow-up on the client build/channel work. Fourteen findings; the
substantive ones:
The v3 body fallback took client_playback_context.app_version whenever the
header was absent. The web player sends the literal "web" there and sends no
X-Silo-Client, so every browser session would have stamped client_version="web"
— the one field the contract promises is semver and the field a future
minimum-version gate has to key on. client_playback_context carries no app name,
so the body can never identify a nameless client anyway; the fallback now
applies only to a client that sent X-Silo-Client, and a test pins the "web"
case.
An over-long app_build or app_channel in the start body failed the whole request
with 400 while the same value in a header was silently clamped — an opaque
diagnostic label could refuse playback. validateCapabilitiesV3 now clamps both
with the same helper the header path uses, which is what the docs already
claimed.
Route events posted out of band resolved identity from headers only, so a client
reporting its build in the start body attributed plan_selected to a build and
every later event of the same attempt to none. They now fill empty fields from
the session, as the replan path already did.
playbackClientFullDisplayName discarded build and channel whenever the client
reported no name, so the new Client card could never show a build for a
user-agent-labelled session. It now qualifies whatever label the compact
formatter resolved, which also drops its duplicated name+version assembly.
normalizeClientMetadataValue truncated by bytes; a multi-byte header value cut
mid-rune yields invalid UTF-8, which Postgres rejects — and the per-node session
upserts share one transaction, so one malformed client string would fail that
whole node's sync. It now clamps on a rune boundary.
replan-request.schema.json never got app_build/app_channel even though
ReplanRequestV3 reuses ClientPlaybackContextV3 and validates the same bounds. A
new contract test asserts every $def the two request schemas share is identical,
so the copies cannot drift again.
Also: the four client log attrs move to ClientInfo.LogAttrs(), which is now
their single definition and omits fields the client did not report rather than
persisting empty keys into opslog; startPlannedPlaybackV3 takes the resolved
identity instead of re-parsing the headers; client_label_full is omitted when it
would repeat client_label; getSessionClientLabelFull delegates to
getSessionClientLabel instead of re-implementing it; the Activity search matches
the exact label so a build number is findable; and the web ClientPlaybackContextV3
type mirrors the two new optional fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): clamp client identity at the request boundary, by runes
Follow-up to bot review on the previous commit.
The 64/32 clamp for X-Silo-Client-Build / -Channel only ran where newSession
stamped its fields, but the resolved ClientInfo is written straight to the
plan-decision log and to playback_route_events. A client sending a header-sized
build reached both despite the published bound. ClientInfo.Normalized() is now
the single definition of those limits and runs at the request boundary —
playbackClientInfoFromRequest and playbackClientInfoForStartV3 — with newSession
still normalizing because identities also arrive from the Jellyfin and
Audiobookshelf compat surfaces.
normalizeClientMetadataValue now clamps by runes rather than bytes. The bounds
are published to clients as JSON Schema maxLength, which counts characters, so a
byte clamp cut values the contract calls valid — a 32-character emoji channel
was 128 bytes. It also scrubs invalid UTF-8 outright rather than only after a
mid-rune cut, since a header may carry bytes that were never valid UTF-8 and a
text column refuses them.
Two tests cover it: oversized headers clamp at the boundary, and a 40-rune
multi-byte channel lands on the 32-character bound as valid UTF-8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): strip control characters from client identity
A JSON NUL escape in a v3 start body's app_version, app_build or app_channel
decodes to a real NUL. That is valid UTF-8, so the UTF-8 repair leaves it and
TrimSpace does not treat it as whitespace — but Postgres refuses NUL in a text
column. The per-node session upserts share one transaction, so a single such
start would stop every live session on that node from reconciling until the
offending session went away. Headers cannot carry it (net/http rejects bytes
below 0x20), which is why only the body path this PR added is exposed.
normalizeClientMetadataValue now strips control characters outright rather than
NUL alone: none of them belong in an identity label rendered in the admin UI and
written to structured logs.
Reported by Codex review on b43b7ef06.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): satisfy goconst and misspell on the changed log lines
CI's `golangci-lint --new-from-merge-base` failed on the previous commits: the
two decision-log calls were reformatted into slice literals, which brought their
"component" key inside the changed-lines window where goconst flags it against
the existing logComponentKey constant, and a doc comment used the British
"labelled". Both lines now use the constant, and the spelling is corrected here
and in docs/settings-api.md.
The file's other 16 "component" literals are left alone: CI only requires the
lines a branch touches to be clean, and rewriting them would bury this change in
unrelated churn.
Verified with the same command and version CI runs (golangci-lint v2.12.2,
--new-from-merge-base=origin/main): 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(playback): require context-aware session starts
startPlannedPlaybackV3 probed for StartSessionWithFilesContext with a type
assertion and fell back to the context-free StartSessionWithFiles. The context
is how the reporting client's identity reaches the new session, so any
implementation missing the method would start sessions carrying no client name,
version, build or channel — silently, and now that build and channel ride the
same path, silently losing more.
SessionManagerInterface requires the method instead, so a non-conforming
implementation fails to compile rather than dropping the identity at run time.
The one test double gains a three-line method; production already implemented it.
Raised as a nitpick by CodeRabbit review; pre-existing, but it is this PR's data
that the fallback drops.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
748 lines
33 KiB
Go
748 lines
33 KiB
Go
// 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),
|
|
string(playback.ReplanOperationOutputChangeV3),
|
|
}
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
func TestIntentOnlyReplansRejectFailureEvidence(t *testing.T) {
|
|
schemas := compileSchemasV3(t)
|
|
for _, operation := range []playback.ReplanOperationV3{
|
|
playback.ReplanOperationTrackChangeV3,
|
|
playback.ReplanOperationQualityChangeV3,
|
|
playback.ReplanOperationOutputChangeV3,
|
|
} {
|
|
t.Run(string(operation), func(t *testing.T) {
|
|
request := decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "replan_request.json"))).(map[string]any)
|
|
request["operation"] = string(operation)
|
|
request["quality_preference"] = "720p"
|
|
request["failure"] = map[string]any{"classification": "decoder_failure"}
|
|
if err := schemas["replan-request.schema.json"].Validate(request); err == nil {
|
|
t.Fatal("intent-only replan with failure 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.ReplanOperationOutputChangeV3), 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"})
|
|
}
|
|
|
|
// TestRequestSchemasShareIdenticalDefs pins the one thing two copies of a
|
|
// definition can never be trusted to keep on their own. Start and replan
|
|
// deserialize the *same* Go types — ClientCodecCapabilitiesV3 and
|
|
// ClientPlaybackContextV3 — through the same validator, so any $def they both
|
|
// declare must be byte-identical. Adding a field to one schema and not the
|
|
// other compiles, validates, and ships a contract that lies about one of the
|
|
// two endpoints; this is what catches it.
|
|
func TestRequestSchemasShareIdenticalDefs(t *testing.T) {
|
|
start := schemaValue(t, mustReadObject(t, filepath.Join(schemaRootV3, "v3", "start-request.schema.json")), "$defs").(map[string]any)
|
|
replan := schemaValue(t, mustReadObject(t, filepath.Join(schemaRootV3, "v3", "replan-request.schema.json")), "$defs").(map[string]any)
|
|
|
|
shared := make([]string, 0, len(start))
|
|
for name := range start {
|
|
if _, ok := replan[name]; ok {
|
|
shared = append(shared, name)
|
|
}
|
|
}
|
|
sort.Strings(shared)
|
|
if !slices.Contains(shared, "client_playback_context") || !slices.Contains(shared, "client_capabilities") {
|
|
t.Fatalf("shared $defs = %v, want the client contract definitions in both request schemas", shared)
|
|
}
|
|
for _, name := range shared {
|
|
if !reflect.DeepEqual(start[name], replan[name]) {
|
|
t.Errorf("$defs.%s differs between start-request and replan-request; both deserialize the same Go type", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|