Some checks failed
Build and Push Docker Image / build (pull_request) Failing after 38s
This commit includes: 1. Live/Remix Filtering Feature: - Added ignore_live and ignore_remix columns to artist_settings table (migration 010) - Updated ArtistSettings struct with IgnoreLive and IgnoreRemix fields - Modified SaveArtistSettings and UpdateArtistSettings to handle new fields - Extended FilterOptions struct with IgnoreLive and IgnoreRemix - Updated ApplyTypeToggles and ApplyTypeTogglesToReleaseGroups to filter Live/Remix types - Added toggleIgnoreLive and toggleIgnoreRemix handlers in web layer - Updated ArtistData view model and artist.html template with new toggle UI - Comprehensive test coverage for all new functionality 2. CI/CD Pipeline with Gitea Actions: - Added .gitea/workflows/docker-build.yml for automated Docker builds - Workflow triggers on pushes to main/master and tags, plus PRs - Runs Go tests before building - Builds and pushes multi-architecture Docker images to gitea.mrixs.me - Includes caching for faster subsequent builds - Proper tagging strategy (branch, semver, SHA) - CI-CD-GUIDE.md documentation 3. Cleanup: - Removed temporary build artifacts and coverage files
459 lines
16 KiB
Go
459 lines
16 KiB
Go
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.
|
|
}
|