* 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
245 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|