* 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
221 lines
9.5 KiB
Go
221 lines
9.5 KiB
Go
package userdb
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
const settingValuesSchemaV17 = `
|
|
CREATE TABLE user_setting_values (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
profile_id TEXT,
|
|
device_id TEXT,
|
|
library_id INTEGER,
|
|
series_id TEXT,
|
|
value TEXT NOT NULL CHECK (json_valid(value)),
|
|
revision INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
|
|
CHECK (
|
|
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
|
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
|
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
|
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
|
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
|
)
|
|
);`
|
|
|
|
func TestMigrateToV18AddsClientFamilyAndSeedsShortcuts(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("initial InitSchema: %v", err)
|
|
}
|
|
|
|
// Replace only the canonical values table with its released v17 shape. On a
|
|
// real open, InitSchema sees exactly this table before runMigrations.
|
|
for _, index := range []string{
|
|
"user_setting_values_account_uq", "user_setting_values_profile_uq",
|
|
"user_setting_values_profile_client_uq", "user_setting_values_profile_device_uq",
|
|
"user_setting_values_profile_library_uq", "user_setting_values_profile_series_uq",
|
|
"user_setting_values_resolution_idx", "user_setting_values_series_idx",
|
|
"user_setting_values_library_idx",
|
|
} {
|
|
if _, err := db.Exec("DROP INDEX IF EXISTS " + index); err != nil {
|
|
t.Fatalf("drop %s: %v", index, err)
|
|
}
|
|
}
|
|
if _, err := db.Exec("DROP TABLE user_setting_values"); err != nil {
|
|
t.Fatalf("drop current values table: %v", err)
|
|
}
|
|
if _, err := db.Exec(settingValuesSchemaV17); err != nil {
|
|
t.Fatalf("create v17 values table: %v", err)
|
|
}
|
|
if _, err := db.Exec("PRAGMA user_version = 17"); err != nil {
|
|
t.Fatalf("set v17: %v", err)
|
|
}
|
|
|
|
const created = "2026-07-28T10:00:00Z"
|
|
const updated = "2026-08-01T12:00:00Z"
|
|
legacy := `{"12":[{"type":"collection","id":"classics","label":"Classics"}],"7":[{"type":"section","id":"recently-added","label":"Recently Added"},{"type":"section","id":" ","label":"Whitespace ID"},{"type":"collection","id":"horror","label":"Horror"}],"favorites":[{"type":"collection","id":"keep-me","label":"Legacy group"}]}`
|
|
encodedLegacy, err := json.Marshal(legacy)
|
|
if err != nil {
|
|
t.Fatalf("encode legacy JSON string: %v", err)
|
|
}
|
|
if _, err := db.Exec(`
|
|
INSERT INTO user_setting_values
|
|
(key, scope, profile_id, value, revision, created_at, updated_at)
|
|
VALUES ('ui.sidebar_pins', 'profile', 'p1', ?, 4, ?, ?),
|
|
('ui.sidebar_pins', 'profile', 'p2', ?, 2, ?, ?),
|
|
('ui.sidebar_pins', 'profile', 'p3', ?, 5, ?, ?),
|
|
('nav.shortcuts', 'profile', 'p2', '{"items":[]}', 3, ?, ?)`,
|
|
legacy, created, updated,
|
|
legacy, created, updated,
|
|
string(encodedLegacy), created, updated,
|
|
created, updated,
|
|
); err != nil {
|
|
t.Fatalf("seed v17 rows: %v", err)
|
|
}
|
|
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("upgrade InitSchema: %v", err)
|
|
}
|
|
if err := runMigrations(db); err != nil {
|
|
t.Fatalf("runMigrations: %v", err)
|
|
}
|
|
version, err := userVersion(db)
|
|
if err != nil || version != schemaVersion {
|
|
t.Fatalf("user_version = %d (%v), want %d", version, err, schemaVersion)
|
|
}
|
|
|
|
var columnCount int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('user_setting_values')
|
|
WHERE name = 'client_family'`).Scan(&columnCount); err != nil || columnCount != 1 {
|
|
t.Fatalf("client_family column count = %d (%v), want 1", columnCount, err)
|
|
}
|
|
|
|
// The legacy row is preserved byte-for-byte and keeps its revision.
|
|
var kept string
|
|
var revision int
|
|
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
|
|
WHERE key = 'ui.sidebar_pins' AND profile_id = 'p1'`).Scan(&kept, &revision); err != nil {
|
|
t.Fatalf("read legacy row: %v", err)
|
|
}
|
|
if kept != legacy || revision != 4 {
|
|
t.Fatalf("legacy row changed to %s at revision %d", kept, revision)
|
|
}
|
|
|
|
var migrated string
|
|
if err := db.QueryRow(`SELECT value FROM user_setting_values
|
|
WHERE key = 'nav.shortcuts' AND profile_id = 'p1'`).Scan(&migrated); err != nil {
|
|
t.Fatalf("read migrated shortcuts: %v", err)
|
|
}
|
|
assertJSONValueEqual(t, migrated, `{"items":[{"label":"Recently Added","library_id":7,"section_id":"recently-added","type":"section"},{"collection_id":"horror","label":"Horror","library_id":7,"type":"collection"},{"collection_id":"classics","label":"Classics","library_id":12,"type":"collection"}]}`)
|
|
|
|
// Legacy web builds also wrote the grouped object as one JSON string. It is
|
|
// preserved as authored while seeding the same canonical shortcut document.
|
|
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
|
|
WHERE key = 'ui.sidebar_pins' AND profile_id = 'p3'`).Scan(&kept, &revision); err != nil {
|
|
t.Fatalf("read JSON-string legacy row: %v", err)
|
|
}
|
|
if kept != string(encodedLegacy) || revision != 5 {
|
|
t.Fatalf("JSON-string legacy row changed to %s at revision %d", kept, revision)
|
|
}
|
|
if err := db.QueryRow(`SELECT value FROM user_setting_values
|
|
WHERE key = 'nav.shortcuts' AND profile_id = 'p3'`).Scan(&migrated); err != nil {
|
|
t.Fatalf("read shortcuts migrated from JSON string: %v", err)
|
|
}
|
|
assertJSONValueEqual(t, migrated, `{"items":[{"label":"Recently Added","library_id":7,"section_id":"recently-added","type":"section"},{"collection_id":"horror","label":"Horror","library_id":7,"type":"collection"},{"collection_id":"classics","label":"Classics","library_id":12,"type":"collection"}]}`)
|
|
|
|
// Existing new-format data wins rather than being overwritten by legacy.
|
|
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
|
|
WHERE key = 'nav.shortcuts' AND profile_id = 'p2'`).Scan(&migrated, &revision); err != nil {
|
|
t.Fatalf("read pre-existing shortcuts: %v", err)
|
|
}
|
|
assertJSONValueEqual(t, migrated, `{"items":[]}`)
|
|
if revision != 3 {
|
|
t.Fatalf("pre-existing shortcut revision = %d, want 3", revision)
|
|
}
|
|
|
|
store := NewSQLiteUserStore(db)
|
|
id := userstore.SettingIdentity{
|
|
Key: "nav.primary_menu", Scope: settingscontract.ScopeProfileClient,
|
|
ProfileID: "p1", ClientFamily: settingscontract.ClientFamilyTV,
|
|
}
|
|
if _, err := store.UpsertSettingValue(context.Background(), id,
|
|
json.RawMessage(`{"items":[{"type":"builtin","destination":"home"}]}`)); err != nil {
|
|
t.Fatalf("write profile_client value: %v", err)
|
|
}
|
|
got, err := store.GetSettingValue(context.Background(), id)
|
|
if err != nil || got == nil || got.ClientFamily != settingscontract.ClientFamilyTV {
|
|
t.Fatalf("profile_client round trip = %+v (%v)", got, err)
|
|
}
|
|
}
|
|
|
|
func TestNavigationShortcutsFromSidebarPinsSkipsMalformedSiblingsAndBoundsLibraryIDs(t *testing.T) {
|
|
raw := `{
|
|
"7":[
|
|
{"type":"section","id":"recent","label":"Recent"},
|
|
{"type":"section","id":99,"label":"Wrong ID type"},
|
|
{"type":"collection","id":"horror","label":"Horror"},
|
|
{"type":"collection","id":"bad-label","label":42}
|
|
],
|
|
"8":{"type":"collection","id":"not-an-array","label":"Skipped group"},
|
|
"9":[null,{"type":"collection","id":"drama","label":"Drama"}],
|
|
"2147483647":[{"type":"section","id":"boundary","label":"Boundary"}],
|
|
"2147483648":[{"type":"section","id":"overflow","label":"Overflow"}],
|
|
"999999999999999999999999999999":[{"type":"section","id":"huge","label":"Huge"}],
|
|
"07":[{"type":"section","id":"leading-zero","label":"Leading Zero"}]
|
|
}`
|
|
|
|
value, ok := navigationShortcutsFromSidebarPins(raw)
|
|
if !ok {
|
|
t.Fatal("migration rejected valid siblings because malformed groups/items were present")
|
|
}
|
|
assertJSONValueEqual(t, string(value), `{"items":[
|
|
{"type":"section","library_id":7,"section_id":"recent","label":"Recent"},
|
|
{"type":"collection","library_id":7,"collection_id":"horror","label":"Horror"},
|
|
{"type":"collection","library_id":9,"collection_id":"drama","label":"Drama"},
|
|
{"type":"section","library_id":2147483647,"section_id":"boundary","label":"Boundary"}
|
|
]}`)
|
|
}
|
|
|
|
func TestNavigationShortcutsFromSidebarPinsRejectsOnlyOverflowLibraryIDs(t *testing.T) {
|
|
for name, raw := range map[string]string{
|
|
"one over int32": `{"2147483648":[{"type":"section","id":"overflow","label":"Overflow"}]}`,
|
|
"parse overflow": `{"999999999999999999999999999999":[{"type":"section","id":"huge","label":"Huge"}]}`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if value, ok := navigationShortcutsFromSidebarPins(raw); ok || value != nil {
|
|
t.Fatalf("overflow-only pins migrated as %s", value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertJSONValueEqual(t *testing.T, got, want string) {
|
|
t.Helper()
|
|
var gotValue, wantValue any
|
|
if err := json.Unmarshal([]byte(got), &gotValue); err != nil {
|
|
t.Fatalf("decode got JSON: %v", err)
|
|
}
|
|
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
|
|
t.Fatalf("decode want JSON: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(gotValue, wantValue) {
|
|
t.Fatalf("JSON = %s, want %s", got, want)
|
|
}
|
|
}
|