Files
yellowjacket/backend/datamap/datamap_test.go
yonluandClaude Opus 5 dc890d1fcc feat(library): remove a track from the library without deleting the file
RemoveFromLibrary deletes the audio_files rows the way the scan's own
orphan cleanup does and records each path in excluded_paths. The
exclusion is not an enhancement: without it the next scan finds the
file, sees no row and imports it again, so the button undoes itself.

The soft scan compares files on disk against rows in the database, so
surveyAudioFiles and countAudioFiles both take the exclusion set —
otherwise an excluded path makes the two disagree forever and queues a
full scan on every launch. Deleting a row cascades to queue_tracks, so
the removal calls the same CompactQueue hook RemoveLibrary does.

Also lands the requested badge: library-status-indicator is a button
again where it can act, utils/library-status.ts states once what owning
and wanting mean, and the long-declared queued state finally has a
producer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 13:12:01 -04:00

297 lines
7.5 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,
// An exclusion says "do not import this path into library 3".
// Remove that library and no scan will ever visit the path
// again, so the row has nothing left to exclude it from — and
// the data it protects is the *absence* of a row, which the
// library removal has already achieved for everything. Adding
// the library back is the user asking to import it afresh.
"excluded_paths": 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)
}
}