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
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
package library
|
package library
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
"yellowjacket/backend/metadata"
|
"yellowjacket/backend/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -292,3 +297,422 @@ func TestMapTrackRow(t *testing.T) {
|
|||||||
t.Errorf("null DiscNumber = %d, want 0", trackNull.DiscNumber)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user