The durable "I asked for this" record was called Want, and the one-shot search-and-grab attempt was called Request — names that didn't match what either actually did. Want is now Request, and the old Request/Item is now Download/DownloadItem, with a table-rename migration (download_wants -> download_requests, old download_requests -> download_downloads) safe against both fresh installs and existing data. Every anchored manual download now upserts/reuses a durable Request before running, so a "download now" that finds nothing is picked up by the background reconciler automatically instead of just failing with no trace — the gap that caused this session's repeated "no candidates found" failures on the same album. Also adds auto-download guardrails (file-size min/max with a preferred target, allowed file types) that gate what the pipeline may grab unattended, live-editable from a new settings section. The frontend's wanted-view becomes downloads-view, with a new Downloads tab showing attempt/transfer history that previously had no UI at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
289 lines
7.1 KiB
Go
289 lines
7.1 KiB
Go
package datamap_test
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/datamap"
|
|
)
|
|
|
|
// liveTables returns every table and view in a freshly migrated schema.
|
|
func liveTables(t *testing.T, db *database.DB) []string {
|
|
t.Helper()
|
|
|
|
rows, err := db.QueryContext(
|
|
`SELECT name FROM sqlite_master
|
|
WHERE type IN ('table', 'view')
|
|
ORDER BY name`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("read sqlite_master: %v", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var names []string
|
|
|
|
for rows.Next() {
|
|
var name string
|
|
|
|
if err := rows.Scan(&name); err != nil {
|
|
t.Fatalf("scan table name: %v", err)
|
|
}
|
|
|
|
names = append(names, name)
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
// Every table in the schema must be claimed by exactly one catalog
|
|
// entry. This is the mechanism that stops a new table from silently
|
|
// having no deletion policy — the failure mode that made libraries
|
|
// unremovable when tagging_items was added.
|
|
func TestCatalogCoversSchema(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
|
|
for _, name := range liveTables(t, db) {
|
|
if datamap.IsInternal(name) {
|
|
continue
|
|
}
|
|
|
|
if _, ok := datamap.Lookup(name); !ok {
|
|
t.Errorf(
|
|
"table %q exists in the schema but is not in the datamap "+
|
|
"catalog — add an entry stating its Kind and Lifetime",
|
|
name,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The reverse direction: a catalog entry naming a table that no longer
|
|
// exists means the catalog has drifted.
|
|
func TestCatalogHasNoStaleEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
|
|
live := make(map[string]bool)
|
|
for _, name := range liveTables(t, db) {
|
|
live[name] = true
|
|
}
|
|
|
|
for _, entry := range datamap.Tables() {
|
|
if !live[entry.Name] {
|
|
t.Errorf(
|
|
"catalog lists %q but it is not in the schema",
|
|
entry.Name,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
type foreignKey struct {
|
|
child string
|
|
from string
|
|
parent string
|
|
onDelete string
|
|
}
|
|
|
|
// liveForeignKeys reads every foreign key in the schema.
|
|
func liveForeignKeys(t *testing.T, db *database.DB) []foreignKey {
|
|
t.Helper()
|
|
|
|
var out []foreignKey
|
|
|
|
for _, table := range liveTables(t, db) {
|
|
if datamap.IsInternal(table) {
|
|
continue
|
|
}
|
|
|
|
rows, err := db.QueryContext(
|
|
fmt.Sprintf("PRAGMA foreign_key_list(%q)", table),
|
|
)
|
|
if err != nil {
|
|
continue // views have none
|
|
}
|
|
|
|
for rows.Next() {
|
|
var (
|
|
id, seq int
|
|
parent, from, to, onUpd, onDel, matchOn string
|
|
)
|
|
|
|
if err := rows.Scan(
|
|
&id, &seq, &parent, &from, &to, &onUpd, &onDel, &matchOn,
|
|
); err != nil {
|
|
continue
|
|
}
|
|
|
|
out = append(out, foreignKey{
|
|
child: table,
|
|
from: from,
|
|
parent: parent,
|
|
onDelete: onDel,
|
|
})
|
|
}
|
|
|
|
_ = rows.Close()
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// A foreign key with NO ACTION blocks its parent's deletion until
|
|
// application code clears the child rows. Any table with such a key
|
|
// must therefore declare Lifetime "swept" — an assertion that some
|
|
// removal path or janitor actually deletes them. Declaring "retained"
|
|
// or "cascade" while holding a NO ACTION key is the exact shape of the
|
|
// tagging_items bug.
|
|
func TestNoActionForeignKeysAreDeclaredSwept(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
|
|
for _, fk := range liveForeignKeys(t, db) {
|
|
if fk.onDelete != "NO ACTION" {
|
|
continue
|
|
}
|
|
|
|
entry, ok := datamap.Lookup(fk.child)
|
|
if !ok {
|
|
continue // TestCatalogCoversSchema reports this
|
|
}
|
|
|
|
if entry.Lifetime != datamap.Swept {
|
|
t.Errorf(
|
|
"%s.%s references %s with ON DELETE NO ACTION, so it "+
|
|
"blocks deletion of %s — but the catalog declares "+
|
|
"Lifetime %q. Either declare it %q and delete the rows "+
|
|
"explicitly, or give the key an ON DELETE action.",
|
|
fk.child, fk.from, fk.parent, fk.parent,
|
|
entry.Lifetime, datamap.Swept,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Declared cascade/set-null lifetimes must match the actual schema, so
|
|
// the catalog cannot quietly drift from what SQLite enforces.
|
|
func TestLifetimesMatchSchema(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := database.NewTestDB(t)
|
|
|
|
actual := make(map[string]map[string]bool)
|
|
|
|
for _, fk := range liveForeignKeys(t, db) {
|
|
if actual[fk.child] == nil {
|
|
actual[fk.child] = make(map[string]bool)
|
|
}
|
|
|
|
actual[fk.child][fk.onDelete] = true
|
|
}
|
|
|
|
for _, entry := range datamap.Tables() {
|
|
switch entry.Lifetime {
|
|
case datamap.Cascade:
|
|
if !actual[entry.Name]["CASCADE"] {
|
|
t.Errorf(
|
|
"%s declares Lifetime cascade but has no "+
|
|
"ON DELETE CASCADE foreign key",
|
|
entry.Name,
|
|
)
|
|
}
|
|
case datamap.SetNull:
|
|
if !actual[entry.Name]["SET NULL"] {
|
|
t.Errorf(
|
|
"%s declares Lifetime set-null but has no "+
|
|
"ON DELETE SET NULL foreign key",
|
|
entry.Name,
|
|
)
|
|
}
|
|
case datamap.Swept, datamap.Retained:
|
|
// No schema-level obligation.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Authored data is unrecoverable, so it must never be removed as a side
|
|
// effect of deleting owned data. Cascade is allowed only where the
|
|
// catalog explains why (play_history, queue_tracks); this test pins the
|
|
// set so a new cascade onto authored data is a deliberate decision.
|
|
func TestAuthoredCascadesAreDeliberate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowed := map[string]bool{
|
|
"play_history": true,
|
|
"queue_tracks": true,
|
|
|
|
// Download history is scoped to the library it imported into.
|
|
// When that library is removed the files it acquired go with
|
|
// it, so a download describing "fetch this into library 3" has
|
|
// nothing left to mean. Keeping the rows would leave history
|
|
// pointing at a library the user deleted.
|
|
"download_downloads": true,
|
|
|
|
// Items belong to their download and have no independent
|
|
// meaning; they cascade with it.
|
|
"download_items": true,
|
|
|
|
// A request says "put this in library 3". Delete that library
|
|
// and there is no longer anywhere for it to go, so the request
|
|
// has nothing left to mean — the same reasoning as its
|
|
// downloads. The second cascade, artist request to derived
|
|
// album requests, is the point of the subscription:
|
|
// unsubscribing from an artist must stop the albums it queued
|
|
// on the user's behalf.
|
|
"download_requests": true,
|
|
}
|
|
|
|
for _, entry := range datamap.ByKind(datamap.Authored) {
|
|
if entry.Lifetime == datamap.Cascade && !allowed[entry.Name] {
|
|
t.Errorf(
|
|
"authored table %q cascades on delete — authored data is "+
|
|
"unrecoverable, so this needs an explicit exemption "+
|
|
"and a note explaining it",
|
|
entry.Name,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every catalogued table needs a note; the classification is only useful
|
|
// if the reasoning is written down.
|
|
func TestEveryEntryHasANote(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, entry := range datamap.Tables() {
|
|
if entry.Note == "" {
|
|
t.Errorf("catalog entry %q has no Note", entry.Name)
|
|
}
|
|
}
|
|
|
|
for _, dir := range datamap.Directories() {
|
|
if dir.Note == "" {
|
|
t.Errorf("catalog directory %q has no Note", dir.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// FTS shadow tables must resolve to their parent entry rather than
|
|
// needing catalogue entries of their own.
|
|
func TestFTSShadowResolution(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
entry, ok := datamap.Lookup("search_index_data")
|
|
if !ok {
|
|
t.Fatal("search_index_data did not resolve to a catalog entry")
|
|
}
|
|
|
|
if entry.Name != "search_index" {
|
|
t.Errorf("resolved to %q, want search_index", entry.Name)
|
|
}
|
|
}
|