Files
silo-server/internal/database/settings_backfill_test.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

245 lines
8.5 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 six 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)
}
var quality, bitrate string
if err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.preferred_quality' AND scope = 'profile' AND profile_id = 'mp1'`).
Scan(&quality); err != nil {
t.Fatalf("reading migrated profile quality: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.max_bitrate_kbps' AND scope = 'profile' AND profile_id = 'mp1'`).
Scan(&bitrate); err != nil {
t.Fatalf("reading migrated profile bitrate: %v", err)
}
if quality != `"1080p"` || bitrate != `6000` {
t.Errorf("profile quality = (%s, %s), want (\"1080p\", 6000)", quality, bitrate)
}
})
t.Run("auto-skip columns migrate only when true", func(t *testing.T) {
var value string
err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.auto_skip_intro' AND scope = 'profile' AND profile_id = 'mp1'`).
Scan(&value)
if err != nil {
t.Fatalf("reading migrated auto_skip_intro: %v", err)
}
if value != `true` {
t.Errorf("auto_skip_intro = %s, want true", value)
}
// The false column is the default and must not become a stored choice.
var count int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*) FROM user_setting_values
WHERE key = 'playback.auto_skip_credits' AND profile_id = 'mp1'`).Scan(&count); err != nil {
t.Fatalf("counting auto_skip_credits rows: %v", err)
}
if count != 0 {
t.Error("an untouched false auto_skip_credits column became a row")
}
})
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,
auto_skip_intro, auto_skip_credits)
VALUES ($1, 'mp1', 'Migrate Me', '1080p', 'ja', 'en', 'always', false, 'fr',
true, false)
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)
}
}