From fa6c378e25e81a0fbc23fb7ffa4e2bede2493434 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 4 Mar 2026 16:37:48 -0500 Subject: [PATCH] test(05-02): add entity cache and orphan cleanup tests with DB backing - TestCachedUpsertArtistCredit: cache hit returns same ID on second call - TestCachedLinkArtist: skips duplicate INSERT via linkedCredits cache - TestCachedLinkArtist_MultiCredit: same artist linked to different credits - TestCachedUpsertGenre: genre cache returns same ID on repeated calls - TestResolveReleaseGroup: creates release group, updates cover art on cache hit - TestResolveReleaseGroup_CacheHit: pre-populated cache returns cached ID - TestOrphanDeletion: DeleteAudioFile removes row, documents contentless FTS5 limitation - TestEntityCache_EmptyFields: empty artist name, empty album, AlbumArtist reuse --- backend/library/scan_test.go | 424 +++++++++++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) diff --git a/backend/library/scan_test.go b/backend/library/scan_test.go index 2ab00bc..d4de577 100644 --- a/backend/library/scan_test.go +++ b/backend/library/scan_test.go @@ -1,9 +1,14 @@ package library import ( + "context" "database/sql" + "fmt" + "log/slog" "testing" + "yellowjacket/backend/database" + "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/metadata" ) @@ -292,3 +297,422 @@ func TestMapTrackRow(t *testing.T) { t.Errorf("null DiscNumber = %d, want 0", trackNull.DiscNumber) } } + +// --------------------------------------------------------------------------- +// Test helper — constructs a Library backed by an in-memory test DB +// --------------------------------------------------------------------------- + +func setupTestLibrary(t *testing.T) (*Library, *database.DB) { + t.Helper() + + db := database.NewTestDB(t) + + // Construct Library directly (internal test) — avoids Config.Validate + // calling os.Stat on the directory. Entity cache functions only need + // l.ctx and l.db; they have no Wails runtime dependency. + lib := &Library{ + ctx: t.Context(), + logger: slog.Default(), + conf: &Config{}, + db: db, + } + + return lib, db +} + +// --------------------------------------------------------------------------- +// Entity cache tests — DB-backed +// --------------------------------------------------------------------------- + +func TestCachedUpsertArtistCredit(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // First call — hits DB. + ac1, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("first cachedUpsertArtistCredit: %v", err) + } + + if ac1.ID == 0 { + t.Fatal("expected non-zero ArtistCredit ID") + } + + // Second call — cache hit, same ID. + ac2, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("second cachedUpsertArtistCredit: %v", err) + } + + if ac2.ID != ac1.ID { + t.Errorf("cache miss: got ID %d, want %d", ac2.ID, ac1.ID) + } + + // Different name — different ID. + ac3, err := lib.cachedUpsertArtistCredit(q, cache, "Beyoncé") + if err != nil { + t.Fatalf("cachedUpsertArtistCredit(Beyoncé): %v", err) + } + + if ac3.ID == ac1.ID { + t.Errorf("different name returned same ID %d", ac3.ID) + } + + // Cache should have 2 entries. + if len(cache.artistCredits) != 2 { + t.Errorf("cache entries = %d, want 2", len(cache.artistCredits)) + } +} + +func TestCachedLinkArtist(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + metrics := newScanMetrics() + + // Create an artist credit first. + ac, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert artist credit: %v", err) + } + + // First link — creates artist + artist-credit-artist link. + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac.ID) + + if len(cache.artists) != 1 { + t.Errorf("artists cache = %d, want 1", len(cache.artists)) + } + + if len(cache.linkedCredits) != 1 { + t.Errorf("linkedCredits cache = %d, want 1", len(cache.linkedCredits)) + } + + // Second call with same args — should skip (cache hit). + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac.ID) + + if len(cache.linkedCredits) != 1 { + t.Errorf("linkedCredits after duplicate = %d, want 1 (should skip)", len(cache.linkedCredits)) + } +} + +func TestCachedLinkArtist_MultiCredit(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + metrics := newScanMetrics() + + // Two different artist credits referencing the same artist name. + ac1, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert credit 1: %v", err) + } + + ac2, err := lib.cachedUpsertArtistCredit(q, cache, "Queen feat. David Bowie") + if err != nil { + t.Fatalf("upsert credit 2: %v", err) + } + + // Link "Queen" artist to both credits. + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac1.ID) + lib.cachedLinkArtist(q, cache, metrics, "Queen", ac2.ID) + + // Artist cached once. + if len(cache.artists) != 1 { + t.Errorf("artists cache = %d, want 1 (same artist name)", len(cache.artists)) + } + + // Two distinct linked-credit entries. + if len(cache.linkedCredits) != 2 { + t.Errorf("linkedCredits = %d, want 2", len(cache.linkedCredits)) + } + + // Verify link keys are correct format. + queenArtist := cache.artists["Queen"] + key1 := fmt.Sprintf("%d:%d", queenArtist.ID, ac1.ID) + key2 := fmt.Sprintf("%d:%d", queenArtist.ID, ac2.ID) + + if _, ok := cache.linkedCredits[key1]; !ok { + t.Errorf("missing linked credit key %q", key1) + } + + if _, ok := cache.linkedCredits[key2]; !ok { + t.Errorf("missing linked credit key %q", key2) + } +} + +func TestCachedUpsertGenre(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // First call — creates genre. + g1, err := lib.cachedUpsertGenre(q, cache, "Rock") + if err != nil { + t.Fatalf("first cachedUpsertGenre: %v", err) + } + + if g1.ID == 0 { + t.Fatal("expected non-zero Genre ID") + } + + // Second call — cache hit. + g2, err := lib.cachedUpsertGenre(q, cache, "Rock") + if err != nil { + t.Fatalf("second cachedUpsertGenre: %v", err) + } + + if g2.ID != g1.ID { + t.Errorf("cache miss: got ID %d, want %d", g2.ID, g1.ID) + } + + if len(cache.genres) != 1 { + t.Errorf("genre cache entries = %d, want 1", len(cache.genres)) + } +} + +func TestResolveReleaseGroup(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // Need an album artist credit for the release group. + ac, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert artist credit: %v", err) + } + + albumArtistCreditID := sql.NullInt64{Int64: ac.ID, Valid: true} + + // First call — no cover art. + tags := &metadata.TrackMetadata{ + Album: "A Night at the Opera", + Year: 1975, + } + + rgID := lib.resolveReleaseGroup(q, cache, tags, albumArtistCreditID, sql.NullInt64{}) + if !rgID.Valid { + t.Fatal("expected valid release group ID") + } + + if rgID.Int64 == 0 { + t.Fatal("expected non-zero release group ID") + } + + // Verify cached. + if len(cache.releaseGroups) != 1 { + t.Errorf("releaseGroups cache = %d, want 1", len(cache.releaseGroups)) + } + + // Second call — same album with cover art → should update cover art on cached entry. + // First, create a cover art record in the DB. + coverArt, err := q.UpsertCoverArt(lib.ctx, sqlcgen.UpsertCoverArtParams{ + IsEmbedded: true, + FilePath: "/covers/opera.jpg", + MimeType: "image/jpeg", + }) + if err != nil { + t.Fatalf("create cover art: %v", err) + } + + coverArtID := sql.NullInt64{Int64: coverArt.ID, Valid: true} + rgID2 := lib.resolveReleaseGroup(q, cache, tags, albumArtistCreditID, coverArtID) + + if rgID2.Int64 != rgID.Int64 { + t.Errorf("cache miss: got ID %d, want %d", rgID2.Int64, rgID.Int64) + } + + // Cover art should be updated on the cached release group. + cachedRG := cache.releaseGroups["A Night at the Opera"] + if !cachedRG.CoverArtID.Valid { + t.Error("expected CoverArtID to be set after update") + } + + if cachedRG.CoverArtID.Int64 != coverArt.ID { + t.Errorf("CoverArtID = %d, want %d", cachedRG.CoverArtID.Int64, coverArt.ID) + } + + // Empty album → invalid NullInt64. + emptyTags := &metadata.TrackMetadata{Album: ""} + rgEmpty := lib.resolveReleaseGroup(q, cache, emptyTags, albumArtistCreditID, sql.NullInt64{}) + + if rgEmpty.Valid { + t.Errorf("empty album should return invalid NullInt64, got valid with ID %d", rgEmpty.Int64) + } +} + +func TestResolveReleaseGroup_CacheHit(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + + // Pre-populate cache with a known release group. + cache.releaseGroups["Cached Album"] = sqlcgen.ReleaseGroup{ + ID: 42, + Name: "Cached Album", + } + + tags := &metadata.TrackMetadata{Album: "Cached Album"} + rgID := lib.resolveReleaseGroup(q, cache, tags, sql.NullInt64{}, sql.NullInt64{}) + + if !rgID.Valid { + t.Fatal("expected valid release group ID from cache") + } + + if rgID.Int64 != 42 { + t.Errorf("resolveReleaseGroup() = %d, want 42 (cached)", rgID.Int64) + } +} + +// --------------------------------------------------------------------------- +// Orphan cleanup test — DB-level +// --------------------------------------------------------------------------- + +func TestOrphanDeletion(t *testing.T) { + t.Parallel() + + _, db := setupTestLibrary(t) + ctx := context.Background() + q := db.Queries + + // Seed an artist credit → recording → audio file chain. + ac, err := q.UpsertArtistCredit(ctx, "Test Artist") + if err != nil { + t.Fatalf("upsert artist credit: %v", err) + } + + rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ + Name: "Test Song", + ArtistCreditID: ac.ID, + }) + if err != nil { + t.Fatalf("create recording: %v", err) + } + + af, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ + FilePath: "/music/test.mp3", + LengthMilliseconds: 180000, + FileTypeID: 0, + RecordingID: rec.ID, + Basename: "test.mp3", + }) + if err != nil { + t.Fatalf("create audio file: %v", err) + } + + // Add FTS search index entry. + if err := db.InsertSearchIndex(af.ID, "/music/test.mp3", "Test Song", "Test Artist", ""); err != nil { + t.Fatalf("insert search index: %v", err) + } + + // Verify the search index entry exists before deletion. + results, err := db.SearchFTS("Test Song", 10) + if err != nil { + t.Fatalf("search before delete: %v", err) + } + + if len(results) != 1 { + t.Fatalf("search results before delete = %d, want 1", len(results)) + } + + // Delete audio file — this is the primary orphan cleanup step. + if err := q.DeleteAudioFile(ctx, af.ID); err != nil { + t.Fatalf("delete audio file: %v", err) + } + + // Verify audio file is gone by attempting to query all audio files. + allFiles, err := q.GetAllAudioFiles(ctx) + if err != nil { + t.Fatalf("get all audio files: %v", err) + } + + if len(allFiles) != 0 { + t.Errorf("audio files after delete = %d, want 0", len(allFiles)) + } + + // DeleteSearchIndex on contentless FTS5 table (content='') is + // expected to error. The production orphan cleanup code in + // library.go logs this as a warning — the search index entries + // become stale but harmless (they reference a non-existent + // audio_file ID, so JOINs return no results). + // ClearSearchIndex (used during full rescan) handles bulk cleanup. + err = db.DeleteSearchIndex(af.ID) + if err == nil { + t.Log("DeleteSearchIndex succeeded (unexpected for contentless FTS5)") + } + // Not a fatal error — documents the contentless FTS5 limitation. +} + +// --------------------------------------------------------------------------- +// Empty/missing metadata tests +// --------------------------------------------------------------------------- + +func TestEntityCache_EmptyFields(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + cache := newEntityCache() + q := lib.db.Queries + metrics := newScanMetrics() + + // Empty artist credit name — documents behavior (creates "" credit). + ac, err := lib.cachedUpsertArtistCredit(q, cache, "") + if err != nil { + t.Fatalf("cachedUpsertArtistCredit with empty name: %v", err) + } + + if ac.ID == 0 { + t.Error("expected non-zero ID even for empty artist credit name") + } + + // Empty album → resolveReleaseGroup returns invalid NullInt64. + tags := &metadata.TrackMetadata{Album: ""} + rgID := lib.resolveReleaseGroup(q, cache, tags, sql.NullInt64{}, sql.NullInt64{}) + + if rgID.Valid { + t.Errorf("empty album should return invalid NullInt64, got valid ID %d", rgID.Int64) + } + + // resolveAlbumArtistCredit with empty AlbumArtist reuses track artist credit. + trackTags := &metadata.TrackMetadata{ + Artist: "Queen", + AlbumArtist: "", + } + + trackAC, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + if err != nil { + t.Fatalf("upsert track artist credit: %v", err) + } + + albumACID := lib.resolveAlbumArtistCredit(q, cache, metrics, trackTags, trackAC.ID) + if !albumACID.Valid { + t.Fatal("expected valid album artist credit ID when AlbumArtist is empty") + } + + if albumACID.Int64 != trackAC.ID { + t.Errorf("empty AlbumArtist should reuse track credit: got %d, want %d", albumACID.Int64, trackAC.ID) + } + + // resolveAlbumArtistCredit when AlbumArtist matches Artist also reuses. + sameTags := &metadata.TrackMetadata{ + Artist: "Queen", + AlbumArtist: "Queen", + } + + sameACID := lib.resolveAlbumArtistCredit(q, cache, metrics, sameTags, trackAC.ID) + if sameACID.Int64 != trackAC.ID { + t.Errorf("matching AlbumArtist should reuse track credit: got %d, want %d", sameACID.Int64, trackAC.ID) + } +}