Files
yellowjacket/backend/library/removal_test.go
T
yonlu 32bb64918c
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m19s
CI / e2e (pull_request) Successful in 10m53s
fix(library): sweep orphaned cover art when an album empties
pruneEmptyEntities deleted empty albums but never the cover_art rows
they referenced, so removing the last track of an album leaked the row
and its files forever — the janitor's covers sweep computes its live set
from cover_art.file_path, which keeps the orphaned row's files exempt.

Extract sweepOrphanedCoverArt/removeCoverArtFiles as one implementation
and run it from pruneEmptyEntities (scan orphan path and
RemoveFromLibrary) and RemoveLibrary alike.

Closes #247
2026-09-09 10:14:07 -04:00

251 lines
6.2 KiB
Go

package library
import (
"os"
"path/filepath"
"testing"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// seedRemovableLibrary builds a library with one track, its recording
// chain, a cover art row, and a tagging queue entry — the shape a real
// scan leaves behind.
func seedRemovableLibrary(
t *testing.T,
lib *Library,
coverPath string,
) sqlcgen.Library {
t.Helper()
ctx := lib.ctx
q := lib.db.Queries
library, err := q.CreateLibrary(ctx, sqlcgen.CreateLibraryParams{
Name: "Test Library",
Path: "/music",
})
if err != nil {
t.Fatalf("create library: %v", err)
}
if _, err := lib.db.ExecContext(
`INSERT INTO cover_art (is_embedded, file_path, mime_type)
VALUES (0, ?, 'image/jpeg')`, coverPath,
); err != nil {
t.Fatalf("insert cover art: %v", err)
}
database.InsertTestTrack(t, lib.db, database.TestTrack{
FilePath: "/music/song.mp3",
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
LengthMs: 180000,
LibraryID: library.ID,
})
// Every scanned library gets tagging_items rows, one per album
// folder. These FK-reference libraries.
if _, err := lib.db.ExecContext(
`INSERT INTO tagging_items (group_key, library_id, album_name)
VALUES ('grp1', ?, 'Test Album')`, library.ID,
); err != nil {
t.Fatalf("insert tagging_items: %v", err)
}
if _, err := lib.db.ExecContext(
`INSERT INTO tagging_candidates (group_key, candidates)
VALUES ('grp1', '[]')`,
); err != nil {
t.Fatalf("insert tagging_candidates: %v", err)
}
return library
}
func countRows(
t *testing.T,
lib *Library,
table string,
args ...any,
) int64 {
t.Helper()
query := "SELECT COUNT(*) FROM " + table
rows, err := lib.db.QueryContext(query, args...)
if err != nil {
t.Fatalf("count %s: %v", table, err)
}
defer func() { _ = rows.Close() }()
var n int64
if rows.Next() {
if err := rows.Scan(&n); err != nil {
t.Fatalf("scan count %s: %v", table, err)
}
}
return n
}
// A library with tagging_items must still be removable. tagging_items
// FK-references libraries with no ON DELETE clause, so leaving those
// rows behind fails the DELETE and rolls back the entire removal.
func TestRemoveLibrary_WithTaggingItems(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
library := seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
summary, err := lib.RemoveLibrary(library.ID)
if err != nil {
t.Fatalf("RemoveLibrary: %v", err)
}
if summary.TracksDeleted != 1 {
t.Errorf("TracksDeleted = %d, want 1", summary.TracksDeleted)
}
// The test DB keeps a sentinel library at id=0 so audio_files rows
// using the default library_id satisfy their FK, so scope this one
// to the library actually removed.
if n := countRows(
t, lib, "libraries WHERE id = ?", library.ID,
); n != 0 {
t.Errorf("library row still present after removal")
}
for _, table := range []string{
"audio_files",
"albums",
"artists",
"file_genres",
"tagging_items",
"tagging_candidates",
"cover_art",
} {
if n := countRows(t, lib, table); n != 0 {
t.Errorf("%s has %d rows after removal, want 0", table, n)
}
}
}
// Removing a library must delete the cover art original *and* its
// derived size variants, which are not recorded in the database.
func TestRemoveLibrary_DeletesCoverArtVariants(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
dir := t.TempDir()
var paths []string
for _, tier := range thumbnailTiers {
paths = append(paths, filepath.Join(
dir, coverart.SizedFilename("abc123.jpg", tier.Suffix),
))
}
// The largest tier is what cover_art.file_path names.
cover := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", "_lg"))
for _, p := range paths {
if err := os.WriteFile(p, []byte("img"), 0o600); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
library := seedRemovableLibrary(t, lib, cover)
if _, err := lib.RemoveLibrary(library.ID); err != nil {
t.Fatalf("RemoveLibrary: %v", err)
}
for _, p := range paths {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("cover art file still present: %s", filepath.Base(p))
}
}
}
// CoverArtFileSet must cover every generated tier, from any of them:
// cover_art.file_path names the largest, and the others are derived.
func TestCoverArtFileSet(t *testing.T) {
t.Parallel()
got := CoverArtFileSet("/covers/abc123_lg.jpg")
want := []string{
"/covers/abc123_sm.jpg",
"/covers/abc123_md.jpg",
"/covers/abc123_lg.jpg",
}
if len(got) != len(want) {
t.Fatalf("got %d paths, want %d: %v", len(got), len(want), got)
}
for i, w := range want {
if got[i] != w {
t.Errorf("path %d = %q, want %q", i, got[i], w)
}
}
}
// Removing the last track of an album must take the album's cover art
// with it — both the row and every derived file — or the row keeps its
// files exempt from the janitor's covers sweep forever (#247).
func TestRemoveFromLibrary_DeletesOrphanedCoverArt(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
dir := t.TempDir()
// The largest tier is what cover_art.file_path names; write every
// variant so the sweep has a real set to remove.
for _, tier := range thumbnailTiers {
p := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", tier.Suffix))
if err := os.WriteFile(p, []byte("img"), 0o600); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
cover := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", "_lg"))
seedRemovableLibrary(t, lib, cover)
// Link the album to the cover so it is not orphaned until the track
// (and with it the album) goes.
if _, err := lib.db.ExecContext(
`UPDATE albums SET cover_art_id =
(SELECT id FROM cover_art WHERE file_path = ?)
WHERE name = 'Test Album'`,
cover,
); err != nil {
t.Fatalf("link cover art: %v", err)
}
if _, err := lib.RemoveFromLibrary([]string{"/music/song.mp3"}); err != nil {
t.Fatalf("RemoveFromLibrary: %v", err)
}
if n := countRows(t, lib, "cover_art"); n != 0 {
t.Errorf("cover_art has %d rows after removal, want 0", n)
}
for _, tier := range thumbnailTiers {
p := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", tier.Suffix))
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("cover art file still present: %s", filepath.Base(p))
}
}
}