Files
silo-server/internal/userdb/setting_values_migrate_test.go
T
QuickandClaude Opus 5 1f40e25438 feat(settings): run the one-time migration on the SQLite backend
Wires the planner to real storage as userdb migration V15. V14 created the
tables; this fills them.

It runs inside runMigrations' existing transaction, so a database either
comes out fully migrated or untouched — a partial migration is the one
state neither the operator's backup nor a rollback covers. Pinned by a test
that rolls back and asserts nothing was left behind.

Two things the wiring had to get right that the planner could not see:

Reject identities are JSON. Postgres declares that column jsonb NOT NULL
and SQLite guards it with a json_valid CHECK, so the free-form
"profile=p1 device=d1" the planner emitted would have failed to insert — on
exactly the rows the table exists to record. They are structured documents
now, which is also queryable.

Subtitle and audio preferences are two tables keyed the same way, so they
merge into one per-series record before planning. Converting them
independently would have produced two rows racing for the same identity.

Every legacy read tolerates a missing table, since this runs against
databases created at any schema version, and preferred_metadata_language is
deliberately absent: that column exists only in the Postgres schema.

Tested end to end against a real database rather than only through the
planner — the rows land, satisfy the scope CHECK and the partial unique
indexes, and hold valid JSON. Also covers the empty-install case and
asserts a second run fails rather than silently doubling every value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:18:18 +00:00

345 lines
12 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 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")
}
}