feat(settings): add the canonical resolution engine

One answer to "what is this setting, for this profile, on this device, for
this content". Before this, each caller carried its own ladder:
catalog/detail.go resolved subtitles across four levels by hand and audio
across three, handlers/settings.go had a two-level device/user resolution
with a lazy write-back inside a GET, and jellycompat read profile columns
directly. Those disagreed about precedence, which is the drift the contract
exists to remove.

Resolution is one batched read regardless of how many keys, libraries, or
series are in play — ranking happens in Go against each definition's
declared resolution_order. Five sequential index lookups per key per item
is the implementation the design rejects, and a season view is exactly
where it would have shown up.

An absent identity drops its scope rather than erroring, so one code path
serves an identified client, an anonymous jellycompat seed, and a batch
spanning many series. Rows for a foreign profile, device, library or series
are ignored even though the batched read returns them.

Constraints narrow without destroying: a capped 4K preference resolves to
the cap, reports itself constrained, and keeps the authored value so it
takes effect the day the cap lifts. Two cases needed care — null on a
nullable numeric means unbounded, so a ceiling must cap it rather than rank
it equal and let the value that most needs capping slip past; and an
allowlist falls back to a permitted member rather than the definition's
default, which may itself be outside the list.

Adds ValueSchema.CompareValues to the contract package, since ordering
values is what makes a ceiling or floor mean anything and value semantics
belong with the schema that declares them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-27 23:01:01 +00:00
co-authored by Claude Opus 5
parent f44240e5e5
commit 9fa57653b8
3 changed files with 804 additions and 0 deletions
+76
View File
@@ -780,3 +780,79 @@ func isAlpha(value string) bool {
}
return true
}
// CompareValues orders two values of this schema's type: negative when a sorts
// below b, zero when they are equivalent, positive when a sorts above.
//
// This is what makes a ceiling or floor constraint mean anything. Numeric types
// compare numerically; an ordered enum compares by declared member position,
// which is why manifest.schema.json only permits those constraints on a numeric
// type or an enum marked ordered — every other type has no defined direction to
// cap in.
//
// A value that is not a member, or that will not decode, sorts as equivalent so
// an unrecognized value is never silently narrowed. Validation is a separate
// concern and has already rejected it by the time a constraint is applied.
func (v *ValueSchema) CompareValues(a, b json.RawMessage) int {
switch v.Type {
case TypeInteger, TypeNumber:
left, okA := decodeFloat(a)
right, okB := decodeFloat(b)
if !okA || !okB {
return 0
}
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
case TypeEnum:
if !v.Ordered {
return 0
}
left, okA := v.enumIndex(a)
right, okB := v.enumIndex(b)
if !okA || !okB {
return 0
}
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}
return 0
}
// enumIndex returns the declared position of raw among this schema's members.
func (v *ValueSchema) enumIndex(raw json.RawMessage) (int, bool) {
var decoded any
if err := strictUnmarshal(bytes.TrimSpace(raw), &decoded); err != nil {
return 0, false
}
for i, member := range v.Values {
if enumMatches(decoded, member.Value) {
return i, true
}
}
return 0, false
}
func decodeFloat(raw json.RawMessage) (float64, bool) {
var number json.Number
if err := strictUnmarshal(bytes.TrimSpace(raw), &number); err != nil {
return 0, false
}
parsed, err := number.Float64()
if err != nil {
return 0, false
}
return parsed, true
}
+353
View File
@@ -0,0 +1,353 @@
// Package settingsresolve turns stored setting values into effective ones.
//
// It is the single answer to "what is this setting, for this profile, on this
// device, for this content" — the mutation endpoint, the effective-values
// endpoint, playback, catalog, and the jellycompat DisplayPreferences seed all
// resolve through here. Before this package each of those carried its own
// ladder: internal/catalog/detail.go resolved subtitles across four levels by
// hand and audio across three, internal/api/handlers/settings.go had a
// two-level device/user resolution with a lazy write-back inside a GET, and
// jellycompat read profile columns directly. Those disagreed about precedence,
// which is the drift the contract exists to remove.
//
// The package deliberately holds no storage of its own. It takes candidate rows
// and a contract, and returns decisions.
package settingsresolve
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// Context is the identity a resolution happens against.
//
// Every field is optional and an absent one simply drops the scopes that need
// it: no DeviceID means no profile_device candidates, no SeriesIDs means no
// profile_series. That is what lets one code path serve an identified client,
// an anonymous jellycompat seed, and a batch spanning many series.
type Context struct {
ProfileID string
DeviceID string
// LibraryIDs and SeriesIDs are the content contexts in play. A batch
// resolving a season passes every id once rather than resolving per item.
LibraryIDs []int
SeriesIDs []string
}
// Constraints carries the policy inputs a definition's constrained_by may
// reference, keyed by policy_input name. A missing entry means the policy does
// not constrain that setting for this viewer.
//
// Values are compared through the definition's own value schema, so a ceiling
// on an ordered enum ranks by member order and a ceiling on a number compares
// numerically.
type Constraints map[string]json.RawMessage
// Source names where an effective value came from. It is the resolved scope, or
// ScopeDefault when nothing was stored.
type Source = settingscontract.Scope
// Effective is one resolved setting.
type Effective struct {
Key string `json:"key"`
Value json.RawMessage `json:"value"`
// Source is the scope Value came from, or "default".
Source Source `json:"source"`
// StoredValue is what the user actually authored, present only when a
// constraint changed the answer. A capped 4K preference must survive the
// cap so it takes effect the day the cap lifts, so the stored value is
// reported rather than overwritten.
StoredValue json.RawMessage `json:"stored_value,omitempty"`
// Constrained is set when policy narrowed Value away from StoredValue.
Constrained bool `json:"constrained,omitempty"`
// ConstraintKind names how it was narrowed, for client copy.
ConstraintKind settingscontract.ConstraintKind `json:"constraint_kind,omitempty"`
// Identity locates the row Value came from, so a client can offer "reset
// this device's override" against the exact scope that holds it. Empty for
// a default.
Identity *userstore.SettingIdentity `json:"-"`
}
// Store is the read surface this package needs. It is satisfied by
// userstore.UserStore and by a fake in tests.
type Store interface {
ListSettingValuesForResolution(
ctx context.Context, query userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error)
}
// Resolver resolves against one contract.
type Resolver struct {
contract *settingscontract.Manifest
}
// New returns a Resolver over the given contract.
func New(contract *settingscontract.Manifest) *Resolver {
return &Resolver{contract: contract}
}
// Resolve returns the effective value for each requested key.
//
// One batched store read regardless of how many keys, libraries, or series are
// in play; ranking happens here in Go. Unknown keys are omitted rather than
// erroring, so a newer client asking for a setting this server does not have
// gets a short answer instead of a failed request.
func (r *Resolver) Resolve(
ctx context.Context,
store Store,
rc Context,
keys []string,
constraints Constraints,
) ([]Effective, error) {
if r == nil || r.contract == nil {
return nil, fmt.Errorf("settingsresolve: no contract")
}
known := make([]string, 0, len(keys))
defs := make(map[string]*settingscontract.Definition, len(keys))
for _, key := range keys {
def, ok := r.contract.Lookup(key)
if !ok || def.Persistence != settingscontract.PersistenceRemote {
// client_local settings never have server rows; asking for one is
// not an error, it simply has no server answer.
continue
}
if _, seen := defs[key]; seen {
continue
}
defs[key] = def
known = append(known, key)
}
if len(known) == 0 {
return nil, nil
}
stored, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: known,
ProfileID: rc.ProfileID,
DeviceID: rc.DeviceID,
LibraryIDs: rc.LibraryIDs,
SeriesIDs: rc.SeriesIDs,
})
if err != nil {
return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err)
}
byKey := make(map[string][]userstore.SettingValue, len(known))
for _, row := range stored {
byKey[row.Key] = append(byKey[row.Key], row)
}
out := make([]Effective, 0, len(known))
for _, key := range known {
out = append(out, r.resolveOne(defs[key], byKey[key], rc, constraints))
}
return out, nil
}
// resolveOne ranks one key's candidates by its declared resolution order.
func (r *Resolver) resolveOne(
def *settingscontract.Definition,
candidates []userstore.SettingValue,
rc Context,
constraints Constraints,
) Effective {
eff := Effective{
Key: def.Key,
Value: append(json.RawMessage(nil), def.DefaultValue...),
Source: settingscontract.ScopeDefault,
}
for _, scope := range def.ResolutionOrder {
if scope == settingscontract.ScopeDefault {
break
}
row, ok := pickForScope(scope, candidates, rc)
if !ok {
continue
}
eff.Value = append(json.RawMessage(nil), row.Value...)
eff.Source = scope
identity := row.SettingIdentity
eff.Identity = &identity
break
}
return applyConstraint(def, eff, constraints)
}
// pickForScope returns the candidate row for one scope.
//
// Library and series scopes can return several rows in a batch — one per
// library or series in the request — so the caller's context decides which is
// the relevant one. Ties are broken by the most specific id in the request
// order, which is why a batch must resolve per item rather than expecting one
// answer to cover a whole season.
func pickForScope(
scope settingscontract.Scope,
candidates []userstore.SettingValue,
rc Context,
) (userstore.SettingValue, bool) {
matches := make([]userstore.SettingValue, 0, 2)
for _, row := range candidates {
if row.Scope != scope {
continue
}
switch scope {
case settingscontract.ScopeAccount:
matches = append(matches, row)
case settingscontract.ScopeProfile:
if row.ProfileID == rc.ProfileID {
matches = append(matches, row)
}
case settingscontract.ScopeProfileDevice:
if row.ProfileID == rc.ProfileID && row.DeviceID == rc.DeviceID && rc.DeviceID != "" {
matches = append(matches, row)
}
case settingscontract.ScopeProfileLibrary:
if row.ProfileID == rc.ProfileID && containsInt(rc.LibraryIDs, row.LibraryID) {
matches = append(matches, row)
}
case settingscontract.ScopeProfileSeries:
if row.ProfileID == rc.ProfileID && containsString(rc.SeriesIDs, row.SeriesID) {
matches = append(matches, row)
}
}
}
if len(matches) == 0 {
return userstore.SettingValue{}, false
}
if len(matches) > 1 {
// Deterministic rather than arbitrary: a batch spanning several
// libraries or series has no single right answer, and the caller is
// expected to resolve per item. Sorting means it at least cannot differ
// between two identical requests.
sort.Slice(matches, func(i, j int) bool {
if matches[i].LibraryID != matches[j].LibraryID {
return matches[i].LibraryID < matches[j].LibraryID
}
return matches[i].SeriesID < matches[j].SeriesID
})
}
return matches[0], true
}
// applyConstraint narrows an effective value to what policy permits.
//
// The stored value is never destroyed: a preference capped today must take
// effect the day the cap lifts, so the cap is reported alongside the authored
// value rather than replacing it.
func applyConstraint(
def *settingscontract.Definition,
eff Effective,
constraints Constraints,
) Effective {
if def.ConstrainedBy == nil || len(constraints) == 0 {
return eff
}
limit, ok := constraints[def.ConstrainedBy.PolicyInput]
if !ok || len(limit) == 0 {
return eff
}
narrow, changed := narrowValue(def, eff.Value, limit)
if !changed {
return eff
}
eff.StoredValue = eff.Value
eff.Value = narrow
eff.Constrained = true
eff.ConstraintKind = def.ConstrainedBy.Constraint
return eff
}
// narrowValue applies one constraint kind, returning the permitted value and
// whether it differs from the stored one.
func narrowValue(
def *settingscontract.Definition,
value, limit json.RawMessage,
) (json.RawMessage, bool) {
switch def.ConstrainedBy.Constraint {
case settingscontract.ConstraintLocked:
// The policy value replaces the user's outright.
if bytes.Equal(bytes.TrimSpace(value), bytes.TrimSpace(limit)) {
return value, false
}
return append(json.RawMessage(nil), limit...), true
case settingscontract.ConstraintCeiling:
// null on a nullable numeric means "no cap of my own", which is
// unbounded above — exactly what a ceiling exists to bring down. It has
// no rank, so CompareValues reports 0 and the value would otherwise
// slip past the cap it most needs to obey.
if isNull(value) && isNumeric(def) {
return append(json.RawMessage(nil), limit...), true
}
if def.ValueSchema.CompareValues(value, limit) <= 0 {
return value, false
}
return append(json.RawMessage(nil), limit...), true
case settingscontract.ConstraintFloor:
// The mirror of the above: unbounded above already satisfies any floor.
if isNull(value) && isNumeric(def) {
return value, false
}
if def.ValueSchema.CompareValues(value, limit) >= 0 {
return value, false
}
return append(json.RawMessage(nil), limit...), true
case settingscontract.ConstraintAllowlist:
var allowed []json.RawMessage
if err := json.Unmarshal(limit, &allowed); err != nil || len(allowed) == 0 {
return value, false
}
trimmed := bytes.TrimSpace(value)
for _, entry := range allowed {
if bytes.Equal(bytes.TrimSpace(entry), trimmed) {
return value, false
}
}
// Falling back to the first allowed member rather than the default:
// the default may itself be outside the allowlist, and an effective
// value the policy forbids is the one thing this must never return.
return append(json.RawMessage(nil), allowed[0]...), true
}
return value, false
}
func isNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
func isNumeric(def *settingscontract.Definition) bool {
return def.ValueSchema.Type == settingscontract.TypeInteger ||
def.ValueSchema.Type == settingscontract.TypeNumber
}
func containsInt(haystack []int, needle int) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
func containsString(haystack []string, needle string) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
+375
View File
@@ -0,0 +1,375 @@
package settingsresolve
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// fakeStore records the query it was asked and replays fixed rows, so a test
// can assert both the answer and that only one read produced it.
type fakeStore struct {
rows []userstore.SettingValue
queries []userstore.SettingResolutionQuery
err error
}
func (f *fakeStore) ListSettingValuesForResolution(
_ context.Context, query userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error) {
f.queries = append(f.queries, query)
if f.err != nil {
return nil, f.err
}
return f.rows, nil
}
func row(key string, scope settingscontract.Scope, value string, id userstore.SettingIdentity) userstore.SettingValue {
id.Key = key
id.Scope = scope
return userstore.SettingValue{SettingIdentity: id, Value: json.RawMessage(value)}
}
func mustContract(t *testing.T) *settingscontract.Manifest {
t.Helper()
manifest, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
return manifest
}
func resolveOne(t *testing.T, store Store, rc Context, key string, constraints Constraints) Effective {
t.Helper()
got, err := New(mustContract(t)).Resolve(context.Background(), store, rc, []string{key}, constraints)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if len(got) != 1 {
t.Fatalf("Resolve returned %d results, want 1", len(got))
}
return got[0]
}
// TestResolutionOrderIsHonored is the whole point of the package: the most
// specific scope holding a value wins, and the declared order decides what
// "specific" means rather than each caller's own opinion.
func TestResolutionOrderIsHonored(t *testing.T) {
const key = "playback.subtitle_language"
profile := row(key, settingscontract.ScopeProfile, `"en"`,
userstore.SettingIdentity{ProfileID: "p1"})
device := row(key, settingscontract.ScopeProfileDevice, `"de"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "d1"})
library := row(key, settingscontract.ScopeProfileLibrary, `"fr"`,
userstore.SettingIdentity{ProfileID: "p1", LibraryID: 7})
series := row(key, settingscontract.ScopeProfileSeries, `"ja"`,
userstore.SettingIdentity{ProfileID: "p1", SeriesID: "s1"})
rc := Context{ProfileID: "p1", DeviceID: "d1", LibraryIDs: []int{7}, SeriesIDs: []string{"s1"}}
for name, tc := range map[string]struct {
rows []userstore.SettingValue
wantValue string
wantSource settingscontract.Scope
}{
"series beats everything": {
[]userstore.SettingValue{profile, device, library, series}, `"ja"`,
settingscontract.ScopeProfileSeries,
},
"library beats device and profile": {
[]userstore.SettingValue{profile, device, library}, `"fr"`,
settingscontract.ScopeProfileLibrary,
},
"device beats profile": {
[]userstore.SettingValue{profile, device}, `"de"`,
settingscontract.ScopeProfileDevice,
},
"profile alone": {
[]userstore.SettingValue{profile}, `"en"`, settingscontract.ScopeProfile,
},
"nothing stored falls to the default": {
nil, `null`, settingscontract.ScopeDefault,
},
} {
t.Run(name, func(t *testing.T) {
got := resolveOne(t, &fakeStore{rows: tc.rows}, rc, key, nil)
if string(got.Value) != tc.wantValue {
t.Errorf("value = %s, want %s", got.Value, tc.wantValue)
}
if got.Source != tc.wantSource {
t.Errorf("source = %q, want %q", got.Source, tc.wantSource)
}
})
}
}
// TestAbsentIdentityDropsItsScope covers the anonymous caller. jellycompat
// seeds DisplayPreferences without a device, and a device override leaking into
// that seed would hand one device's settings to every Jellyfin client.
func TestAbsentIdentityDropsItsScope(t *testing.T) {
const key = "playback.subtitle_language"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"en"`,
userstore.SettingIdentity{ProfileID: "p1"}),
row(key, settingscontract.ScopeProfileDevice, `"de"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "d1"}),
}
got := resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, nil)
if got.Source != settingscontract.ScopeProfile {
t.Fatalf("source = %q, want profile: a device row resolved for a caller with no device",
got.Source)
}
if string(got.Value) != `"en"` {
t.Errorf("value = %s, want \"en\"", got.Value)
}
}
// TestUnrelatedIdentitiesAreIgnored guards the cross-identity leak: rows for
// another profile, device, library or series must not resolve just because the
// batched read returned them.
func TestUnrelatedIdentitiesAreIgnored(t *testing.T) {
const key = "playback.subtitle_language"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"xx"`,
userstore.SettingIdentity{ProfileID: "other"}),
row(key, settingscontract.ScopeProfileDevice, `"yy"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "other-device"}),
row(key, settingscontract.ScopeProfileSeries, `"zz"`,
userstore.SettingIdentity{ProfileID: "p1", SeriesID: "other-series"}),
}
rc := Context{ProfileID: "p1", DeviceID: "d1", SeriesIDs: []string{"s1"}}
got := resolveOne(t, &fakeStore{rows: rows}, rc, key, nil)
if got.Source != settingscontract.ScopeDefault {
t.Fatalf("source = %q with value %s, want default: a foreign row resolved",
got.Source, got.Value)
}
}
// TestResolveIssuesOneRead pins the batching. The design rejects one lookup per
// scope per key, and a season view resolving many items is exactly where that
// would show up.
func TestResolveIssuesOneRead(t *testing.T) {
store := &fakeStore{}
keys := []string{
"playback.subtitle_language", "playback.audio_language",
"playback.subtitle_mode", "playback.show_forced_subtitles",
}
rc := Context{
ProfileID: "p1",
DeviceID: "d1",
LibraryIDs: []int{1, 2, 3},
SeriesIDs: []string{"s1", "s2", "s3"},
}
if _, err := New(mustContract(t)).Resolve(context.Background(), store, rc, keys, nil); err != nil {
t.Fatalf("Resolve: %v", err)
}
if len(store.queries) != 1 {
t.Fatalf("issued %d reads for %d keys, want 1", len(store.queries), len(keys))
}
if len(store.queries[0].Keys) != len(keys) {
t.Errorf("query carried %d keys, want %d", len(store.queries[0].Keys), len(keys))
}
}
// TestUnknownAndLocalKeysAreOmitted keeps a newer client's request from
// failing wholesale. A key this server does not have, or one the contract says
// never leaves the device, simply has no server answer.
func TestUnknownAndLocalKeysAreOmitted(t *testing.T) {
got, err := New(mustContract(t)).Resolve(context.Background(), &fakeStore{},
Context{ProfileID: "p1"},
[]string{"playback.subtitle_mode", "not.a.real.key", "downloads.wifi_only"}, nil)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if len(got) != 1 {
t.Fatalf("returned %d results, want only the one remote key", len(got))
}
if got[0].Key != "playback.subtitle_mode" {
t.Errorf("resolved %q", got[0].Key)
}
}
// TestCeilingNarrowsWithoutDestroying is the preferences-versus-restrictions
// rule: a capped preference is reported capped and kept intact, so it takes
// effect the day the cap lifts.
func TestCeilingNarrowsWithoutDestroying(t *testing.T) {
const key = "playback.preferred_quality"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"2160p"`,
userstore.SettingIdentity{ProfileID: "p1"}),
}
constraints := Constraints{"max_playback_quality": json.RawMessage(`"1080p"`)}
got := resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, constraints)
if string(got.Value) != `"1080p"` {
t.Errorf("effective = %s, want \"1080p\"", got.Value)
}
if string(got.StoredValue) != `"2160p"` {
t.Errorf("stored = %s, want \"2160p\" preserved", got.StoredValue)
}
if !got.Constrained || got.ConstraintKind != settingscontract.ConstraintCeiling {
t.Errorf("constrained=%v kind=%q, want true/ceiling", got.Constrained, got.ConstraintKind)
}
// Under the cap, nothing is touched and no constraint is reported.
rows[0].Value = json.RawMessage(`"720p"`)
got = resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, constraints)
if string(got.Value) != `"720p"` || got.Constrained {
t.Errorf("value = %s constrained = %v, want \"720p\"/false", got.Value, got.Constrained)
}
if got.StoredValue != nil {
t.Errorf("stored_value = %s, want absent when nothing was narrowed", got.StoredValue)
}
}
// TestCeilingCapsAnUncappedNullable covers the case CompareValues cannot rank.
// null on max_bitrate_kbps means "no cap of my own", which is unbounded above —
// precisely what a ceiling exists to bring down. Ranking it as equal would let
// the one value that most needs capping slip past.
func TestCeilingCapsAnUncappedNullable(t *testing.T) {
manifest := mustContract(t)
def, ok := manifest.Lookup("playback.max_bitrate_kbps")
if !ok {
t.Fatal("playback.max_bitrate_kbps is not registered")
}
// The manifest does not bind this key to a policy input today; the rule
// still has to hold for whichever numeric key does.
bound := *def
bound.ConstrainedBy = &settingscontract.Constraint{
PolicyInput: "max_bitrate_kbps",
Constraint: settingscontract.ConstraintCeiling,
}
capped := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`null`),
Source: settingscontract.ScopeDefault,
}, Constraints{"max_bitrate_kbps": json.RawMessage(`8000`)})
if string(capped.Value) != `8000` {
t.Errorf("uncapped bitrate resolved to %s, want the policy cap 8000", capped.Value)
}
if !capped.Constrained {
t.Error("capping an uncapped value was not reported as constrained")
}
// A floor is the mirror: unbounded already satisfies it.
bound.ConstrainedBy.Constraint = settingscontract.ConstraintFloor
floored := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`null`),
}, Constraints{"max_bitrate_kbps": json.RawMessage(`8000`)})
if floored.Constrained {
t.Errorf("floor narrowed an already-unbounded value to %s", floored.Value)
}
}
// TestAllowlistFallsBackInsideTheAllowedSet guards the one thing a constraint
// must never do: return a value the policy forbids. The definition's own
// default is not a safe fallback, because it may itself be outside the list.
func TestAllowlistFallsBackInsideTheAllowedSet(t *testing.T) {
manifest := mustContract(t)
def, ok := manifest.Lookup("catalog.metadata_language")
if !ok {
t.Fatal("catalog.metadata_language is not registered")
}
bound := *def
bound.ConstrainedBy = &settingscontract.Constraint{
PolicyInput: "allowed_metadata_languages",
Constraint: settingscontract.ConstraintAllowlist,
}
got := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`"ja"`),
Source: settingscontract.ScopeProfile,
}, Constraints{"allowed_metadata_languages": json.RawMessage(`["en","fr"]`)})
if string(got.Value) != `"en"` {
t.Errorf("value = %s, want the first allowed member", got.Value)
}
if string(got.StoredValue) != `"ja"` {
t.Errorf("stored = %s, want the authored value kept", got.StoredValue)
}
// A permitted value passes through untouched.
allowed := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`"fr"`),
}, Constraints{"allowed_metadata_languages": json.RawMessage(`["en","fr"]`)})
if allowed.Constrained {
t.Error("a permitted value was reported as constrained")
}
}
// TestNoConstraintInputLeavesValueAlone covers the viewer a policy says nothing
// about, which is most of them.
func TestNoConstraintInputLeavesValueAlone(t *testing.T) {
const key = "playback.preferred_quality"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"2160p"`,
userstore.SettingIdentity{ProfileID: "p1"}),
}
for name, constraints := range map[string]Constraints{
"no constraints at all": nil,
"unrelated input": {"something_else": json.RawMessage(`"1080p"`)},
"empty input": {"max_playback_quality": json.RawMessage(``)},
} {
t.Run(name, func(t *testing.T) {
got := resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, constraints)
if got.Constrained || string(got.Value) != `"2160p"` {
t.Errorf("value = %s constrained = %v, want the stored value untouched",
got.Value, got.Constrained)
}
})
}
}
// TestIdentityLocatesTheResolvedRow so a client can offer "reset this device's
// override" against the scope that actually holds the value.
func TestIdentityLocatesTheResolvedRow(t *testing.T) {
const key = "playback.subtitle_language"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfileDevice, `"de"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "d1"}),
}
got := resolveOne(t, &fakeStore{rows: rows},
Context{ProfileID: "p1", DeviceID: "d1"}, key, nil)
if got.Identity == nil {
t.Fatal("no identity reported for a stored value")
}
if got.Identity.Scope != settingscontract.ScopeProfileDevice || got.Identity.DeviceID != "d1" {
t.Errorf("identity = %+v, want the profile_device row", *got.Identity)
}
// A default came from no row, so there is nothing to reset.
def := resolveOne(t, &fakeStore{}, Context{ProfileID: "p1", DeviceID: "d1"}, key, nil)
if def.Identity != nil {
t.Errorf("identity = %+v for a default, want none", *def.Identity)
}
}
func TestStoreErrorsPropagate(t *testing.T) {
sentinel := errors.New("boom")
_, err := New(mustContract(t)).Resolve(context.Background(),
&fakeStore{err: sentinel}, Context{ProfileID: "p1"},
[]string{"playback.subtitle_mode"}, nil)
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want it to wrap the store error", err)
}
}
// The production store must satisfy the narrow read interface declared here.
// The package takes an interface rather than the concrete store so tests can
// fake it, which is exactly the seam that lets an incompatible signature go
// unnoticed until a caller wires the two together.
var _ Store = (userstore.UserStore)(nil)