Files
silo-server/migrations/user_setting_values_test.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

79 lines
4.0 KiB
Go

package migrations
import (
"strings"
"testing"
)
// TestUserSettingValuesMigrationContract pins the parts of the canonical
// settings storage that the store code and the design both depend on: 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.
// A silent edit to any of them would not fail a store test until a duplicate row
// or a sequential-scan regression reached production.
func TestUserSettingValuesMigrationContract(t *testing.T) {
migration := readMigration(t, "sql/20260727010621_user_setting_values.sql")
for _, want := range []string{
"CREATE TABLE public.user_setting_values",
"value jsonb NOT NULL",
"revision bigint NOT NULL DEFAULT 1",
"CONSTRAINT user_setting_values_scope_check\n CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series'))",
"(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL)",
"(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL)",
"(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL)",
"(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL)",
"(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)",
// The cascades that exist today, and only those.
"CONSTRAINT user_setting_values_user_id_fkey\n FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE",
"CONSTRAINT user_setting_values_profile_fkey\n FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE",
// One explicit value per identity.
"CREATE UNIQUE INDEX user_setting_values_account_uq\n ON public.user_setting_values (user_id, key) WHERE scope = 'account'",
"CREATE UNIQUE INDEX user_setting_values_profile_uq\n ON public.user_setting_values (user_id, profile_id, key) WHERE scope = 'profile'",
"CREATE UNIQUE INDEX user_setting_values_profile_device_uq\n ON public.user_setting_values (user_id, profile_id, device_id, key) WHERE scope = 'profile_device'",
"CREATE UNIQUE INDEX user_setting_values_profile_library_uq\n ON public.user_setting_values (user_id, profile_id, library_id, key) WHERE scope = 'profile_library'",
"CREATE UNIQUE INDEX user_setting_values_profile_series_uq\n ON public.user_setting_values (user_id, profile_id, series_id, key) WHERE scope = 'profile_series'",
// The hot read path.
"ON public.user_setting_values (user_id, profile_id, key, scope)",
"ON public.user_setting_values (user_id, profile_id, series_id)",
"ON public.user_setting_values (user_id, profile_id, library_id)",
// Idempotency and the inert migration audit table.
"CREATE TABLE public.user_setting_mutations",
"CONSTRAINT user_setting_mutations_pkey PRIMARY KEY (user_id, mutation_id)",
"request_hash text NOT NULL",
"expires_at timestamptz NOT NULL",
"ON public.user_setting_mutations (expires_at)",
"CREATE TABLE public.user_setting_migration_rejects",
} {
if !strings.Contains(migration, want) {
t.Fatalf("migration missing %q", want)
}
}
// Library, series and device identity columns must stay reference-free: the
// per-user SQLite store has no foreign keys at all, so inheriting cleanup
// from constraints here would let the two backends drift.
for _, forbidden := range []string{
"REFERENCES public.library_folders",
"REFERENCES public.media_items",
"REFERENCES public.user_devices",
} {
if strings.Contains(migration, forbidden) {
t.Fatalf("migration must not add %q; delete behavior is application-enforced", forbidden)
}
}
}
func readMigration(t *testing.T, path string) string {
t.Helper()
contents, err := FS.ReadFile(path)
if err != nil {
t.Fatalf("read migration %s: %v", path, err)
}
return string(contents)
}