Files
silo-server/internal/userstore/storetest/settingvalues.go
T
QuickandClaude Opus 5 05af63ea2b feat(settings): add canonical typed storage for the settings contract
The cross-platform settings contract needs one typed store behind it before a
resolver, routes or a migration can exist. This adds that storage to both
user-store backends and holds them to identical behavior.

PostgreSQL gets user_setting_values with the scope CHECK constraints, the five
partial unique indexes that enforce one explicit value per identity, and the
covering indexes the one-query read path needs, plus user_setting_mutations for
mutation_id idempotency and the inert user_setting_migration_rejects audit
table. The per-user SQLite store gets the same shape minus user_id, since that
database is already user-scoped.

The UserStore interface grows the typed operations: read one explicit value at
one scope, collect every candidate row for a resolution request in a single
query, upsert with a revision increment, unset, and the idempotency receipt
operations. The resolution read deliberately returns unranked candidates so the
resolver can rank in Go — one query per request, never one per scope, which the
pgx query-count test pins.

Delete behavior is application-enforced. Neither backend can inherit it from
constraints: the SQLite store declares no foreign keys, and library, series and
device columns are not FK targets in Postgres either. Profile deletion cascades
to profile-anchored values while account scope survives, forgetting a device
clears its profile_device values alongside the legacy overrides, and the
library/series purges remove only what is scoped to that entity.

The shared conformance suite covers all of it, including the set-versus-unset
distinction for false, 0, "" and null, so a divergence between the two backends
fails a test rather than reaching a client.

Part of #376

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 01:22:23 +00:00

853 lines
30 KiB
Go

package storetest
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// RunSettingValues runs the canonical settings-contract storage conformance
// tests. It is exposed separately from RunSuite so each backend can pin this
// behavior on its own, which is what keeps the PostgreSQL and per-user SQLite
// stores from drifting on the table the whole contract rests on.
func RunSettingValues(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
t.Run("ExplicitValuesPerScope", func(t *testing.T) {
testSettingValueScopes(t, newStore)
})
t.Run("UnsetIsNotFalsy", func(t *testing.T) {
testSettingValueUnsetIsNotFalsy(t, newStore)
})
t.Run("RevisionIncrements", func(t *testing.T) {
testSettingValueRevisions(t, newStore)
})
t.Run("PartialUniqueness", func(t *testing.T) {
testSettingValuePartialUniqueness(t, newStore)
})
t.Run("IdentityValidation", func(t *testing.T) {
testSettingValueIdentityValidation(t, newStore)
})
t.Run("ResolutionCandidates", func(t *testing.T) {
testSettingValueResolution(t, newStore)
})
t.Run("DeletePaths", func(t *testing.T) {
testSettingValueDeletePaths(t, newStore)
})
t.Run("MutationIdempotency", func(t *testing.T) {
testSettingMutationIdempotency(t, newStore)
})
}
const (
audioKey = "playback.audio_language"
subtitleKey = "playback.subtitle_mode"
)
// seedSettingProfiles creates the profiles every setting-value test addresses.
// The PostgreSQL table carries a composite profile FK, so a profile-anchored row
// cannot be written for a profile that does not exist.
func seedSettingProfiles(t *testing.T, ctx context.Context, store userstore.UserStore, ids ...string) {
t.Helper()
for _, id := range ids {
if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: "Profile " + id}); err != nil {
t.Fatalf("CreateProfile(%s): %v", id, err)
}
}
}
func accountID(key string) userstore.SettingIdentity {
return userstore.SettingIdentity{Key: key, Scope: settingscontract.ScopeAccount}
}
func profileID(key, profile string) userstore.SettingIdentity {
return userstore.SettingIdentity{Key: key, Scope: settingscontract.ScopeProfile, ProfileID: profile}
}
func deviceID(key, profile, device string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice, ProfileID: profile, DeviceID: device,
}
}
func libraryID(key, profile string, library int) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileLibrary, ProfileID: profile, LibraryID: library,
}
}
func seriesID(key, profile, series string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileSeries, ProfileID: profile, SeriesID: series,
}
}
func mustUpsert(
t *testing.T,
ctx context.Context,
store userstore.UserStore,
id userstore.SettingIdentity,
value string,
) userstore.SettingValue {
t.Helper()
stored, err := store.UpsertSettingValue(ctx, id, json.RawMessage(value))
if err != nil {
t.Fatalf("UpsertSettingValue(%s at %s): %v", id.Key, id.Scope, err)
}
if stored == nil {
t.Fatalf("UpsertSettingValue(%s at %s) returned nil", id.Key, id.Scope)
}
if stored.SettingIdentity != id {
t.Fatalf("UpsertSettingValue echoed identity %+v, want %+v", stored.SettingIdentity, id)
}
if !jsonEqual(stored.Value, json.RawMessage(value)) {
t.Fatalf("UpsertSettingValue stored %s, want %s", stored.Value, value)
}
return *stored
}
// testSettingValueScopes pins that every remote scope stores and reads back its
// own explicit value, that scopes do not read each other, and that an unset
// identity is nil rather than a zero value.
func testSettingValueScopes(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1")
cases := []struct {
name string
id userstore.SettingIdentity
value string
}{
{"account", accountID(audioKey), `"en"`},
{"profile", profileID(audioKey, "p1"), `"fr"`},
{"profile_device", deviceID(audioKey, "p1", "apple-tv"), `"de"`},
{"profile_library", libraryID(audioKey, "p1", 42), `"es"`},
{"profile_series", seriesID(audioKey, "p1", "series-1"), `"ja"`},
}
for _, tc := range cases {
missing, err := store.GetSettingValue(ctx, tc.id)
if err != nil {
t.Fatalf("GetSettingValue(%s, unset): %v", tc.name, err)
}
if missing != nil {
t.Fatalf("GetSettingValue(%s, unset) = %+v, want nil", tc.name, missing)
}
}
for _, tc := range cases {
mustUpsert(t, ctx, store, tc.id, tc.value)
}
for _, tc := range cases {
got, err := store.GetSettingValue(ctx, tc.id)
if err != nil {
t.Fatalf("GetSettingValue(%s): %v", tc.name, err)
}
if got == nil {
t.Fatalf("GetSettingValue(%s) = nil, want a stored value", tc.name)
}
if !jsonEqual(got.Value, json.RawMessage(tc.value)) {
t.Fatalf("GetSettingValue(%s) = %s, want %s", tc.name, got.Value, tc.value)
}
if got.Revision != 1 {
t.Fatalf("GetSettingValue(%s) revision = %d, want 1", tc.name, got.Revision)
}
if got.CreatedAt == "" || got.UpdatedAt == "" {
t.Fatalf("GetSettingValue(%s) timestamps = %q/%q, want both set", tc.name, got.CreatedAt, got.UpdatedAt)
}
if _, err := time.Parse(time.RFC3339, got.UpdatedAt); err != nil {
t.Fatalf("GetSettingValue(%s) updated_at %q is not RFC3339: %v", tc.name, got.UpdatedAt, err)
}
}
// A different profile, device, library or series is a different identity and
// must not see the values above.
seedSettingProfiles(t, ctx, store, "p2")
for _, id := range []userstore.SettingIdentity{
profileID(audioKey, "p2"),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 43),
seriesID(audioKey, "p1", "series-2"),
} {
got, err := store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("GetSettingValue(neighbor %+v): %v", id, err)
}
if got != nil {
t.Fatalf("GetSettingValue(neighbor %+v) = %+v, want nil", id, got)
}
}
}
// testSettingValueUnsetIsNotFalsy pins the distinction the whole contract rests
// on: false, 0, "" and JSON null are stored values, and only deleting the row
// makes a setting unset.
func testSettingValueUnsetIsNotFalsy(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1")
falsy := []struct {
key string
value string
}{
{"playback.show_forced_subtitles", `false`},
{"playback.next_up_prompt_seconds", `0`},
{"catalog.metadata_language", `""`},
{"playback.subtitle_language", `null`},
}
for _, tc := range falsy {
id := profileID(tc.key, "p1")
mustUpsert(t, ctx, store, id, tc.value)
got, err := store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("GetSettingValue(%s): %v", tc.key, err)
}
if got == nil {
t.Fatalf("GetSettingValue(%s) = nil; %s must be a stored value, not unset", tc.key, tc.value)
}
if !jsonEqual(got.Value, json.RawMessage(tc.value)) {
t.Fatalf("GetSettingValue(%s) = %s, want %s", tc.key, got.Value, tc.value)
}
removed, err := store.DeleteSettingValue(ctx, id)
if err != nil {
t.Fatalf("DeleteSettingValue(%s): %v", tc.key, err)
}
if !removed {
t.Fatalf("DeleteSettingValue(%s) reported no row; %s was stored", tc.key, tc.value)
}
got, err = store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("GetSettingValue(%s, after unset): %v", tc.key, err)
}
if got != nil {
t.Fatalf("GetSettingValue(%s, after unset) = %+v, want nil", tc.key, got)
}
removed, err = store.DeleteSettingValue(ctx, id)
if err != nil {
t.Fatalf("DeleteSettingValue(%s, repeat): %v", tc.key, err)
}
if removed {
t.Fatalf("DeleteSettingValue(%s, repeat) reported a row; the value was already unset", tc.key)
}
}
}
// testSettingValueRevisions pins last-write-wins with a per-row revision: each
// write replaces the value and increments revision, and created_at is not
// rewritten.
func testSettingValueRevisions(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1")
id := profileID(audioKey, "p1")
first := mustUpsert(t, ctx, store, id, `"en"`)
if first.Revision != 1 {
t.Fatalf("first write revision = %d, want 1", first.Revision)
}
second := mustUpsert(t, ctx, store, id, `"ja"`)
if second.Revision != 2 {
t.Fatalf("second write revision = %d, want 2", second.Revision)
}
if second.CreatedAt != first.CreatedAt {
t.Fatalf("second write rewrote created_at %q -> %q", first.CreatedAt, second.CreatedAt)
}
third := mustUpsert(t, ctx, store, id, `"de"`)
if third.Revision != 3 {
t.Fatalf("third write revision = %d, want 3", third.Revision)
}
got, err := store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("GetSettingValue: %v", err)
}
if got == nil || !jsonEqual(got.Value, json.RawMessage(`"de"`)) || got.Revision != 3 {
t.Fatalf("GetSettingValue = %+v, want the newest write at revision 3", got)
}
// A re-set after an unset starts a fresh row rather than resurrecting the
// old revision counter.
if _, err := store.DeleteSettingValue(ctx, id); err != nil {
t.Fatalf("DeleteSettingValue: %v", err)
}
reset := mustUpsert(t, ctx, store, id, `"it"`)
if reset.Revision != 1 {
t.Fatalf("revision after unset/re-set = %d, want 1", reset.Revision)
}
}
// testSettingValuePartialUniqueness pins the five partial unique indexes: one
// explicit value per identity, and identities that differ in any one context
// column coexist.
func testSettingValuePartialUniqueness(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1", "p2")
identities := []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
profileID(audioKey, "p2"),
deviceID(audioKey, "p1", "apple-tv"),
deviceID(audioKey, "p1", "iphone"),
deviceID(audioKey, "p2", "apple-tv"),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", "s-1"),
seriesID(audioKey, "p1", "s-2"),
}
// Two writes each: the second must update its own row, never insert a
// duplicate at the same identity.
for _, id := range identities {
mustUpsert(t, ctx, store, id, `"en"`)
mustUpsert(t, ctx, store, id, `"ja"`)
}
rows, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileID: "p1",
DeviceID: "apple-tv",
LibraryIDs: []int{1, 2},
SeriesIDs: []string{"s-1", "s-2"},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution: %v", err)
}
// p1's candidates: account, profile, one device, two libraries, two series.
want := []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
deviceID(audioKey, "p1", "apple-tv"),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", "s-1"),
seriesID(audioKey, "p1", "s-2"),
}
assertIdentitySet(t, rows, want)
for _, row := range rows {
if row.Revision != 2 {
t.Fatalf("identity %+v has revision %d, want 2 — the second write inserted a duplicate row",
row.SettingIdentity, row.Revision)
}
}
}
// testSettingValueIdentityValidation pins that both backends reject the same
// malformed identities and values, with the same sentinel errors, before any SQL
// runs. A scope CHECK violation surfacing as a driver error would read
// differently in each backend.
func testSettingValueIdentityValidation(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1")
invalid := []struct {
name string
id userstore.SettingIdentity
}{
{"empty key", userstore.SettingIdentity{Scope: settingscontract.ScopeProfile, ProfileID: "p1"}},
{"blank key", userstore.SettingIdentity{Key: " ", Scope: settingscontract.ScopeProfile, ProfileID: "p1"}},
{"unknown scope", userstore.SettingIdentity{Key: audioKey, Scope: "wishful"}},
{"client_local is not remote", userstore.SettingIdentity{Key: audioKey, Scope: settingscontract.ScopeClientLocal}},
{"default is not remote", userstore.SettingIdentity{Key: audioKey, Scope: settingscontract.ScopeDefault}},
{"profile scope without profile", userstore.SettingIdentity{Key: audioKey, Scope: settingscontract.ScopeProfile}},
{"account scope with profile", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeAccount, ProfileID: "p1",
}},
{"device scope without device", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileDevice, ProfileID: "p1",
}},
{"profile scope with device", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfile, ProfileID: "p1", DeviceID: "apple-tv",
}},
{"library scope without library", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileLibrary, ProfileID: "p1",
}},
{"series scope with library", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1", SeriesID: "s-1", LibraryID: 4,
}},
{"series scope without series", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1",
}},
}
for _, tc := range invalid {
if _, err := store.UpsertSettingValue(ctx, tc.id, json.RawMessage(`"en"`)); !errors.Is(err, userstore.ErrInvalidSettingIdentity) {
t.Fatalf("UpsertSettingValue(%s) error = %v, want ErrInvalidSettingIdentity", tc.name, err)
}
if _, err := store.GetSettingValue(ctx, tc.id); !errors.Is(err, userstore.ErrInvalidSettingIdentity) {
t.Fatalf("GetSettingValue(%s) error = %v, want ErrInvalidSettingIdentity", tc.name, err)
}
if _, err := store.DeleteSettingValue(ctx, tc.id); !errors.Is(err, userstore.ErrInvalidSettingIdentity) {
t.Fatalf("DeleteSettingValue(%s) error = %v, want ErrInvalidSettingIdentity", tc.name, err)
}
}
valid := profileID(audioKey, "p1")
for _, tc := range []struct {
name string
value json.RawMessage
}{
{"empty", nil},
{"truncated object", json.RawMessage(`{"fontScale":`)},
{"bare word", json.RawMessage(`nope`)},
} {
if _, err := store.UpsertSettingValue(ctx, valid, tc.value); !errors.Is(err, userstore.ErrInvalidSettingValue) {
t.Fatalf("UpsertSettingValue(%s value) error = %v, want ErrInvalidSettingValue", tc.name, err)
}
}
}
// testSettingValueResolution pins the read path's normative rule: one query
// returns every candidate row for a resolution request, unranked, and nothing
// belonging to another identity. Ranking is the resolver's job in Go.
func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1", "p2")
mustUpsert(t, ctx, store, accountID(audioKey), `"en"`)
mustUpsert(t, ctx, store, profileID(audioKey, "p1"), `"fr"`)
mustUpsert(t, ctx, store, deviceID(audioKey, "p1", "apple-tv"), `"de"`)
mustUpsert(t, ctx, store, libraryID(audioKey, "p1", 42), `"es"`)
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", "s-1"), `"ja"`)
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", "s-2"), `"ko"`)
mustUpsert(t, ctx, store, profileID(subtitleKey, "p1"), `"forced"`)
// Decoys: another profile, another device, another library, another series,
// and a key nobody asked for.
mustUpsert(t, ctx, store, profileID(audioKey, "p2"), `"pt"`)
mustUpsert(t, ctx, store, deviceID(audioKey, "p1", "iphone"), `"nl"`)
mustUpsert(t, ctx, store, libraryID(audioKey, "p1", 43), `"sv"`)
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", "s-3"), `"da"`)
mustUpsert(t, ctx, store, profileID("ui.library_page_state", "p1"), `{"sort":"title"}`)
// The batched shape: two content contexts resolved in one call.
rows, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey, subtitleKey},
ProfileID: "p1",
DeviceID: "apple-tv",
LibraryIDs: []int{42},
SeriesIDs: []string{"s-1", "s-2"},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution: %v", err)
}
assertIdentitySet(t, rows, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
deviceID(audioKey, "p1", "apple-tv"),
libraryID(audioKey, "p1", 42),
seriesID(audioKey, "p1", "s-1"),
seriesID(audioKey, "p1", "s-2"),
profileID(subtitleKey, "p1"),
})
// A batch resolves the same rows n single-context calls would, which is what
// lets a list view make one round trip instead of one per item.
for _, series := range []string{"s-1", "s-2"} {
single, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileID: "p1",
DeviceID: "apple-tv",
SeriesIDs: []string{series},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(%s): %v", series, err)
}
assertIdentitySet(t, single, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
deviceID(audioKey, "p1", "apple-tv"),
seriesID(audioKey, "p1", series),
})
}
// No device identity — an incognito window, or jellycompat's seed — drops
// profile_device candidates without touching the roaming ones.
noDevice, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileID: "p1",
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(no device): %v", err)
}
assertIdentitySet(t, noDevice, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
})
// No profile at all leaves only account scope.
accountOnly, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(account only): %v", err)
}
assertIdentitySet(t, accountOnly, []userstore.SettingIdentity{accountID(audioKey)})
// Blank and duplicate context ids are compacted rather than bound as
// literals, so they neither match a '' row nor multiply the result set.
dirty, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey, "", audioKey, " "},
ProfileID: "p1",
DeviceID: "apple-tv",
LibraryIDs: []int{42, 42, 0, -1},
SeriesIDs: []string{"s-1", "s-1", "", " "},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(dirty): %v", err)
}
assertIdentitySet(t, dirty, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
deviceID(audioKey, "p1", "apple-tv"),
libraryID(audioKey, "p1", 42),
seriesID(audioKey, "p1", "s-1"),
})
// No keys is not an error and is not "everything".
none, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{ProfileID: "p1"})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(no keys): %v", err)
}
if len(none) != 0 {
t.Fatalf("ListSettingValuesForResolution(no keys) = %d rows, want 0", len(none))
}
// An unknown key resolves to nothing rather than erroring; rejecting unknown
// keys is the contract layer's job, not the store's.
unknown, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{"playback.not_a_setting"},
ProfileID: "p1",
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(unknown key): %v", err)
}
if len(unknown) != 0 {
t.Fatalf("ListSettingValuesForResolution(unknown key) = %d rows, want 0", len(unknown))
}
}
// testSettingValueDeletePaths pins the application-enforced delete behavior.
// Neither backend can inherit this from constraints: the per-user SQLite store
// declares no foreign keys, and library, series and device columns reference
// nothing in PostgreSQL either.
func testSettingValueDeletePaths(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
// seed writes one value at every scope for two profiles, two devices, two
// libraries and two series, so each delete can be checked for over-reach.
seed := func(t *testing.T) (userstore.UserStore, []userstore.SettingIdentity) {
t.Helper()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1", "p2")
identities := []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
profileID(audioKey, "p2"),
deviceID(audioKey, "p1", "apple-tv"),
deviceID(audioKey, "p1", "iphone"),
deviceID(audioKey, "p2", "apple-tv"),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
libraryID(audioKey, "p2", 1),
seriesID(audioKey, "p1", "s-1"),
seriesID(audioKey, "p1", "s-2"),
seriesID(audioKey, "p2", "s-1"),
}
for _, id := range identities {
mustUpsert(t, ctx, store, id, `"en"`)
}
return store, identities
}
assertRemaining := func(t *testing.T, store userstore.UserStore, all, removed []userstore.SettingIdentity) {
t.Helper()
gone := make(map[userstore.SettingIdentity]struct{}, len(removed))
for _, id := range removed {
gone[id] = struct{}{}
}
for _, id := range all {
got, err := store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("GetSettingValue(%+v): %v", id, err)
}
_, shouldBeGone := gone[id]
if shouldBeGone && got != nil {
t.Fatalf("identity %+v survived a delete that owns it", id)
}
if !shouldBeGone && got == nil {
t.Fatalf("identity %+v was removed by a delete that does not own it", id)
}
}
}
t.Run("Device", func(t *testing.T) {
store, all := seed(t)
removed, err := store.DeleteSettingValuesForDevice(ctx, "p1", "apple-tv")
if err != nil {
t.Fatalf("DeleteSettingValuesForDevice: %v", err)
}
if removed != 1 {
t.Fatalf("DeleteSettingValuesForDevice removed %d rows, want 1", removed)
}
assertRemaining(t, store, all, []userstore.SettingIdentity{deviceID(audioKey, "p1", "apple-tv")})
})
t.Run("ForgetDeviceThroughDeviceSettings", func(t *testing.T) {
store, all := seed(t)
// DeleteAllDeviceSettings is the forget-device path: it must clear the
// canonical profile_device values alongside the legacy string overrides.
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
ProfileID: "p1", DeviceID: "apple-tv", Key: "player.playback_speed", Value: "1.25",
}); err != nil {
t.Fatalf("SetDeviceSetting: %v", err)
}
if err := store.DeleteAllDeviceSettings(ctx, "p1", "apple-tv"); err != nil {
t.Fatalf("DeleteAllDeviceSettings: %v", err)
}
legacy, err := store.GetDeviceSetting(ctx, "p1", "apple-tv", "player.playback_speed")
if err != nil {
t.Fatalf("GetDeviceSetting after forget: %v", err)
}
if legacy != nil {
t.Fatalf("GetDeviceSetting after forget = %+v, want nil", legacy)
}
assertRemaining(t, store, all, []userstore.SettingIdentity{deviceID(audioKey, "p1", "apple-tv")})
})
t.Run("Library", func(t *testing.T) {
store, all := seed(t)
removed, err := store.DeleteSettingValuesForLibrary(ctx, 1)
if err != nil {
t.Fatalf("DeleteSettingValuesForLibrary: %v", err)
}
if removed != 2 {
t.Fatalf("DeleteSettingValuesForLibrary removed %d rows, want 2 (one per profile)", removed)
}
assertRemaining(t, store, all, []userstore.SettingIdentity{
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p2", 1),
})
})
t.Run("Series", func(t *testing.T) {
store, all := seed(t)
removed, err := store.DeleteSettingValuesForSeries(ctx, "s-1")
if err != nil {
t.Fatalf("DeleteSettingValuesForSeries: %v", err)
}
if removed != 2 {
t.Fatalf("DeleteSettingValuesForSeries removed %d rows, want 2 (one per profile)", removed)
}
assertRemaining(t, store, all, []userstore.SettingIdentity{
seriesID(audioKey, "p1", "s-1"),
seriesID(audioKey, "p2", "s-1"),
})
})
t.Run("Profile", func(t *testing.T) {
store, all := seed(t)
removed, err := store.DeleteSettingValuesForProfile(ctx, "p1")
if err != nil {
t.Fatalf("DeleteSettingValuesForProfile: %v", err)
}
if removed != 7 {
t.Fatalf("DeleteSettingValuesForProfile removed %d rows, want 7", removed)
}
assertRemaining(t, store, all, []userstore.SettingIdentity{
profileID(audioKey, "p1"),
deviceID(audioKey, "p1", "apple-tv"),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", "s-1"),
seriesID(audioKey, "p1", "s-2"),
})
})
t.Run("DeleteProfileCascades", func(t *testing.T) {
store, all := seed(t)
if err := store.DeleteProfile(ctx, "p1"); err != nil {
t.Fatalf("DeleteProfile: %v", err)
}
// Account scope belongs to the account, not to any one household member.
assertRemaining(t, store, all, []userstore.SettingIdentity{
profileID(audioKey, "p1"),
deviceID(audioKey, "p1", "apple-tv"),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", "s-1"),
seriesID(audioKey, "p1", "s-2"),
})
})
}
// testSettingMutationIdempotency pins the receipt storage behind
// mutation_id idempotency: a receipt is written once and never overwritten, a
// replay reads back the original result, and expired receipts are sweepable.
func testSettingMutationIdempotency(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
expires := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second)
record := userstore.SettingMutationRecord{
MutationID: "8cc515ad-88c5-48f0-a6cc-44d0a870e32c",
RequestHash: "hash-a",
Result: json.RawMessage(`{"status":"applied"}`),
ExpiresAt: expires,
}
missing, err := store.GetSettingMutation(ctx, record.MutationID)
if err != nil {
t.Fatalf("GetSettingMutation(unrecorded): %v", err)
}
if missing != nil {
t.Fatalf("GetSettingMutation(unrecorded) = %+v, want nil", missing)
}
stored, inserted, err := store.PutSettingMutation(ctx, record)
if err != nil {
t.Fatalf("PutSettingMutation: %v", err)
}
if !inserted {
t.Fatal("PutSettingMutation reported no insertion for a new mutation id")
}
if stored.RequestHash != "hash-a" || !jsonEqual(stored.Result, record.Result) {
t.Fatalf("PutSettingMutation stored %+v, want the submitted receipt", stored)
}
if !stored.ExpiresAt.Equal(expires) {
t.Fatalf("PutSettingMutation expires_at = %s, want %s", stored.ExpiresAt, expires)
}
if stored.CreatedAt.IsZero() {
t.Fatal("PutSettingMutation left created_at zero")
}
// A replay of the same id must read back the first result, whatever the
// second attempt carries: that is what makes a retry idempotent instead of a
// silent re-run, and what lets the caller answer mutation_id_conflict.
replay := record
replay.RequestHash = "hash-b"
replay.Result = json.RawMessage(`{"status":"invalid_value"}`)
existing, inserted, err := store.PutSettingMutation(ctx, replay)
if err != nil {
t.Fatalf("PutSettingMutation(replay): %v", err)
}
if inserted {
t.Fatal("PutSettingMutation(replay) reported an insertion; the receipt already existed")
}
if existing.RequestHash != "hash-a" {
t.Fatalf("PutSettingMutation(replay) request hash = %q, want the original hash-a", existing.RequestHash)
}
if !jsonEqual(existing.Result, record.Result) {
t.Fatalf("PutSettingMutation(replay) result = %s, want the original result", existing.Result)
}
got, err := store.GetSettingMutation(ctx, record.MutationID)
if err != nil {
t.Fatalf("GetSettingMutation: %v", err)
}
if got == nil || got.RequestHash != "hash-a" {
t.Fatalf("GetSettingMutation = %+v, want the original receipt", got)
}
// Expiry is not self-enforcing; the sweeper removes only what has expired.
expired := userstore.SettingMutationRecord{
MutationID: "5ae96ffc-1077-4da8-8f64-a1ca9c3c72b8",
RequestHash: "hash-c",
Result: json.RawMessage(`{"status":"applied"}`),
ExpiresAt: time.Now().UTC().Add(-time.Hour),
}
if _, _, err := store.PutSettingMutation(ctx, expired); err != nil {
t.Fatalf("PutSettingMutation(expired): %v", err)
}
swept, err := store.DeleteExpiredSettingMutations(ctx, time.Now().UTC())
if err != nil {
t.Fatalf("DeleteExpiredSettingMutations: %v", err)
}
if swept != 1 {
t.Fatalf("DeleteExpiredSettingMutations swept %d rows, want 1", swept)
}
if got, err := store.GetSettingMutation(ctx, expired.MutationID); err != nil || got != nil {
t.Fatalf("GetSettingMutation(expired) = %+v (%v), want nil", got, err)
}
if got, err := store.GetSettingMutation(ctx, record.MutationID); err != nil || got == nil {
t.Fatalf("GetSettingMutation(live) = %+v (%v), want the unexpired receipt", got, err)
}
invalid := []userstore.SettingMutationRecord{
{RequestHash: "h", Result: json.RawMessage(`{}`), ExpiresAt: expires},
{MutationID: "m", Result: json.RawMessage(`{}`), ExpiresAt: expires},
{MutationID: "m", RequestHash: "h", Result: json.RawMessage(`{}`)},
}
for i, rec := range invalid {
if _, _, err := store.PutSettingMutation(ctx, rec); !errors.Is(err, userstore.ErrInvalidSettingIdentity) {
t.Fatalf("PutSettingMutation(invalid %d) error = %v, want ErrInvalidSettingIdentity", i, err)
}
}
if _, _, err := store.PutSettingMutation(ctx, userstore.SettingMutationRecord{
MutationID: "m", RequestHash: "h", ExpiresAt: expires,
}); !errors.Is(err, userstore.ErrInvalidSettingValue) {
t.Fatalf("PutSettingMutation(no result) error = %v, want ErrInvalidSettingValue", err)
}
}
// assertIdentitySet compares the returned rows to the expected identities as a
// set. Row order is deliberately not asserted: the two backends sort text under
// different collations, and ranking is the resolver's job anyway.
func assertIdentitySet(t *testing.T, rows []userstore.SettingValue, want []userstore.SettingIdentity) {
t.Helper()
got := make([]string, 0, len(rows))
for _, row := range rows {
got = append(got, identityToken(row.SettingIdentity))
}
expected := make([]string, 0, len(want))
for _, id := range want {
expected = append(expected, identityToken(id))
}
sort.Strings(got)
sort.Strings(expected)
if !reflect.DeepEqual(got, expected) {
t.Fatalf("candidate identities =\n %v\nwant\n %v", got, expected)
}
}
func identityToken(id userstore.SettingIdentity) string {
return fmt.Sprintf("%s|%s|%s|%s|%d|%s",
id.Key, id.Scope, id.ProfileID, id.DeviceID, id.LibraryID, id.SeriesID)
}
// jsonEqual compares two JSON documents by value. PostgreSQL stores jsonb, which
// re-serializes objects in its own key order, so a byte comparison would report
// a difference between the backends that no client can observe.
func jsonEqual(a, b json.RawMessage) bool {
var left, right any
if err := json.Unmarshal(a, &left); err != nil {
return false
}
if err := json.Unmarshal(b, &right); err != nil {
return false
}
return reflect.DeepEqual(left, right)
}