diff --git a/internal/settingsmigrate/plan.go b/internal/settingsmigrate/plan.go index 0a14145f..ba0979b2 100644 --- a/internal/settingsmigrate/plan.go +++ b/internal/settingsmigrate/plan.go @@ -103,14 +103,33 @@ type Row struct { // Reject records a legacy value that could not be converted. It is written to // user_setting_migration_rejects so an operator can see exactly what did not // survive, rather than discovering it from a support ticket. +// +// Identity is JSON because both backends store it as one: Postgres declares the +// column jsonb NOT NULL and SQLite guards it with a json_valid CHECK. A +// free-form description would violate the schema on write, and a structured one +// is queryable — "every reject for profile p1" is a jsonb predicate rather than +// a LIKE over prose. type Reject struct { SourceTable string SourceKey string - Identity string + Identity json.RawMessage Value string Reason string } +// identityJSON builds the reject identity document. Only the fields that locate +// the row are emitted, so a profile-column reject does not carry an empty +// device id. +func identityJSON(fields map[string]any) json.RawMessage { + encoded, err := json.Marshal(fields) + if err != nil { + // Only strings and ints reach here, so this cannot fire; an empty + // object still satisfies both backends' JSON constraint. + return json.RawMessage(`{}`) + } + return encoded +} + // Result is what one user's migration produced. type Result struct { Rows []Row @@ -145,6 +164,15 @@ const ( // two canonical keys rather than converting to one. const keyPreferredQuality = "playback.preferred_quality" +// fieldProfileID is the identity field every non-account reject carries. +const fieldProfileID = "profile_id" + +// accountIdentity locates a reject from the account-wide key/value table, which +// has no profile, device or content to name. +func accountIdentity() json.RawMessage { + return identityJSON(map[string]any{"scope": "account"}) +} + // legacyQualityDecomposition maps each legacy compound quality value to the two // axes that replaced it. // @@ -231,8 +259,8 @@ func (p *Planner) Plan(in Input) Result { // planProfiles converts the six preference columns on user_profiles. func (p *Planner) planProfiles(profiles []LegacyProfile, res *Result) { for _, profile := range profiles { - id := func(field string) string { - return fmt.Sprintf("profile=%s column=%s", profile.ID, field) + id := func(field string) json.RawMessage { + return identityJSON(map[string]any{fieldProfileID: profile.ID, "column": field}) } // Language columns: the empty string is the legacy spelling of "no @@ -290,8 +318,9 @@ func (p *Planner) planAccountSettings( if !ok { res.Rejects = append(res.Rejects, Reject{ SourceTable: sourceUserSettings, SourceKey: setting.Key, - Value: setting.Value, - Reason: "no contract definition; the key was only ever accepted by the unknown-key extension bag", + Identity: accountIdentity(), + Value: setting.Value, + Reason: "no contract definition; the key was only ever accepted by the unknown-key extension bag", }) continue } @@ -300,7 +329,8 @@ func (p *Planner) planAccountSettings( if err != nil { res.Rejects = append(res.Rejects, Reject{ SourceTable: sourceUserSettings, SourceKey: setting.Key, - Value: setting.Value, Reason: err.Error(), + Identity: accountIdentity(), + Value: setting.Value, Reason: err.Error(), }) continue } @@ -312,8 +342,9 @@ func (p *Planner) planAccountSettings( if !def.AllowsScope(scope) { res.Rejects = append(res.Rejects, Reject{ SourceTable: sourceUserSettings, SourceKey: setting.Key, - Value: setting.Value, - Reason: fmt.Sprintf("%s does not allow profile scope", key), + Identity: accountIdentity(), + Value: setting.Value, + Reason: fmt.Sprintf("%s does not allow profile scope", key), }) continue } @@ -328,7 +359,9 @@ func (p *Planner) planAccountSettings( func (p *Planner) planDeviceSettings(devices []LegacyDeviceSetting, res *Result) { for _, row := range devices { key := canonicalKey(row.Key) - identity := fmt.Sprintf("profile=%s device=%s", row.ProfileID, row.DeviceID) + identity := identityJSON(map[string]any{ + fieldProfileID: row.ProfileID, "device_id": row.DeviceID, + }) if key == keyPreferredQuality { p.addQuality(res, sourceUserDeviceSettings, identity, @@ -375,7 +408,9 @@ func (p *Planner) planDeviceSettings(devices []LegacyDeviceSetting, res *Result) func (p *Planner) planSeriesPrefs(prefs []LegacySeriesPreference, res *Result) { for _, pref := range prefs { base := Row{ProfileID: pref.ProfileID, SeriesID: pref.SeriesID} - identity := fmt.Sprintf("profile=%s series=%s", pref.ProfileID, pref.SeriesID) + identity := identityJSON(map[string]any{ + fieldProfileID: pref.ProfileID, "series_id": pref.SeriesID, + }) p.addPlaybackTriple(res, sourceSeriesPrefs, identity, settingscontract.ScopeProfileSeries, base, pref.AudioLanguage, pref.SubtitleLanguage, pref.SubtitleMode, pref.ShowForcedSubtitles) @@ -385,7 +420,9 @@ func (p *Planner) planSeriesPrefs(prefs []LegacySeriesPreference, res *Result) { func (p *Planner) planLibraryPrefs(prefs []LegacyLibraryPreference, res *Result) { for _, pref := range prefs { base := Row{ProfileID: pref.ProfileID, LibraryID: pref.LibraryID} - identity := fmt.Sprintf("profile=%s library=%d", pref.ProfileID, pref.LibraryID) + identity := identityJSON(map[string]any{ + fieldProfileID: pref.ProfileID, "library_id": pref.LibraryID, + }) p.addPlaybackTriple(res, sourceLibraryPrefs, identity, settingscontract.ScopeProfileLibrary, base, pref.AudioLanguage, pref.SubtitleLanguage, pref.SubtitleMode, pref.ShowForcedSubtitles) @@ -396,7 +433,7 @@ func (p *Planner) planLibraryPrefs(prefs []LegacyLibraryPreference, res *Result) // library rows share. Both tables carry the same columns with the same // semantics, so they convert identically at different scopes. func (p *Planner) addPlaybackTriple( - res *Result, sourceTable, identity string, + res *Result, sourceTable string, identity json.RawMessage, scope settingscontract.Scope, base Row, audio, subtitle, mode *string, forced *bool, ) { @@ -422,7 +459,7 @@ func (p *Planner) addPlaybackTriple( // spelling of "no preference" and produces no row; so does a value still equal // to the column default. func (p *Planner) addLanguage( - res *Result, sourceTable, identity, key string, + res *Result, sourceTable string, identity json.RawMessage, key string, scope settingscontract.Scope, base Row, raw *string, columnDefault string, ) { value := strings.TrimSpace(deref(raw)) @@ -444,7 +481,7 @@ func (p *Planner) addLanguage( // addQuality decomposes a legacy quality value into the two axes that replaced // it, writing up to two rows. func (p *Planner) addQuality( - res *Result, sourceTable, identity string, + res *Result, sourceTable string, identity json.RawMessage, scope settingscontract.Scope, base Row, raw, columnDefault string, ) { value := strings.TrimSpace(raw) @@ -477,7 +514,7 @@ func (p *Planner) addQuality( // addValue appends a row after checking the value against its own definition, // so nothing reaches storage that the mutation endpoint would refuse. func (p *Planner) addValue( - res *Result, sourceTable, identity, key string, + res *Result, sourceTable string, identity json.RawMessage, key string, scope settingscontract.Scope, base Row, value json.RawMessage, ) { def, ok := p.contract.Lookup(key) diff --git a/internal/settingsmigrate/plan_test.go b/internal/settingsmigrate/plan_test.go index f3fc2e38..2fb6c07a 100644 --- a/internal/settingsmigrate/plan_test.go +++ b/internal/settingsmigrate/plan_test.go @@ -406,3 +406,34 @@ func TestEveryPlannedRowValidates(t *testing.T) { } } } + +// TestRejectIdentityIsAlwaysValidJSON. Both backends store this column as JSON — +// Postgres declares it jsonb NOT NULL, SQLite guards it with a json_valid CHECK +// — so a reject carrying prose would fail to insert, and the migration would +// abort on the very rows it exists to record. +func TestRejectIdentityIsAlwaysValidJSON(t *testing.T) { + res := planner(t).Plan(Input{ + Profiles: []LegacyProfile{{ID: "p1", Language: str("!!!")}}, + Settings: []LegacySetting{{Key: "totally.unknown", Value: "x"}}, + DeviceSettings: []LegacyDeviceSetting{ + {ProfileID: "p1", DeviceID: "d1", Key: "playback.auto_skip_intro", Value: "maybe"}, + }, + SeriesPrefs: []LegacySeriesPreference{ + {ProfileID: "p1", SeriesID: "s1", SubtitleLanguage: str("!!!")}, + }, + LibraryPrefs: []LegacyLibraryPreference{ + {ProfileID: "p1", LibraryID: 4, AudioLanguage: str("!!!")}, + }, + }) + + if len(res.Rejects) == 0 { + t.Fatal("nothing was rejected, so this proves nothing") + } + for _, reject := range res.Rejects { + var decoded map[string]any + if err := json.Unmarshal(reject.Identity, &decoded); err != nil { + t.Errorf("identity %q for %s is not a JSON object: %v", + reject.Identity, reject.SourceKey, err) + } + } +} diff --git a/internal/userdb/migrate.go b/internal/userdb/migrate.go index e94a890e..b0023a22 100644 --- a/internal/userdb/migrate.go +++ b/internal/userdb/migrate.go @@ -5,7 +5,7 @@ import ( "fmt" ) -const schemaVersion = 14 +const schemaVersion = 15 func runMigrations(db *sql.DB) error { version, err := userVersion(db) @@ -142,9 +142,27 @@ func runMigrations(db *sql.DB) error { } } + if version < 15 { + if err := migrateToV15(tx); err != nil { + return err + } + if _, err := tx.Exec("PRAGMA user_version = 15"); err != nil { + return fmt.Errorf("setting sqlite user_version 15: %w", err) + } + } + return tx.Commit() } +// migrateToV15 backfills canonical setting values from the legacy tables. +// +// V14 created the tables; this fills them. It is the cutover's data half, and +// it runs in the same transaction as every other step, so a database either +// comes out fully migrated or untouched. +func migrateToV15(tx *sql.Tx) error { + return migrateSettingsToCanonical(tx) +} + // migrateToV14 adds the canonical settings contract tables. InitSchema already // creates them with IF NOT EXISTS on every open, so this step is what records // that an existing database has them — the same shape migrateToV6 used for diff --git a/internal/userdb/setting_values_migrate.go b/internal/userdb/setting_values_migrate.go new file mode 100644 index 00000000..dd74fefc --- /dev/null +++ b/internal/userdb/setting_values_migrate.go @@ -0,0 +1,252 @@ +package userdb + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/settingsmigrate" +) + +// migrateSettingsToCanonical is the one-time backfill from legacy settings +// storage into user_setting_values. +// +// The conversion rules live in internal/settingsmigrate so this backend and +// Postgres cannot disagree about them; everything here is reading rows, handing +// them over, and writing what comes back. It runs inside the caller's +// transaction, so a failure anywhere leaves the database exactly as it was — +// the design's "completes and verifies atomically or leaves the database +// unchanged". +// +// The legacy tables are left in place. Dropping them belongs to the follow-up +// migration named in the design's post-cutover cleanup, once migrated counts +// have been verified against a backup. +func migrateSettingsToCanonical(tx *sql.Tx) error { + contract, err := settingscontract.Load() + if err != nil { + return fmt.Errorf("loading settings contract: %w", err) + } + + input, err := readLegacySettings(tx) + if err != nil { + return err + } + + planner := settingsmigrate.New(contract, settingscontract.ObjectSchemas()) + result := planner.Plan(input) + + now := time.Now().UTC().Format(time.RFC3339Nano) + for _, row := range result.Rows { + if _, err := tx.Exec(` +INSERT INTO user_setting_values + (key, scope, profile_id, device_id, library_id, series_id, value, revision, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, + row.Key, string(row.Scope), + nullableText(row.ProfileID), nullableText(row.DeviceID), + nullableInt(row.LibraryID), nullableText(row.SeriesID), + string(row.Value), now, now, + ); err != nil { + return fmt.Errorf("writing migrated setting %s at %s: %w", row.Key, row.Scope, err) + } + } + + for _, reject := range result.Rejects { + if _, err := tx.Exec(` +INSERT INTO user_setting_migration_rejects + (source_table, source_key, identity, value, reason, recorded_at) +VALUES (?, ?, ?, ?, ?, ?)`, + reject.SourceTable, reject.SourceKey, string(reject.Identity), + reject.Value, reject.Reason, now, + ); err != nil { + return fmt.Errorf("recording migration reject for %s: %w", reject.SourceKey, err) + } + } + + return nil +} + +// readLegacySettings gathers every source the migration reads. +// +// Each query tolerates a missing table: this runs against databases created at +// any schema version, and a table an older install never had is simply empty +// rather than fatal. +func readLegacySettings(tx *sql.Tx) (settingsmigrate.Input, error) { + var input settingsmigrate.Input + + profiles, err := readLegacyProfiles(tx) + if err != nil { + return input, err + } + input.Profiles = profiles + + if err := eachRow(tx, `SELECT key, value FROM user_settings`, + func(scan func(...any) error) error { + var row settingsmigrate.LegacySetting + if err := scan(&row.Key, &row.Value); err != nil { + return err + } + input.Settings = append(input.Settings, row) + return nil + }); err != nil { + return input, fmt.Errorf("reading user_settings: %w", err) + } + + if err := eachRow(tx, `SELECT profile_id, device_id, key, value FROM user_device_settings`, + func(scan func(...any) error) error { + var row settingsmigrate.LegacyDeviceSetting + if err := scan(&row.ProfileID, &row.DeviceID, &row.Key, &row.Value); err != nil { + return err + } + input.DeviceSettings = append(input.DeviceSettings, row) + return nil + }); err != nil { + return input, fmt.Errorf("reading user_device_settings: %w", err) + } + + // Subtitle and audio preferences are two tables keyed the same way, so they + // merge into one per-series record rather than producing two rows that + // would each overwrite the other's scope. + bySeries := map[[2]string]*settingsmigrate.LegacySeriesPreference{} + seriesRecord := func(profileID, seriesID string) *settingsmigrate.LegacySeriesPreference { + key := [2]string{profileID, seriesID} + if existing, ok := bySeries[key]; ok { + return existing + } + record := &settingsmigrate.LegacySeriesPreference{ProfileID: profileID, SeriesID: seriesID} + bySeries[key] = record + return record + } + + if err := eachRow(tx, ` +SELECT profile_id, series_id, subtitle_language, subtitle_mode, show_forced_subtitles + FROM subtitle_preferences`, + func(scan func(...any) error) error { + var profileID, seriesID string + var language, mode sql.NullString + var forced sql.NullBool + if err := scan(&profileID, &seriesID, &language, &mode, &forced); err != nil { + return err + } + record := seriesRecord(profileID, seriesID) + record.SubtitleLanguage = nullString(language) + record.SubtitleMode = nullString(mode) + record.ShowForcedSubtitles = nullBool(forced) + return nil + }); err != nil { + return input, fmt.Errorf("reading subtitle_preferences: %w", err) + } + + if err := eachRow(tx, `SELECT profile_id, series_id, audio_language FROM audio_preferences`, + func(scan func(...any) error) error { + var profileID, seriesID string + var language sql.NullString + if err := scan(&profileID, &seriesID, &language); err != nil { + return err + } + seriesRecord(profileID, seriesID).AudioLanguage = nullString(language) + return nil + }); err != nil { + return input, fmt.Errorf("reading audio_preferences: %w", err) + } + + for _, record := range bySeries { + input.SeriesPrefs = append(input.SeriesPrefs, *record) + } + + if err := eachRow(tx, ` +SELECT profile_id, library_id, audio_language, subtitle_language, subtitle_mode, show_forced_subtitles + FROM library_playback_preferences`, + func(scan func(...any) error) error { + var row settingsmigrate.LegacyLibraryPreference + var audio, subtitle, mode sql.NullString + var forced sql.NullBool + if err := scan(&row.ProfileID, &row.LibraryID, + &audio, &subtitle, &mode, &forced); err != nil { + return err + } + row.AudioLanguage = nullString(audio) + row.SubtitleLanguage = nullString(subtitle) + row.SubtitleMode = nullString(mode) + row.ShowForcedSubtitles = nullBool(forced) + input.LibraryPrefs = append(input.LibraryPrefs, row) + return nil + }); err != nil { + return input, fmt.Errorf("reading library_playback_preferences: %w", err) + } + + return input, nil +} + +// readLegacyProfiles reads the preference columns off the profiles table. +// +// preferred_metadata_language is deliberately absent: the column exists only in +// the Postgres schema, so catalog.metadata_language has no SQLite source and +// the field stays nil here. +func readLegacyProfiles(tx *sql.Tx) ([]settingsmigrate.LegacyProfile, error) { + var profiles []settingsmigrate.LegacyProfile + err := eachRow(tx, ` +SELECT id, quality_preference, language, subtitle_language, subtitle_mode, show_forced_subtitles + FROM profiles`, + func(scan func(...any) error) error { + var profile settingsmigrate.LegacyProfile + var quality, language, subtitle, mode sql.NullString + var forced sql.NullBool + if err := scan(&profile.ID, &quality, &language, &subtitle, &mode, &forced); err != nil { + return err + } + profile.QualityPreference = nullString(quality) + profile.Language = nullString(language) + profile.SubtitleLanguage = nullString(subtitle) + profile.SubtitleMode = nullString(mode) + profile.ShowForcedSubtitles = nullBool(forced) + profiles = append(profiles, profile) + return nil + }) + if err != nil { + return nil, fmt.Errorf("reading profiles: %w", err) + } + return profiles, nil +} + +// eachRow runs a query and calls fn per row, treating a missing table as no +// rows. Every legacy table this migration reads was added at some schema +// version, so a database older than that simply has nothing to migrate from it. +func eachRow(tx *sql.Tx, query string, fn func(scan func(...any) error) error) error { + rows, err := tx.Query(query) + if err != nil { + if isMissingTable(err) { + return nil + } + return err + } + defer rows.Close() //nolint:errcheck // read-only iteration + + for rows.Next() { + if err := fn(rows.Scan); err != nil { + return err + } + } + return rows.Err() +} + +func isMissingTable(err error) bool { + return err != nil && strings.Contains(err.Error(), "no such table") +} + +func nullString(value sql.NullString) *string { + if !value.Valid { + return nil + } + text := value.String + return &text +} + +func nullBool(value sql.NullBool) *bool { + if !value.Valid { + return nil + } + flag := value.Bool + return &flag +} diff --git a/internal/userdb/setting_values_migrate_test.go b/internal/userdb/setting_values_migrate_test.go new file mode 100644 index 00000000..9399db62 --- /dev/null +++ b/internal/userdb/setting_values_migrate_test.go @@ -0,0 +1,344 @@ +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 bool + }{ + {"p1", "1080p-high", "ja", "en", "always", false}, + {"p2", "1080p", "en", "", "auto", true}, + } { + if _, err := db.Exec(` +INSERT INTO profiles + (id, name, quality_preference, language, subtitle_language, subtitle_mode, show_forced_subtitles, + 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); 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"}, + } { + 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 +} + +// TestMigrateToV15BackfillsCanonicalValues 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 TestMigrateToV15BackfillsCanonicalValues(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 := migrateToV15(tx); err != nil { + t.Fatalf("migrateToV15: %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) + } + // p2 holds only column defaults, so it must produce nothing. + if got, ok := canonicalValue(t, db, "playback.audio_language", "profile", + "profile_id = ?", "p2"); ok { + t.Errorf("p2 got %q from a column still holding its default", got) + } + }) + + 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") + } + }) +} + +// TestMigrateToV15IsAtomic. 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 TestMigrateToV15IsAtomic(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 := migrateToV15(tx); err != nil { + t.Fatalf("migrateToV15: %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) + } +} + +// TestMigrateToV15OnAnEmptyDatabase: a fresh install has nothing to migrate and +// must not fail trying. +func TestMigrateToV15OnAnEmptyDatabase(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 := migrateToV15(tx); err != nil { + t.Fatalf("migrateToV15 on an empty database: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } +} + +// TestMigrateToV15IsIdempotentUnderReRun 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 TestMigrateToV15IsIdempotentUnderReRun(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 := migrateToV15(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 = migrateToV15(tx2) + _ = tx2.Rollback() + if err == nil { + t.Error("a second migration run was accepted; values would be duplicated") + } +}