Files
silo-server/internal/userdb/setting_values_migrate_test.go
T
af974edfe9 feat(settings): add playback.intro_skip_mode and deprecate auto_skip_intro
Skipping intros stops being a switch and becomes a three-way choice —
never / ask / always — matching what Jellyfin offers and giving viewers a
way to turn the prompt off, which the boolean could not express.

Contract revision 6 → 7: adds playback.intro_skip_mode (enum, default
"ask", profile + profile_device scopes) and marks playback.auto_skip_intro
deprecated without removing it. Every shipped client still reads the
boolean, so for one release the server keeps the pair in step at write
time: canonical PUT/DELETE, the legacy /profiles route, and the legacy
runtime /settings/{key} route all land both rows, and a profile-scope enum
write refreshes user_profiles.auto_skip_intro so GET /profiles stays
truthful. Existing rows are carried onto the new key by a Goose migration
(Postgres) and an InitSchema twin (per-user SQLite); the settings-migrate
planner emits the companion for installs whose backfill runs later.

The spec in docs/design/2026-08-16-intro-skip-mode.md also defines the
prompt state machine every client (web, Android, Apple; browser, tablet,
mobile, TV) implements against this key. It builds on the Android TV
Skip Intro work in silo-android#210 — wall-clock timer, rebuffer-vs-pause
debounce, root-level key handling.

Co-authored-by: evulhotdog <365456+evulhotdog@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 19:38:19 -04:00

499 lines
18 KiB
Go

package userdb
import (
"database/sql"
"encoding/json"
"testing"
)
// seedLegacySettings fills the pre-cutover tables the way a real install would.
func seedLegacySettings(t *testing.T, db *sql.DB) {
t.Helper()
// Two profiles on one account: appearance moved from the account to the
// profile, so an account row has to reach both.
for _, profile := range []struct {
id, quality, language, subtitleLang, mode string
forced, skipIntro bool
}{
{"p1", "1080p-high", "ja", "en", "always", false, true},
{"p2", "1080p", "en", "", "auto", true, false},
} {
if _, err := db.Exec(`
INSERT INTO profiles
(id, name, quality_preference, language, subtitle_language, subtitle_mode, show_forced_subtitles,
auto_skip_intro, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
profile.id, profile.id, profile.quality, profile.language,
profile.subtitleLang, profile.mode, profile.forced, profile.skipIntro); err != nil {
t.Fatalf("seeding profile %s: %v", profile.id, err)
}
}
for key, value := range map[string]string{
"ui_theme": "cobalt-studio",
"ui_text_scale": "large",
// Rides the same table under a synthetic key and is not a user setting.
"jellycompat:displayprefs:usersettings:emby": `{"a":1}`,
// Never had a definition; must be recorded rather than dropped.
"legacy.mystery": "whatever",
} {
if _, err := db.Exec(
`INSERT INTO user_settings (key, value) VALUES (?, ?)`, key, value); err != nil {
t.Fatalf("seeding user_settings %s: %v", key, err)
}
}
for _, row := range []struct{ profile, device, key, value string }{
{"p1", "d1", "playback.preferred_quality", "720p-high"},
{"p1", "d1", "playback.auto_skip_intro", "true"},
{"p1", "d1", "player.audio_sync_ms", "-250"},
// Both spellings of the same key on one device: the legacy PK keys on
// the stored spelling, so real installs hold this pair whenever the
// runtime's best-effort alias cleanup failed. Migrating both would trip
// the canonical unique index and abort the whole migration.
{"p1", "d1", "player.next_up_prompt_seconds", "45"},
{"p1", "d1", "playback.next_up_prompt_seconds", "20"},
} {
if _, err := db.Exec(`
INSERT INTO user_device_settings (profile_id, device_id, key, value, updated_at)
VALUES (?, ?, ?, ?, '2026-01-01T00:00:00Z')`,
row.profile, row.device, row.key, row.value); err != nil {
t.Fatalf("seeding device setting %s: %v", row.key, err)
}
}
if _, err := db.Exec(`
INSERT INTO subtitle_preferences (profile_id, series_id, subtitle_language, subtitle_mode, show_forced_subtitles, updated_at)
VALUES ('p1', 's1', 'de', 'always', 0, '2026-01-01T00:00:00Z')`); err != nil {
t.Fatalf("seeding subtitle_preferences: %v", err)
}
if _, err := db.Exec(`
INSERT INTO audio_preferences (profile_id, series_id, audio_language, updated_at)
VALUES ('p1', 's1', 'fr', '2026-01-01T00:00:00Z')`); err != nil {
t.Fatalf("seeding audio_preferences: %v", err)
}
if _, err := db.Exec(`
INSERT INTO library_playback_preferences (profile_id, library_id, audio_language, subtitle_mode, updated_at)
VALUES ('p1', 7, 'es', 'off', '2026-01-01T00:00:00Z')`); err != nil {
t.Fatalf("seeding library_playback_preferences: %v", err)
}
}
func canonicalValue(t *testing.T, db *sql.DB, key, scope string, where string, args ...any) (string, bool) {
t.Helper()
query := `SELECT value FROM user_setting_values WHERE key = ? AND scope = ?`
if where != "" {
query += " AND " + where
}
full := append([]any{key, scope}, args...)
var value string
err := db.QueryRow(query, full...).Scan(&value)
if err == sql.ErrNoRows {
return "", false
}
if err != nil {
t.Fatalf("reading %s at %s: %v", key, scope, err)
}
return value, true
}
// TestMigrateToV16BackfillsCanonicalValues runs the real migration against a
// real database. The planner's rules are unit-tested in internal/settingsmigrate;
// what this covers is the wiring — that the rows actually land, satisfy the
// scope CHECK and the partial unique indexes, and that nothing violates the
// json_valid constraints.
func TestMigrateToV16BackfillsCanonicalValues(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
seedLegacySettings(t, db)
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
if err := migrateToV16(tx); err != nil {
t.Fatalf("migrateToV16: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
t.Run("profile columns become profile-scope values", func(t *testing.T) {
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile",
"profile_id = ?", "p1"); !ok || got != `"ja"` {
t.Errorf("p1 audio language = %q (found=%v), want \"ja\"", got, ok)
}
// These schema defaults were also the effective legacy behavior, so they
// survive as explicit rows where the contract defaults differ.
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile",
"profile_id = ?", "p2"); !ok || got != `"en"` {
t.Errorf("p2 audio language = %q (found=%v), want the effective \"en\"", got, ok)
}
if got, ok := canonicalValue(t, db, "playback.preferred_quality", "profile",
"profile_id = ?", "p2"); !ok || got != `"1080p"` {
t.Errorf("p2 quality = %q (found=%v), want the effective \"1080p\"", got, ok)
}
if got, ok := canonicalValue(t, db, "playback.max_bitrate_kbps", "profile",
"profile_id = ?", "p2"); !ok || got != `6000` {
t.Errorf("p2 bitrate = %q (found=%v), want the effective 6000", got, ok)
}
})
t.Run("auto-skip columns migrate only when true", func(t *testing.T) {
if got, ok := canonicalValue(t, db, "playback.auto_skip_intro", "profile",
"profile_id = ?", "p1"); !ok || got != `true` {
t.Errorf("p1 auto_skip_intro = %q (found=%v), want true", got, ok)
}
if got, ok := canonicalValue(t, db, "playback.auto_skip_intro", "profile",
"profile_id = ?", "p2"); ok {
t.Errorf("p2 got auto_skip_intro %q from an untouched false column", got)
}
})
t.Run("renamed and canonical device keys collapse to one row", func(t *testing.T) {
// Both spellings were seeded; without the dedup pass the second insert
// violates the unique index and the whole migration — and with it the
// user's store — fails to open. The canonical-keyed value wins.
if got, ok := canonicalValue(t, db, "playback.next_up_prompt_seconds", "profile_device",
"profile_id = ? AND device_id = ?", "p1", "d1"); !ok || got != `20` {
t.Errorf("next_up_prompt_seconds = %q (found=%v), want the canonical-keyed 20", got, ok)
}
})
t.Run("legacy quality decomposes into two axes", func(t *testing.T) {
quality, ok := canonicalValue(t, db, "playback.preferred_quality", "profile",
"profile_id = ?", "p1")
if !ok || quality != `"1080p"` {
t.Errorf("resolution = %q (found=%v), want \"1080p\"", quality, ok)
}
bitrate, ok := canonicalValue(t, db, "playback.max_bitrate_kbps", "profile",
"profile_id = ?", "p1")
if !ok || bitrate != `10000` {
t.Errorf("bitrate = %q (found=%v), want 10000", bitrate, ok)
}
// The device row decomposes too, at its own scope.
deviceQuality, ok := canonicalValue(t, db, "playback.preferred_quality", "profile_device",
"profile_id = ? AND device_id = ?", "p1", "d1")
if !ok || deviceQuality != `"720p"` {
t.Errorf("device resolution = %q (found=%v), want \"720p\"", deviceQuality, ok)
}
})
t.Run("account settings fan out to every profile", func(t *testing.T) {
for _, profile := range []string{"p1", "p2"} {
if got, ok := canonicalValue(t, db, "ui.theme", "profile",
"profile_id = ?", profile); !ok || got != `"cobalt-studio"` {
t.Errorf("%s theme = %q (found=%v)", profile, got, ok)
}
}
})
t.Run("series and library preferences land at their scopes", func(t *testing.T) {
if got, ok := canonicalValue(t, db, "playback.subtitle_language", "profile_series",
"profile_id = ? AND series_id = ?", "p1", "s1"); !ok || got != `"de"` {
t.Errorf("series subtitle language = %q (found=%v), want \"de\"", got, ok)
}
// Audio and subtitle preferences are separate tables keyed alike; both
// must survive rather than one overwriting the other.
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile_series",
"profile_id = ? AND series_id = ?", "p1", "s1"); !ok || got != `"fr"` {
t.Errorf("series audio language = %q (found=%v), want \"fr\"", got, ok)
}
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile_library",
"profile_id = ? AND library_id = ?", "p1", 7); !ok || got != `"es"` {
t.Errorf("library audio language = %q (found=%v), want \"es\"", got, ok)
}
})
t.Run("legacy strings become typed JSON", func(t *testing.T) {
if got, ok := canonicalValue(t, db, "playback.auto_skip_intro", "profile_device",
"profile_id = ? AND device_id = ?", "p1", "d1"); !ok || got != `true` {
t.Errorf("auto_skip_intro = %q, want the boolean true", got)
}
if got, ok := canonicalValue(t, db, "player.audio_sync_ms", "profile_device",
"profile_id = ? AND device_id = ?", "p1", "d1"); !ok || got != `-250` {
t.Errorf("audio_sync_ms = %q, want the number -250", got)
}
})
t.Run("unconvertible rows are recorded, jellycompat blobs are not", func(t *testing.T) {
var reason, identity string
err := db.QueryRow(`
SELECT reason, identity FROM user_setting_migration_rejects WHERE source_key = 'legacy.mystery'`).
Scan(&reason, &identity)
if err != nil {
t.Fatalf("the unknown key was dropped rather than recorded: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal([]byte(identity), &decoded); err != nil {
t.Errorf("reject identity %q is not JSON: %v", identity, err)
}
var jellycompat int
if err := db.QueryRow(`
SELECT COUNT(*) FROM user_setting_migration_rejects WHERE source_key LIKE 'jellycompat:%'`).
Scan(&jellycompat); err != nil {
t.Fatalf("counting jellycompat rejects: %v", err)
}
if jellycompat != 0 {
t.Errorf("%d jellycompat blobs were rejected; they should be left alone", jellycompat)
}
})
t.Run("every written value is valid JSON at an allowed scope", func(t *testing.T) {
rows, err := db.Query(`SELECT key, scope, value FROM user_setting_values`)
if err != nil {
t.Fatalf("listing values: %v", err)
}
defer rows.Close() //nolint:errcheck // test cleanup
count := 0
for rows.Next() {
var key, scope, value string
if err := rows.Scan(&key, &scope, &value); err != nil {
t.Fatalf("scan: %v", err)
}
count++
var decoded any
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
t.Errorf("%s at %s holds invalid JSON %q", key, scope, value)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("iterating: %v", err)
}
if count == 0 {
t.Fatal("the migration wrote nothing")
}
})
}
// TestMigrateToV16IsAtomic. The migration runs inside the caller's transaction,
// so a failure has to leave the database exactly as it was rather than half
// converted — an operator's restore point is the pre-upgrade backup, and a
// partial migration is the one state neither backup nor rollback covers.
func TestMigrateToV16IsAtomic(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
seedLegacySettings(t, db)
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
if err := migrateToV16(tx); err != nil {
t.Fatalf("migrateToV16: %v", err)
}
if err := tx.Rollback(); err != nil {
t.Fatalf("rollback: %v", err)
}
var values, rejects int
if err := db.QueryRow(`SELECT COUNT(*) FROM user_setting_values`).Scan(&values); err != nil {
t.Fatalf("counting values: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM user_setting_migration_rejects`).Scan(&rejects); err != nil {
t.Fatalf("counting rejects: %v", err)
}
if values != 0 || rejects != 0 {
t.Errorf("rollback left %d values and %d rejects behind", values, rejects)
}
}
// TestMigrateToV16OnAnEmptyDatabase: a fresh install has nothing to migrate and
// must not fail trying.
func TestMigrateToV16OnAnEmptyDatabase(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
if err := migrateToV16(tx); err != nil {
t.Fatalf("migrateToV16 on an empty database: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
}
func TestMigrateToV16RejectsOrphansWhenProfileListIsEmpty(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
if _, err := db.Exec(`
INSERT INTO user_device_settings (profile_id, device_id, key, value, updated_at)
VALUES ('deleted', 'd1', 'subtitle_appearance', '{"fontSize":"large"}',
'2026-01-01T00:00:00Z')`); err != nil {
t.Fatalf("seeding orphan: %v", err)
}
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
if err := migrateToV16(tx); err != nil {
t.Fatalf("migrateToV16: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
var values, rejects int
if err := db.QueryRow(`SELECT COUNT(*) FROM user_setting_values`).Scan(&values); err != nil {
t.Fatalf("counting canonical values: %v", err)
}
if err := db.QueryRow(`
SELECT COUNT(*) FROM user_setting_migration_rejects
WHERE source_key = 'subtitle_appearance'
AND json_extract(identity, '$.profile_id') = 'deleted'`).Scan(&rejects); err != nil {
t.Fatalf("counting orphan rejects: %v", err)
}
if values != 0 || rejects != 1 {
t.Fatalf("values=%d rejects=%d, want 0/1", values, rejects)
}
}
// TestMigrateToV16IsIdempotentUnderReRun guards the partial-unique indexes: the
// migration must not be runnable twice into a conflict. runMigrations gates it
// behind user_version, so the second call is what an operator would trigger by
// restoring a backup over a migrated database.
func TestMigrateToV16IsIdempotentUnderReRun(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
seedLegacySettings(t, db)
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
if err := migrateToV16(tx); err != nil {
t.Fatalf("first run: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
// A second run collides with the partial unique indexes. That it fails is
// correct — silently doubling every value would be worse — but it must fail
// as an error rather than corrupting anything, and the version gate in
// runMigrations is what stops it happening in practice.
tx2, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
err = migrateToV16(tx2)
_ = tx2.Rollback()
if err == nil {
t.Error("a second migration run was accepted; values would be duplicated")
}
}
// TestInitSchemaCarriesAutoSkipIntroOntoIntroSkipMode covers this backend's half
// of the revision-7 cutover: rows that were already canonical when the release
// landed, which the legacy backfill will never look at again because it has
// already run.
//
// The PostgreSQL side of the same copy is a Goose migration; both have to reach
// the same rows or a household's intro behavior would depend on which backend
// its account happens to live in.
func TestInitSchemaCarriesAutoSkipIntroOntoIntroSkipMode(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
// Three identities a real store can hold the boolean at, plus an enum a
// client already chose for itself.
for _, row := range []struct{ scope, profile, device, key, value string }{
{"profile", "p1", "", "playback.auto_skip_intro", "true"},
{"profile", "p2", "", "playback.auto_skip_intro", "false"},
{"profile_device", "p1", "d1", "playback.auto_skip_intro", "true"},
{"profile_device", "p1", "d2", "playback.auto_skip_intro", "true"},
{"profile_device", "p1", "d2", "playback.intro_skip_mode", `"never"`},
} {
if _, err := db.Exec(`
INSERT INTO user_setting_values
(key, scope, profile_id, device_id, value, revision, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
row.key, row.scope, nullableText(row.profile), nullableText(row.device),
row.value); err != nil {
t.Fatalf("seeding %s at %s: %v", row.key, row.scope, err)
}
}
// The copy runs on open, so re-running InitSchema is how a deployment
// upgrading into this release reaches it.
if err := InitSchema(db); err != nil {
t.Fatalf("second InitSchema: %v", err)
}
for _, want := range []struct {
scope, where, value string
args []any
}{
{"profile", "profile_id = ?", `"always"`, []any{"p1"}},
{"profile", "profile_id = ?", `"ask"`, []any{"p2"}},
{"profile_device", "profile_id = ? AND device_id = ?", `"always"`, []any{"p1", "d1"}},
// The client's own choice survives; the boolean beside it does not
// overwrite the mode that cannot be spelled as a boolean.
{"profile_device", "profile_id = ? AND device_id = ?", `"never"`, []any{"p1", "d2"}},
} {
got, ok := canonicalValue(t, db, "playback.intro_skip_mode", want.scope, want.where, want.args...)
if !ok || got != want.value {
t.Errorf("intro_skip_mode at %s %v = %q (found=%v), want %s",
want.scope, want.args, got, ok, want.value)
}
}
// Idempotent: opening the store again must not duplicate or disturb a row.
if err := InitSchema(db); err != nil {
t.Fatalf("third InitSchema: %v", err)
}
var count int
if err := db.QueryRow(
`SELECT COUNT(*) FROM user_setting_values WHERE key = 'playback.intro_skip_mode'`,
).Scan(&count); err != nil {
t.Fatalf("counting intro_skip_mode rows: %v", err)
}
if count != 4 {
t.Errorf("intro_skip_mode rows = %d, want 4 after repeated opens", count)
}
}