Files
silo-server/internal/database/settings_backfill_test.go
T
QuickandClaude Opus 5 cfbd69b240 feat(settings): run the one-time migration on the Postgres backend
The mirror of userdb V15, registered with goose as a Go migration rather
than SQL: the conversion validates every value against its own definition
and re-encodes it as typed JSON, and one legacy quality string becomes two
rows — neither is expressible in SQL without duplicating the manifest. The
rules stay in internal/settingsmigrate, so the two backends cannot disagree.

RunTx, so the whole backfill lands in goose's transaction. The down
migration empties the canonical tables; the legacy ones are never touched
by the up, which is what keeps the cutover reversible until the follow-up
migration drops the superseded columns.

preferred_metadata_language is read here and only here — the column exists
in this schema and not in SQLite's, so this is the sole source for
catalog.metadata_language.

Verified against a real Postgres: the full goose chain runs, 1080p-high
decomposes to ("1080p", 10000), values land as typed jsonb rather than
strings (jsonb_typeof reports number), rejects carry a queryable jsonb
identity, and the composite profile foreign key refuses a row naming a
profile that does not exist.

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

203 lines
7.0 KiB
Go

package database
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/Silo-Server/silo-server/migrations"
)
// TestPostgresSettingsBackfill runs the real goose provider — every SQL
// migration plus the Go backfill — against a real database, then checks what
// landed.
//
// The planner's rules are unit-tested in internal/settingsmigrate. What this
// covers is everything only a live database can show: that the Go migration is
// registered and actually runs, that the rows satisfy the scope CHECK, the
// composite profile foreign key and the five partial unique indexes, and that
// jsonb accepts the values the planner encodes.
func TestPostgresSettingsBackfill(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
// Seed legacy state, then run migrations over it. Ordering matters: the
// backfill has to find rows that predate it, which is the real upgrade.
if err := RunMigrations(ctx, pool, migrations.FS, "sql"); err != nil {
t.Fatalf("initial migration: %v", err)
}
seedLegacyPostgresSettings(ctx, t, pool)
// Re-run the backfill against the seeded data. It is idempotent only under
// goose's version gate, so this exercises it directly.
sqlDB := stdlib.OpenDBFromPool(pool)
t.Cleanup(func() { _ = sqlDB.Close() })
tx, err := sqlDB.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
if err := backfillSettingValues(ctx, tx); err != nil {
t.Fatalf("backfillSettingValues: %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) {
var value string
err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.audio_language' AND scope = 'profile' AND profile_id = 'mp1'`).
Scan(&value)
if err != nil {
t.Fatalf("reading migrated audio language: %v", err)
}
if value != `"ja"` {
t.Errorf("audio language = %s, want \"ja\"", value)
}
})
t.Run("metadata language migrates from the postgres-only column", func(t *testing.T) {
var value string
err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'catalog.metadata_language' AND scope = 'profile' AND profile_id = 'mp1'`).
Scan(&value)
if err != nil {
t.Fatalf("reading migrated metadata language: %v", err)
}
if value != `"fr"` {
t.Errorf("metadata language = %s, want \"fr\"", value)
}
})
t.Run("legacy quality decomposes into two axes", func(t *testing.T) {
var resolution, bitrate string
if err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.preferred_quality' AND scope = 'profile_device'
AND profile_id = 'mp1' AND device_id = 'md1'`).Scan(&resolution); err != nil {
t.Fatalf("reading resolution: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.max_bitrate_kbps' AND scope = 'profile_device'
AND profile_id = 'mp1' AND device_id = 'md1'`).Scan(&bitrate); err != nil {
t.Fatalf("reading bitrate: %v", err)
}
if resolution != `"1080p"` || bitrate != `10000` {
t.Errorf("decomposed to (%s, %s), want (\"1080p\", 10000)", resolution, bitrate)
}
})
t.Run("values are stored as typed jsonb, not strings", func(t *testing.T) {
var kind string
if err := pool.QueryRow(ctx, `
SELECT jsonb_typeof(value) FROM user_setting_values
WHERE key = 'playback.max_bitrate_kbps' AND profile_id = 'mp1' AND device_id = 'md1'`).
Scan(&kind); err != nil {
t.Fatalf("reading jsonb type: %v", err)
}
if kind != "number" {
t.Errorf("bitrate stored as jsonb %s, want number", kind)
}
})
t.Run("rejects carry a queryable jsonb identity", func(t *testing.T) {
var identity, reason string
err := pool.QueryRow(ctx, `
SELECT identity::text, reason FROM user_setting_migration_rejects
WHERE source_key = 'legacy.unknown.key' LIMIT 1`).Scan(&identity, &reason)
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("identity %q is not JSON: %v", identity, err)
}
if reason == "" {
t.Error("reject carries no reason")
}
})
t.Run("the composite profile foreign key holds", func(t *testing.T) {
// A profile-scope row naming a profile that does not exist must be
// refused, which is what keeps orphaned settings out after a profile is
// deleted.
_, err := pool.Exec(ctx, `
INSERT INTO user_setting_values (user_id, key, scope, profile_id, value)
VALUES ((SELECT id FROM users WHERE username = 'migtest'),
'playback.subtitle_mode', 'profile', 'no-such-profile', '"auto"'::jsonb)`)
if err == nil {
t.Error("a row for a nonexistent profile was accepted")
}
})
}
// seedLegacyPostgresSettings writes the pre-cutover rows a real install holds.
func seedLegacyPostgresSettings(ctx context.Context, t *testing.T, pool *pgxpool.Pool) {
t.Helper()
var userID int
err := pool.QueryRow(ctx, `
INSERT INTO users (username, email, password_hash, role)
VALUES ('migtest', 'migtest@example.com', 'x', 'user')
ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email
RETURNING id`).Scan(&userID)
if err != nil {
t.Fatalf("seeding user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := pool.Exec(ctx, `
INSERT INTO user_profiles
(user_id, id, name, quality_preference, language, subtitle_language,
subtitle_mode, show_forced_subtitles, preferred_metadata_language)
VALUES ($1, 'mp1', 'Migrate Me', '1080p', 'ja', 'en', 'always', false, 'fr')
ON CONFLICT (user_id, id) DO NOTHING`, userID); err != nil {
t.Fatalf("seeding profile: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_device_settings (user_id, profile_id, device_id, key, value)
VALUES ($1, 'mp1', 'md1', 'playback.preferred_quality', '1080p-high')
ON CONFLICT (user_id, profile_id, device_id, key) DO UPDATE SET value = EXCLUDED.value`,
userID); err != nil {
t.Fatalf("seeding device setting: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_settings (user_id, key, value)
VALUES ($1, 'ui_theme', 'cobalt-studio'), ($1, 'legacy.unknown.key', 'whatever')
ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value`, userID); err != nil {
t.Fatalf("seeding user settings: %v", err)
}
// Clear anything a prior run left, so the assertions above see only this
// seed's conversions.
if _, err := pool.Exec(ctx,
`DELETE FROM user_setting_values WHERE user_id = $1`, userID); err != nil {
t.Fatalf("clearing prior values: %v", err)
}
if _, err := pool.Exec(ctx,
`DELETE FROM user_setting_migration_rejects WHERE user_id = $1`, userID); err != nil {
t.Fatalf("clearing prior rejects: %v", err)
}
}