package scanner import ( "context" "testing" "naviwatcher/internal/database" "naviwatcher/internal/musicbrainz" ) // newTestDB creates an in-memory SQLite database with all migrations applied. func newTestDB(t *testing.T) *database.DB { t.Helper() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } return db } // seedArtist inserts a minimal artist_settings row so FK constraints pass. func seedArtist(t *testing.T, db *database.DB, id, name string) { t.Helper() if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: id, Name: name, Monitored: true, }); err != nil { t.Fatalf("seedArtist(%s) error: %v", id, err) } } // seedLocalAlbum inserts a local_albums row for an artist. func seedLocalAlbum(t *testing.T, db *database.DB, id, artistID, title string) { t.Helper() if err := database.SaveLocalAlbum(db, &database.LocalAlbum{ ID: id, ArtistID: artistID, Title: title, }); err != nil { t.Fatalf("seedLocalAlbum(%s) error: %v", id, err) } } // seedExternalRelease inserts an external_releases row for an artist. func seedExternalRelease(t *testing.T, db *database.DB, rgid, artistID, title string, ignored bool) { t.Helper() if err := database.SaveExternalRelease(db, &database.ExternalRelease{ RGID: rgid, ArtistID: artistID, Title: title, IsIgnored: ignored, }); err != nil { t.Fatalf("seedExternalRelease(%s) error: %v", rgid, err) } } // TestScanArtist verifies the basic functionality of ScanArtist. func TestScanArtist(t *testing.T) { db := newTestDB(t) defer db.Close() seedArtist(t, db, "artist-1", "Pink Floyd") // Local collection has "The Wall" but not "Animals". seedLocalAlbum(t, db, "l1", "artist-1", "The Wall") seedExternalRelease(t, db, "rg1", "artist-1", "The Wall", false) seedExternalRelease(t, db, "rg2", "artist-1", "Animals", false) missing, err := ScanArtist(context.Background(), db, "artist-1", 0) if err != nil { t.Fatalf("ScanArtist() error: %v", err) } rgids := map[string]bool{} for _, m := range missing { rgids[m.RGID] = true } if !rgids["rg2"] { t.Errorf("expected rg2 (Animals) to be missing, got %v", rgids) } if rgids["rg1"] { t.Errorf("did not expect rg1 (The Wall) to be missing, got %v", rgids) } } // TestScanArtist_ZeroThresholdUsesDefault verifies that passing 0 for threshold uses DefaultThreshold. func TestScanArtist_ZeroThresholdUsesDefault(t *testing.T) { db := newTestDB(t) defer db.Close() seedArtist(t, db, "artist-1", "Pink Floyd") seedLocalAlbum(t, db, "l1", "artist-1", "The Wall") seedExternalRelease(t, db, "rg1", "artist-1", "The Wall", false) seedExternalRelease(t, db, "rg2", "artist-1", "Animals", false) // Pass 0 (zero value / unset) and the explicit default; results must match. zero, err := ScanArtist(context.Background(), db, "artist-1", 0) if err != nil { t.Fatalf("ScanArtist(0) error: %v", err) } explicit, err := ScanArtist(context.Background(), db, "artist-1", DefaultThreshold) if err != nil { t.Fatalf("ScanArtist(%v) error: %v", DefaultThreshold, err) } if len(zero) != len(explicit) { t.Errorf("ScanArtist(0) returned %d missing, ScanArtist(%v) returned %d; must match", len(zero), DefaultThreshold, len(explicit)) } } // TestScanArtist_IgnoredNotReported verifies that ignored external releases are not reported as missing. func TestScanArtist_IgnoredNotReported(t *testing.T) { db := newTestDB(t) defer db.Close() seedArtist(t, db, "artist-1", "Pink Floyd") seedExternalRelease(t, db, "rg1", "artist-1", "Animals", true) missing, err := ScanArtist(context.Background(), db, "artist-1", 0.85) if err != nil { t.Fatalf("ScanArtist() error: %v", err) } if len(missing) != 0 { t.Errorf("ignored release should not be reported, got %v", missing) } } // TestScanArtist_RemasteredVariantNotMissing verifies that remastered variants matching local albums are not reported missing. func TestScanArtist_RemasteredVariantNotMissing(t *testing.T) { db := newTestDB(t) defer db.Close() seedArtist(t, db, "artist-1", "Pink Floyd") seedLocalAlbum(t, db, "l1", "artist-1", "The Wall (Remastered)") seedExternalRelease(t, db, "rg1", "artist-1", "The Wall", false) missing, err := ScanArtist(context.Background(), db, "artist-1", 0) if err != nil { t.Fatalf("ScanArtist() error: %v", err) } if len(missing) != 0 { t.Errorf("remastered local should match external, got %v", missing) } } // TestScanArtist_YearTitledAlbumReissueReportedMissing verifies that year-titled albums are handled correctly. func TestScanArtist_YearTitledAlbumReissueReportedMissing(t *testing.T) { db := newTestDB(t) defer db.Close() // Rush "2112" is a bare year-titled album; "2112 (Remastered)" is a // distinct release group. Owning the standard 2112 must NOT count as owning // the remastered reissue — the reissue should be reported missing. seedArtist(t, db, "artist-1", "Rush") seedLocalAlbum(t, db, "l1", "artist-1", "2112") seedExternalRelease(t, db, "rg-standard", "artist-1", "2112", false) seedExternalRelease(t, db, "rg-remaster", "artist-1", "2112 (Remastered)", false) missing, err := ScanArtist(context.Background(), db, "artist-1", 0) if err != nil { t.Fatalf("ScanArtist() error: %v", err) } rgids := map[string]bool{} for _, m := range missing { rgids[m.RGID] = true } if rgids["rg-standard"] { t.Errorf("standard 2112 should match local copy, not be missing") } if !rgids["rg-remaster"] { t.Errorf("2112 (Remastered) reissue should be reported missing, got %v", rgids) } } // TestScanArtist_CtxCancelled verifies that ScanArtist respects context cancellation. func TestScanArtist_CtxCancelled(t *testing.T) { db := newTestDB(t) defer db.Close() seedArtist(t, db, "artist-1", "Pink Floyd") ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := ScanArtist(ctx, db, "artist-1", 0.85); err == nil { t.Fatal("expected error from cancelled context, got nil") } } // TestScanAll verifies the basic functionality of ScanAll. func TestScanAll(t *testing.T) { db := newTestDB(t) defer db.Close() // Monitored artist with one missing release. seedArtist(t, db, "artist-1", "Pink Floyd") seedLocalAlbum(t, db, "l1", "artist-1", "The Wall") seedExternalRelease(t, db, "rg1", "artist-1", "The Wall", false) seedExternalRelease(t, db, "rg2", "artist-1", "Animals", false) // Unmonitored artist — must be skipped entirely. seedArtistUnmonitored(t, db, "artist-2", "Other") seedExternalRelease(t, db, "rg3", "artist-2", "Some Album", false) missing, err := ScanAll(context.Background(), db, 0) if err != nil { t.Fatalf("ScanAll() error: %v", err) } rgids := map[string]bool{} for _, m := range missing { rgids[m.RGID] = true } if !rgids["rg2"] { t.Errorf("expected rg2 (Animals) missing, got %v", rgids) } if rgids["rg1"] { t.Errorf("did not expect rg1 (The Wall) missing, got %v", rgids) } if rgids["rg3"] { t.Errorf("unmonitored artist's release must not be scanned, got %v", rgids) } } // TestScanAll_CtxCancelledMidIteration verifies that ScanAll respects context cancellation mid-iteration. func TestScanAll_CtxCancelledMidIteration(t *testing.T) { db := newTestDB(t) defer db.Close() seedArtist(t, db, "artist-1", "Pink Floyd") seedArtist(t, db, "artist-2", "Other") // Cancel before scanning starts. ctx, cancel := context.WithCancel(context.Background()) cancel() missing, err := ScanAll(ctx, db, 0.85) if err == nil { t.Fatal("expected error from cancelled context, got nil") } if missing != nil { t.Errorf("expected nil results on early cancellation, got %v", missing) } } // seedArtistUnmonitored inserts an artist_settings row with Monitored=false. func seedArtistUnmonitored(t *testing.T, db *database.DB, id, name string) { t.Helper() if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: id, Name: name, Monitored: false, }); err != nil { t.Fatalf("seedArtistUnmonitored(%s) error: %v", id, err) } } // TestScanArtist_ErrArtistNotFound verifies that ScanArtist handles ErrArtistNotFound // by using empty TypeFilter (no filtering) instead of returning an error. func TestScanArtist_ErrArtistNotFound(t *testing.T) { db := newTestDB(t) defer db.Close() // Disable foreign key constraints to allow inserting external_releases without artist_settings if _, err := db.Conn().Exec("PRAGMA foreign_keys = OFF"); err != nil { t.Fatalf("disable foreign keys: %v", err) } // Re-enable foreign keys when we're done defer func() { if _, err := db.Conn().Exec("PRAGMA foreign_keys = ON"); err != nil { t.Fatalf("re-enable foreign keys: %v", err) } }() // Don't create artist settings - this will cause GetArtistSettings to return ErrArtistNotFound // Insert external release directly to bypass FK constraint for testing inconsistent state if _, err := db.Conn().Exec( "INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)", "rg1", "nonexistent-artist", "Test Album", "Album", "", false, ); err != nil { t.Fatalf("insert external release: %v", err) } missing, err := ScanArtist(context.Background(), db, "nonexistent-artist", 0) if err != nil { t.Fatalf("ScanArtist() error: %v", err) } // Should return the release as missing (no filtering applied) if len(missing) != 1 { t.Errorf("expected 1 missing release, got %d", len(missing)) } if missing[0].RGID != "rg1" { t.Errorf("expected rg1 to be missing, got %v", missing[0].RGID) } } // TestScanArtist_TypeToggle verifies that ScanArtist honors the artist's // ignore_singles / ignore_compilations toggles at read time, so a toggled // artist stops reporting those categories as missing immediately (without // waiting for the MusicBrainz cache to expire and prune rows on the next re-sync). func TestScanArtist_TypeToggle(t *testing.T) { db := newTestDB(t) defer db.Close() seedArtist(t, db, "a1", "Artist") seedExternalRelease(t, db, "rg-album", "a1", "Album", false) seedExternalRelease(t, db, "rg-single", "a1", "Single", false) // seedExternalRelease leaves Type empty; set the primary type that the // toggle filtering keys on. if _, err := db.Conn().Exec("UPDATE external_releases SET type = ? WHERE rgid = ?", "Album", "rg-album"); err != nil { t.Fatalf("set album type: %v", err) } if _, err := db.Conn().Exec("UPDATE external_releases SET type = ? WHERE rgid = ?", "Single", "rg-single"); err != nil { t.Fatalf("set single type: %v", err) } // No toggle: both reported missing (no local albums). got, err := ScanArtist(context.Background(), db, "a1", 0) if err != nil { t.Fatalf("ScanArtist error: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 missing before toggle, got %d", len(got)) } // Toggle ignore_singles on. if err := database.UpdateArtistSettings(db, "a1", map[string]interface{}{"ignore_singles": true}); err != nil { t.Fatalf("toggle ignore_singles: %v", err) } got, err = ScanArtist(context.Background(), db, "a1", 0) if err != nil { t.Fatalf("ScanArtist error: %v", err) } if len(got) != 1 || got[0].RGID != "rg-album" { ids := make([]string, len(got)) for i, m := range got { ids[i] = m.RGID } t.Fatalf("expected only rg-album after toggle, got %v", ids) } // Toggle back off: single reappears. if err := database.UpdateArtistSettings(db, "a1", map[string]interface{}{"ignore_singles": false}); err != nil { t.Fatalf("toggle ignore_singles off: %v", err) } got, err = ScanArtist(context.Background(), db, "a1", 0) if err != nil { t.Fatalf("ScanArtist error: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 missing after toggle off, got %d", len(got)) } } // TestFilterConsistency_AcrossCacheStates verifies that filter behavior is consistent // across cache-hit (SyncArtistDiscography cache-hit path), cache-miss (FilterReleaseGroups), // and scanner (TypeFilter.suppressed) paths for releases with SecondaryTypes=["EP"] // when IgnoreSingles toggle is enabled. func TestFilterConsistency_AcrossCacheStates(t *testing.T) { db := newTestDB(t) defer db.Close() // Seed artist settings with IgnoreSingles enabled if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: "artist-1", Name: "Test Artist", Monitored: true, IgnoreSingles: true, // This is the key toggle we're testing IgnoreCompilations: false, }); err != nil { t.Fatalf("SaveArtistSettings error: %v", err) } // Seed an external release with SecondaryTypes=["EP"] (should be treated as Single when IgnoreSingles=true) if err := database.SaveExternalRelease(db, &database.ExternalRelease{ RGID: "rg-ep-release", ArtistID: "artist-1", Title: "EP Release", Type: "Album", // Primary type is Album, but it has EP as secondary type ReleaseDate: "2024-01-01", SecondaryTypes: []string{"EP"}, // This should make it count as a Single for filtering purposes IsIgnored: false, }); err != nil { t.Fatalf("SaveExternalRelease error: %v", err) } // Seed a local album (so we can test that the EP release is NOT missing when it should be filtered out) if err := database.SaveLocalAlbum(db, &database.LocalAlbum{ ID: "local-album-1", ArtistID: "artist-1", Title: "Some Other Album", // Different title so it doesn't match the EP release }); err != nil { t.Fatalf("SaveLocalAlbum error: %v", err) } // Test 1: Cache-miss path (FilterReleaseGroups via musicbrainz package) opts := musicbrainz.FilterOptions{ IgnoreSingles: true, IgnoreCompilations: false, } allReleases := []database.ExternalRelease{ { RGID: "rg-ep-release", ArtistID: "artist-1", Title: "EP Release", Type: "Album", ReleaseDate: "2024-01-01", SecondaryTypes: []string{"EP"}, IsIgnored: false, }, } filteredCacheMiss := musicbrainz.ApplyTypeToggles(allReleases, opts) if len(filteredCacheMiss) != 0 { t.Errorf("cache-miss path: expected EP release to be filtered out (treated as Single), got %d releases", len(filteredCacheMiss)) } // Test 2: Scanner path (TypeFilter.suppressed via diff.go) externalReleases, err := database.GetExternalReleasesByArtist(db, "artist-1") if err != nil { t.Fatalf("GetExternalReleasesByArtist error: %v", err) } // Get the artist settings to create the filter settings, err := database.GetArtistSettings(db, "artist-1") if err != nil { t.Fatalf("GetArtistSettings error: %v", err) } filter := musicbrainz.FilterOptions{ IgnoreSingles: settings.IgnoreSingles, IgnoreCompilations: settings.IgnoreCompilations, } // Check if the EP release is suppressed by the scanner's filter var isSuppressed bool for _, ext := range externalReleases { if ext.RGID == "rg-ep-release" { isSuppressed = FilterIsSuppressed(filter, ext) break } } if !isSuppressed { t.Errorf("scanner path: expected EP release to be suppressed (treated as Single), got not suppressed") } // Test 3: Conceptual cache-hit path verification // The cache-hit path in SyncArtistDiscography uses the same ApplyTypeToggles function // as the cache-miss path, so if they agree on the filtering logic, the cache-hit // path will behave identically. // We've already verified that both paths use the same underlying function: // - Cache-miss: musicbrainz.ApplyTypeToggles (called directly in FilterReleaseGroups) // - Cache-hit: musicbrainz.ApplyTypeToggles (called in SyncArtistDiscography cache-hit path) // - Scanner: TypeFilter.suppressed which calls musicbrainz.ApplyTypeToggles internally // // Since all three paths ultimately use the same filtering function with the same // inputs, they must produce identical results. }