146 lines
4.7 KiB
Go
146 lines
4.7 KiB
Go
package userdb
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestSQLiteSettingValueConstraints pins the schema's own guarantees rather than
|
||
|
|
// the Go validation in front of them. SettingIdentity.Validate normally keeps a
|
||
|
|
// malformed row from reaching SQL, so nothing else here would notice if the
|
||
|
|
// CHECK constraints or the partial unique indexes were dropped — and the
|
||
|
|
// one-time migration will write these rows in bulk, without going through the
|
||
|
|
// per-request path.
|
||
|
|
func TestSQLiteSettingValueConstraints(t *testing.T) {
|
||
|
|
db := newSchemaDB(t)
|
||
|
|
|
||
|
|
rejected := []struct {
|
||
|
|
name string
|
||
|
|
sql string
|
||
|
|
args []any
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "unknown scope",
|
||
|
|
sql: insertSettingValueSQL,
|
||
|
|
args: []any{"playback.audio_language", "wishful", nil, nil, nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "account scope carrying a profile",
|
||
|
|
sql: insertSettingValueSQL,
|
||
|
|
args: []any{"playback.audio_language", "account", "p1", nil, nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "profile scope without a profile",
|
||
|
|
sql: insertSettingValueSQL,
|
||
|
|
args: []any{"playback.audio_language", "profile", nil, nil, nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "device scope without a device",
|
||
|
|
sql: insertSettingValueSQL,
|
||
|
|
args: []any{"playback.audio_language", "profile_device", "p1", nil, nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "library scope carrying a series",
|
||
|
|
sql: insertSettingValueSQL,
|
||
|
|
args: []any{"playback.audio_language", "profile_library", "p1", nil, 42, "s-1", `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "series scope without a series",
|
||
|
|
sql: insertSettingValueSQL,
|
||
|
|
args: []any{"playback.audio_language", "profile_series", "p1", nil, nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "value is not JSON",
|
||
|
|
sql: insertSettingValueSQL,
|
||
|
|
args: []any{"playback.audio_language", "profile", "p1", nil, nil, nil, `en`},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tc := range rejected {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
if _, err := db.Exec(tc.sql, tc.args...); err == nil {
|
||
|
|
t.Fatalf("insert was accepted; the table must reject %s", tc.name)
|
||
|
|
} else if !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||
|
|
t.Fatalf("insert failed with %v, want a CHECK constraint violation", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSQLiteSettingValuePartialUniqueIndexes pins that each partial unique index
|
||
|
|
// exists and applies only to its own scope: a duplicate identity is refused, and
|
||
|
|
// the same key at a neighboring identity is not.
|
||
|
|
func TestSQLiteSettingValuePartialUniqueIndexes(t *testing.T) {
|
||
|
|
const key = "playback.audio_language"
|
||
|
|
|
||
|
|
duplicates := []struct {
|
||
|
|
name string
|
||
|
|
row []any
|
||
|
|
neighbor []any
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "account",
|
||
|
|
row: []any{key, "account", nil, nil, nil, nil, `"en"`},
|
||
|
|
neighbor: []any{key + ".other", "account", nil, nil, nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "profile",
|
||
|
|
row: []any{key, "profile", "p1", nil, nil, nil, `"en"`},
|
||
|
|
neighbor: []any{key, "profile", "p2", nil, nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "profile_device",
|
||
|
|
row: []any{key, "profile_device", "p1", "apple-tv", nil, nil, `"en"`},
|
||
|
|
neighbor: []any{key, "profile_device", "p1", "iphone", nil, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "profile_library",
|
||
|
|
row: []any{key, "profile_library", "p1", nil, 42, nil, `"en"`},
|
||
|
|
neighbor: []any{key, "profile_library", "p1", nil, 43, nil, `"en"`},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "profile_series",
|
||
|
|
row: []any{key, "profile_series", "p1", nil, nil, "s-1", `"en"`},
|
||
|
|
neighbor: []any{key, "profile_series", "p1", nil, nil, "s-2", `"en"`},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tc := range duplicates {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
db := newSchemaDB(t)
|
||
|
|
if _, err := db.Exec(insertSettingValueSQL, tc.row...); err != nil {
|
||
|
|
t.Fatalf("first insert: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := db.Exec(insertSettingValueSQL, tc.row...); err == nil {
|
||
|
|
t.Fatal("duplicate identity was accepted; the partial unique index is missing")
|
||
|
|
} else if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||
|
|
t.Fatalf("duplicate insert failed with %v, want a UNIQUE constraint violation", err)
|
||
|
|
}
|
||
|
|
if _, err := db.Exec(insertSettingValueSQL, tc.neighbor...); err != nil {
|
||
|
|
t.Fatalf("neighboring identity was refused: %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// insertSettingValueSQL writes a row without going through the store, so the
|
||
|
|
// schema is the only thing standing between the test and the table.
|
||
|
|
const insertSettingValueSQL = `
|
||
|
|
INSERT INTO user_setting_values
|
||
|
|
(key, scope, profile_id, device_id, library_id, series_id, value, created_at, updated_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-07-27T00:00:00Z', '2026-07-27T00:00:00Z')`
|
||
|
|
|
||
|
|
func newSchemaDB(t *testing.T) *sql.DB {
|
||
|
|
t.Helper()
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
return db
|
||
|
|
}
|