Files
silo-server/internal/userstore/storetest/settingvalues.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

1330 lines
49 KiB
Go

package storetest
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"sync"
"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("CompareAndSet", func(t *testing.T) {
testSettingValueCompareAndSet(t, newStore)
})
t.Run("PartialUniqueness", func(t *testing.T) {
testSettingValuePartialUniqueness(t, newStore)
})
t.Run("IdentityValidation", func(t *testing.T) {
testSettingValueIdentityValidation(t, newStore)
})
t.Run("PreferenceTransactionRollback", func(t *testing.T) {
testPreferenceSettingsTransactionRollback(t, newStore)
})
t.Run("ResolutionCandidates", func(t *testing.T) {
testSettingValueResolution(t, newStore)
})
t.Run("ListAll", func(t *testing.T) {
testSettingValueListAll(t, newStore)
})
t.Run("DeletePaths", func(t *testing.T) {
testSettingValueDeletePaths(t, newStore)
})
t.Run("MutationIdempotency", func(t *testing.T) {
testSettingMutationIdempotency(t, newStore)
})
t.Run("MutationTransaction", func(t *testing.T) {
testSettingMutationTransaction(t, newStore)
})
t.Run("DeviceExists", func(t *testing.T) {
testDeviceExists(t, newStore)
})
}
// testSettingValueCompareAndSet pins the internal primitive used by semantic
// document mutations. A stale writer must lose without changing the row, and
// an insert-if-absent race must have exactly one winner.
func testSettingValueCompareAndSet(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
cas, ok := store.(userstore.SettingValueCompareAndSetter)
if !ok {
t.Skip("store does not implement SettingValueCompareAndSetter")
}
seedSettingProfiles(t, ctx, store, "p1")
id := profileID(audioKey, "p1")
first, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"en"`), 0)
if err != nil {
t.Fatalf("insert-if-absent: %v", err)
}
if first == nil || first.Revision != 1 || !jsonEqual(first.Value, json.RawMessage(`"en"`)) {
t.Fatalf("insert-if-absent = %+v, want en at revision 1", first)
}
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"fr"`), 0); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("second insert-if-absent error = %v, want ErrSettingValueRevisionConflict", err)
}
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"de"`), first.Revision+1); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("future revision error = %v, want ErrSettingValueRevisionConflict", err)
}
second, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"ja"`), first.Revision)
if err != nil {
t.Fatalf("compare-and-set current revision: %v", err)
}
if second == nil || second.Revision != 2 || !jsonEqual(second.Value, json.RawMessage(`"ja"`)) {
t.Fatalf("compare-and-set = %+v, want ja at revision 2", second)
}
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"it"`), first.Revision); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("stale revision error = %v, want ErrSettingValueRevisionConflict", err)
}
got, err := store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("GetSettingValue: %v", err)
}
if got == nil || got.Revision != 2 || !jsonEqual(got.Value, json.RawMessage(`"ja"`)) {
t.Fatalf("row after stale CAS = %+v, want ja at revision 2", got)
}
}
// testDeviceExists pins the check that authorizes a device named in a request
// rather than taken from its headers. It must be scoped to the profile, not
// merely to the account: two profiles in one household register the same TV
// separately, and a device is only "yours" for the profile that registered it.
func testDeviceExists(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
t.Skip("store does not implement DeviceRegistry")
}
seedSettingProfiles(t, ctx, store, "p1", "p2")
if err := registry.RegisterDevice(ctx, userstore.DeviceEntry{
ProfileID: "p1", DeviceID: deviceApple, DeviceName: "Apple TV",
}); err != nil {
t.Fatalf("RegisterDevice: %v", err)
}
cases := []struct {
name string
profileID string
deviceID string
want bool
}{
{"registered", "p1", deviceApple, true},
{"other profile same device", "p2", deviceApple, false},
{"unknown device", "p1", "no-such-device", false},
{"empty device", "p1", "", false},
{"empty profile", "", deviceApple, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := registry.DeviceExists(ctx, tc.profileID, tc.deviceID)
if err != nil {
t.Fatalf("DeviceExists(%q, %q): %v", tc.profileID, tc.deviceID, err)
}
if got != tc.want {
t.Errorf("DeviceExists(%q, %q) = %v, want %v",
tc.profileID, tc.deviceID, got, tc.want)
}
})
}
}
// testPreferenceSettingsTransactionRollback proves that a failure after both
// the legacy row and an earlier canonical row were written rolls the whole
// group back. This is the failure ordering used by the shipped legacy profile,
// audio, subtitle and library preference routes during the canonical cutover.
func testPreferenceSettingsTransactionRollback(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
seedSettingProfiles(t, ctx, store, "p1")
legacy := userstore.AudioPreference{
ProfileID: "p1", SeriesID: seriesOne, AudioTrackIndex: 1, AudioLanguage: "en",
}
if err := store.SetAudioPreference(ctx, legacy); err != nil {
t.Fatalf("seed legacy preference: %v", err)
}
id := seriesID(audioKey, "p1", seriesOne)
seeded := mustUpsert(t, ctx, store, id, `"en"`)
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
if !ok {
t.Fatal("store does not implement PreferenceSettingsTransactioner")
}
err := transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
language := "ja"
if err := tx.UpdateProfile(ctx, "p1", userstore.UpdateProfileInput{Language: &language}); err != nil {
return err
}
updated := legacy
updated.AudioTrackIndex = 2
updated.AudioLanguage = "ja"
if err := tx.SetAudioPreference(ctx, updated); err != nil {
return err
}
if _, err := tx.UpsertSettingValue(ctx, id, json.RawMessage(`"ja"`)); err != nil {
return err
}
// Fail after both writes. An invalid identity is rejected consistently by
// both backends and represents any later canonical-write failure.
_, err := tx.UpsertSettingValue(ctx, userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.Scope("invalid"),
}, json.RawMessage(`"fr"`))
return err
})
if err == nil {
t.Fatal("transaction with invalid final write succeeded")
}
gotProfile, err := store.GetProfile(ctx, "p1")
if err != nil {
t.Fatalf("read profile after rollback: %v", err)
}
if gotProfile == nil || gotProfile.Language != "" {
t.Fatalf("profile language after rollback = %+v, want empty seeded value", gotProfile)
}
gotLegacy, err := store.GetAudioPreference(ctx, "p1", seriesOne)
if err != nil {
t.Fatalf("read legacy preference after rollback: %v", err)
}
if gotLegacy == nil || gotLegacy.AudioTrackIndex != 1 || gotLegacy.AudioLanguage != "en" {
t.Fatalf("legacy preference after rollback = %+v, want seeded value", gotLegacy)
}
gotCanonical, err := store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("read canonical value after rollback: %v", err)
}
if gotCanonical == nil || !jsonEqual(gotCanonical.Value, json.RawMessage(`"en"`)) {
t.Fatalf("canonical value after rollback = %+v, want seeded value", gotCanonical)
}
if gotCanonical.Revision != seeded.Revision {
t.Fatalf("canonical revision after rollback = %d, want %d", gotCanonical.Revision, seeded.Revision)
}
}
const (
audioKey = "playback.audio_language"
subtitleKey = "playback.subtitle_mode"
// Fixture identities. Named so a backend that mixes up two scope columns
// fails on the assertion rather than on a typo in one of the literals.
deviceApple = "apple-tv"
seriesOne = "s-1"
seriesTwo = "s-2"
mutationHash = "hash-a"
)
// 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 clientID(key, profile string, family settingscontract.ClientFamily) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileClient, ProfileID: profile, ClientFamily: family,
}
}
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_client", clientID(audioKey, "p1", settingscontract.ClientFamilyTV), `"it"`},
{"profile_device", deviceID(audioKey, "p1", deviceApple), `"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"),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
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)
}
}
}
// testSettingValueListAll pins the admin inspection read: every stored value
// comes back regardless of scope, with its identity intact, and nothing is
// invented for identities that were never written.
func testSettingValueListAll(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
empty, err := store.ListAllSettingValues(ctx)
if err != nil {
t.Fatalf("ListAllSettingValues(empty): %v", err)
}
if len(empty) != 0 {
t.Fatalf("ListAllSettingValues(empty) = %+v, want none", empty)
}
seedSettingProfiles(t, ctx, store, "p1", "p2")
seeded := map[userstore.SettingIdentity]string{
accountID(audioKey): `"en"`,
profileID(audioKey, "p1"): `"fr"`,
profileID(subtitleKey, "p2"): `"always"`,
clientID(audioKey, "p1", settingscontract.ClientFamilyTV): `"it"`,
deviceID(audioKey, "p1", deviceApple): `"de"`,
libraryID(audioKey, "p1", 42): `"es"`,
seriesID(audioKey, "p1", seriesOne): `"ja"`,
}
for id, value := range seeded {
mustUpsert(t, ctx, store, id, value)
}
all, err := store.ListAllSettingValues(ctx)
if err != nil {
t.Fatalf("ListAllSettingValues: %v", err)
}
if len(all) != len(seeded) {
t.Fatalf("ListAllSettingValues returned %d rows, want %d: %+v", len(all), len(seeded), all)
}
for _, got := range all {
want, ok := seeded[got.SettingIdentity]
if !ok {
t.Fatalf("ListAllSettingValues invented identity %+v", got.SettingIdentity)
}
if !jsonEqual(got.Value, json.RawMessage(want)) {
t.Fatalf("ListAllSettingValues(%+v) = %s, want %s", got.SettingIdentity, got.Value, want)
}
if got.Revision != 1 || got.UpdatedAt == "" {
t.Fatalf("ListAllSettingValues(%+v) revision/updated_at = %d/%q, want 1/set",
got.SettingIdentity, got.Revision, got.UpdatedAt)
}
}
}
// 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 six 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"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
clientID(audioKey, "p2", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
deviceID(audioKey, "p2", deviceApple),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", seriesOne),
seriesID(audioKey, "p1", seriesTwo),
}
// 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},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
LibraryIDs: []int{1, 2},
SeriesIDs: []string{seriesOne, seriesTwo},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution: %v", err)
}
// p1's candidates: account, profile, one client family, one device, two
// libraries, and two series.
want := []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", seriesOne),
seriesID(audioKey, "p1", seriesTwo),
}
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",
}},
{"client scope without family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1",
}},
{"client scope with unknown family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1", ClientFamily: "car",
}},
{"profile scope with family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfile, ProfileID: "p1", ClientFamily: settingscontract.ClientFamilyTV,
}},
{"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: deviceApple,
}},
{"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: seriesOne, LibraryID: 4,
}},
{"series scope without series", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1",
}},
// Padded ids must be rejected, not stored: the write path binds them
// verbatim while resolution queries bind their trimmed forms, so a
// padded id would persist as a row no resolution ever finds.
{"padded profile id", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfile, ProfileID: " p1 ",
}},
{"padded key", userstore.SettingIdentity{
Key: " " + audioKey, Scope: settingscontract.ScopeProfile, ProfileID: "p1",
}},
{"padded device id", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileDevice, ProfileID: "p1", DeviceID: deviceApple + " ",
}},
{"padded client family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1", ClientFamily: " tv ",
}},
{"padded series id", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1", SeriesID: " " + seriesOne,
}},
}
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, clientID(audioKey, "p1", settingscontract.ClientFamilyTV), `"it"`)
mustUpsert(t, ctx, store, deviceID(audioKey, "p1", deviceApple), `"de"`)
mustUpsert(t, ctx, store, libraryID(audioKey, "p1", 42), `"es"`)
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", seriesOne), `"ja"`)
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", seriesTwo), `"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, clientID(audioKey, "p1", settingscontract.ClientFamilyMobile), `"pl"`)
mustUpsert(t, ctx, store, clientID(audioKey, "p2", settingscontract.ClientFamilyTV), `"cs"`)
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},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
LibraryIDs: []int{42},
SeriesIDs: []string{seriesOne, seriesTwo},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution: %v", err)
}
assertIdentitySet(t, rows, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
libraryID(audioKey, "p1", 42),
seriesID(audioKey, "p1", seriesOne),
seriesID(audioKey, "p1", seriesTwo),
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{seriesOne, seriesTwo} {
single, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
SeriesIDs: []string{series},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(%s): %v", series, err)
}
assertIdentitySet(t, single, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
seriesID(audioKey, "p1", series),
})
}
// A family without a device still sees the like-client value while dropping
// exact-device candidates.
familyOnly, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(family only): %v", err)
}
assertIdentitySet(t, familyOnly, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
})
// No device or family identity — an incognito window, or jellycompat's seed
// — drops both contextual candidates without touching profile roaming.
noDevice, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"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)})
// The household shape: several profiles in one read, which is what lets
// GET /profiles serve a preference block per profile without a read each.
// Every profile's own rows come back, and one profile's value can never
// leak into another's because the identity travels on the row.
household, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"p1", "p2"},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(household): %v", err)
}
assertIdentitySet(t, household, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
profileID(audioKey, "p2"),
})
// 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, " "},
ProfileIDs: []string{"p1", "p1", "", " "},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
LibraryIDs: []int{42, 42, 0, -1},
SeriesIDs: []string{seriesOne, seriesOne, "", " "},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(dirty): %v", err)
}
assertIdentitySet(t, dirty, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
libraryID(audioKey, "p1", 42),
seriesID(audioKey, "p1", seriesOne),
})
// No keys is not an error and is not "everything".
none, err := store.ListSettingValuesForResolution(ctx,
userstore.SettingResolutionQuery{ProfileIDs: []string{"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"},
ProfileIDs: []string{"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"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
clientID(audioKey, "p2", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
deviceID(audioKey, "p2", deviceApple),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
libraryID(audioKey, "p2", 1),
seriesID(audioKey, "p1", seriesOne),
seriesID(audioKey, "p1", seriesTwo),
seriesID(audioKey, "p2", seriesOne),
}
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", deviceApple)
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", deviceApple)})
})
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: deviceApple, Key: "player.playback_speed", Value: "1.25",
}); err != nil {
t.Fatalf("SetDeviceSetting: %v", err)
}
if err := store.DeleteAllDeviceSettings(ctx, "p1", deviceApple); err != nil {
t.Fatalf("DeleteAllDeviceSettings: %v", err)
}
legacy, err := store.GetDeviceSetting(ctx, "p1", deviceApple, "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", deviceApple)})
})
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, seriesOne)
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", seriesOne),
seriesID(audioKey, "p2", seriesOne),
})
})
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 != 9 {
t.Fatalf("DeleteSettingValuesForProfile removed %d rows, want 9", removed)
}
assertRemaining(t, store, all, []userstore.SettingIdentity{
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", seriesOne),
seriesID(audioKey, "p1", seriesTwo),
})
})
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"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
seriesID(audioKey, "p1", seriesOne),
seriesID(audioKey, "p1", seriesTwo),
})
})
}
// 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: mutationHash,
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 != mutationHash || !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 != mutationHash {
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 != mutationHash {
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)
}
}
// testSettingMutationTransaction proves the storage primitive that handlers
// rely on: setting + receipt roll back together, and same-id contenders check
// the winning receipt before either can apply a second setting write.
func testSettingMutationTransaction(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
t.Run("RollbackIsAtomic", func(t *testing.T) {
store := newStore(t)
transactioner, ok := store.(userstore.SettingMutationTransactioner)
if !ok {
t.Skip("store does not implement SettingMutationTransactioner")
}
seedSettingProfiles(t, ctx, store, "p1")
id := profileID(audioKey, "p1")
rollbackErr := errors.New("simulate process failure before commit")
err := transactioner.WithSettingMutationTransaction(ctx, "tx-rollback",
func(writer userstore.SettingMutationWriter) error {
if _, err := writer.UpsertSettingValue(ctx, id, json.RawMessage(`"en"`)); err != nil {
return err
}
if _, _, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
MutationID: "tx-rollback", RequestHash: mutationHash,
Result: json.RawMessage(`{"winner":"en"}`), ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
return err
}
return rollbackErr
})
if !errors.Is(err, rollbackErr) {
t.Fatalf("transaction error = %v, want rollback sentinel", err)
}
if got, err := store.GetSettingValue(ctx, id); err != nil || got != nil {
t.Fatalf("setting after rollback = %+v (%v), want nil", got, err)
}
if got, err := store.GetSettingMutation(ctx, "tx-rollback"); err != nil || got != nil {
t.Fatalf("receipt after rollback = %+v (%v), want nil", got, err)
}
})
for _, tc := range []struct {
name string
secondHash string
second json.RawMessage
wantReplay int
wantConflict int
}{
{name: "SameHashReplays", secondHash: "hash-a", second: json.RawMessage(`"en"`), wantReplay: 1},
{name: "DifferentHashConflicts", secondHash: "hash-b", second: json.RawMessage(`"fr"`), wantConflict: 1},
} {
t.Run(tc.name, func(t *testing.T) {
store := newStore(t)
transactioner, ok := store.(userstore.SettingMutationTransactioner)
if !ok {
t.Skip("store does not implement SettingMutationTransactioner")
}
seedSettingProfiles(t, ctx, store, "p1")
id := profileID(audioKey, "p1")
type attempt struct {
hash string
value json.RawMessage
}
attempts := []attempt{{hash: "hash-a", value: json.RawMessage(`"en"`)}, {hash: tc.secondHash, value: tc.second}}
type result struct {
status string
receipt json.RawMessage
err error
}
results := make(chan result, len(attempts))
start := make(chan struct{})
var ready sync.WaitGroup
ready.Add(len(attempts))
for _, candidate := range attempts {
candidate := candidate
go func() {
ready.Done()
<-start
out := result{}
out.err = transactioner.WithSettingMutationTransaction(ctx, "tx-concurrent",
func(writer userstore.SettingMutationWriter) error {
prior, err := writer.GetSettingMutation(ctx, "tx-concurrent")
if err != nil {
return err
}
if prior != nil {
out.receipt = prior.Result
if prior.RequestHash == candidate.hash {
out.status = "replay"
} else {
out.status = "conflict"
}
return nil
}
stored, err := writer.UpsertSettingValue(ctx, id, candidate.value)
if err != nil {
return err
}
out.receipt, err = json.Marshal(map[string]any{"revision": stored.Revision, "value": candidate.value})
if err != nil {
return err
}
_, inserted, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
MutationID: "tx-concurrent", RequestHash: candidate.hash,
Result: out.receipt, ExpiresAt: time.Now().UTC().Add(time.Hour),
})
if err == nil && !inserted {
return errors.New("serialized transaction unexpectedly lost receipt insert")
}
out.status = "applied"
return err
})
results <- out
}()
}
ready.Wait()
close(start)
counts := map[string]int{}
var receipts []json.RawMessage
for range attempts {
out := <-results
if out.err != nil {
t.Fatalf("concurrent transaction: %v", out.err)
}
counts[out.status]++
receipts = append(receipts, out.receipt)
}
if counts["applied"] != 1 || counts["replay"] != tc.wantReplay || counts["conflict"] != tc.wantConflict {
t.Fatalf("outcomes = %+v, want one applied, %d replay, %d conflict", counts, tc.wantReplay, tc.wantConflict)
}
stored, err := store.GetSettingValue(ctx, id)
if err != nil || stored == nil || stored.Revision != 1 {
t.Fatalf("stored setting = %+v (%v), want exactly one revision", stored, err)
}
receipt, err := store.GetSettingMutation(ctx, "tx-concurrent")
if err != nil || receipt == nil {
t.Fatalf("stored receipt = %+v (%v)", receipt, err)
}
for _, got := range receipts {
if !jsonEqual(got, receipt.Result) {
t.Fatalf("attempt receipt = %s, want coherent winner %s", got, receipt.Result)
}
}
})
}
}
// 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|%s|%d|%s",
id.Key, id.Scope, id.ProfileID, id.ClientFamily, 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)
}