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") } }