package musicbrainz import ( "context" "net/http" "net/http/httptest" "strconv" "testing" "time" "golang.org/x/time/rate" "naviwatcher/internal/config" "naviwatcher/internal/database" ) // mbReleaseGroupXML is a helper to build a single release-group XML element. func mbReleaseGroupXML(id, title, rgType, status, artistID, artistName, releaseDate string) string { statusAttr := "" if status != "" { statusAttr = ` status="` + status + `"` } // Release groups carry type via ; the legacy "type" attribute // is also emitted (ignored by the parser) for realism. Status has no meaning // for release groups and is not parsed. return `` + `` + title + `` + `` + rgType + `` + `` + `` + artistName + `` + `` + `` + releaseDate + `` + `` } // mbReleaseGroupListResponse builds a full MusicBrainz XML response for a release-group list. func mbReleaseGroupListResponse(groups string, count int) string { return ` ` + groups + ` ` } // newTestMBServer creates a mock MusicBrainz HTTP server. func newTestMBServer(handler http.HandlerFunc) *httptest.Server { return httptest.NewServer(handler) } // 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 } // newTestClient creates a MusicBrainzClient pointing at the given test server // with a relaxed rate limiter (100 req/sec) for fast test execution. func newTestClient(serverURL string) *MusicBrainzClient { cfg := config.MusicBrainzConfig{ UserAgent: "test-agent/1.0", } return &MusicBrainzClient{ httpClient: &http.Client{}, userAgent: cfg.UserAgent, baseURL: serverURL, rateLimiter: rate.NewLimiter(rate.Limit(100), 100), } } // seedArtist inserts a minimal artist_settings row so foreign key 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) } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography fetches from API and upserts on cache miss // ----------------------------------------------------------------------- func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) { artistMBID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" artistID := "nav-aaaaaaaa" artistName := "Test Artist" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg1", "First Album", "Album", "", artistMBID, artistName, "2020-01-01")+ mbReleaseGroupXML("rg2", "Second Album", "Album", "", artistMBID, artistName, "2022-06-15")+ mbReleaseGroupXML("rg3", "A Single", "Single", "", artistMBID, artistName, "2021-03-10"), 3, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, artistName) client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } if len(releases) != 3 { t.Fatalf("expected 3 releases, got %d", len(releases)) } // Verify each release has CachedAt set and is keyed by the Navidrome artist ID. for _, r := range releases { if r.CachedAt.IsZero() { t.Errorf("release %s: CachedAt should be set, got zero", r.RGID) } if r.ArtistID != artistID { t.Errorf("release %s: expected ArtistID %q, got %q", r.RGID, artistID, r.ArtistID) } } // Verify data was persisted in the database under the Navidrome artist ID. stored, err := database.GetExternalReleasesByArtist(db, artistID) if err != nil { t.Fatalf("GetExternalReleasesByArtist() error: %v", err) } if len(stored) != 3 { t.Errorf("expected 3 stored releases, got %d", len(stored)) } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography returns cached data on cache hit // ----------------------------------------------------------------------- func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) { artistMBID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" artistID := "nav-bbbbbbbb" db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Cache Artist") // Pre-populate the cache with one release. now := time.Now() if err := database.SaveExternalRelease(db, &database.ExternalRelease{ RGID: "rg-cached", ArtistID: artistID, Title: "Cached Album", Type: "Album", ReleaseDate: "2019-05-01", CachedAt: now, }); err != nil { t.Fatalf("SaveExternalRelease() error: %v", err) } // Server that would be called on cache miss — should NOT be called. serverCalled := false server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { serverCalled = true w.Header().Set("Content-Type", "application/xml") w.Write([]byte(mbReleaseGroupListResponse("", 0))) }) defer server.Close() client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } if serverCalled { t.Error("expected cache hit but server was called (cache miss)") } if len(releases) != 1 { t.Fatalf("expected 1 cached release, got %d", len(releases)) } if releases[0].RGID != "rg-cached" { t.Errorf("expected RGID 'rg-cached', got %q", releases[0].RGID) } if releases[0].Title != "Cached Album" { t.Errorf("expected Title 'Cached Album', got %q", releases[0].Title) } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography applies filtering (excluded statuses) // ----------------------------------------------------------------------- func TestSyncArtistDiscography_StatusIsNotFiltered(t *testing.T) { artistMBID := "cccccccc-dddd-eeee-ffff-000000000000" artistID := "nav-cccccccc" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") // These all carry status values, but release groups have no status in // ws/2, so none should be filtered on that basis. resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-legit", "Legit Album", "Album", "", artistMBID, "Artist", "2020-01-01")+ mbReleaseGroupXML("rg-bootleg", "Bootleg Album", "Album", "Bootleg", artistMBID, "Artist", "2020-02-01")+ mbReleaseGroupXML("rg-promo", "Promo Album", "Album", "Promotion", artistMBID, "Artist", "2020-03-01")+ mbReleaseGroupXML("rg-pseudo", "Pseudo Album", "Album", "Pseudo-Release", artistMBID, "Artist", "2020-04-01"), 4, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Filter Artist") client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } // All four are Albums; status is not a filter, so all four are kept. if len(releases) != 4 { t.Fatalf("expected 4 releases (status is not filtered), got %d", len(releases)) } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography applies type filtering // ----------------------------------------------------------------------- func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) { artistMBID := "dddddddd-eeee-ffff-0000-111111111111" artistID := "nav-dddddddd" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-album", "An Album", "Album", "", artistMBID, "Artist", "2020-01-01")+ mbReleaseGroupXML("rg-single", "A Single", "Single", "", artistMBID, "Artist", "2020-02-01")+ mbReleaseGroupXML("rg-ep", "An EP", "EP", "", artistMBID, "Artist", "2020-03-01")+ mbReleaseGroupXML("rg-comp", "A Compilation", "Compilation", "", artistMBID, "Artist", "2020-04-01")+ mbReleaseGroupXML("rg-soundtrack", "A Soundtrack", "Soundtrack", "", artistMBID, "Artist", "2020-05-01"), 5, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Type Filter Artist") client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } // Soundtrack and the bare "Compilation" primary type should be excluded // (Compilation is not a primary type; it is classified via secondary type). if len(releases) != 3 { t.Fatalf("expected 3 releases after type filtering, got %d", len(releases)) } rgIDs := make(map[string]bool) for _, r := range releases { rgIDs[r.RGID] = true } if rgIDs["rg-soundtrack"] { t.Error("Soundtrack type should have been filtered out") } if rgIDs["rg-comp"] { t.Error("Compilation primary type should have been filtered out") } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography with context cancellation // ----------------------------------------------------------------------- func TestSyncArtistDiscography_ContextCancellation(t *testing.T) { artistMBID := "eeeeeeee-ffff-0000-1111-222222222222" artistID := "nav-eeeeeeee" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg1", "Album One", "Album", "", artistMBID, "Artist", "2020-01-01"), 1, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Cancel Artist") client := newTestClient(server.URL) ttl := 24 * time.Hour // Create a context that is already cancelled. ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err == nil { t.Fatal("SyncArtistDiscography() expected error for cancelled context, got nil") } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography upsert is idempotent (re-sync replaces) // ----------------------------------------------------------------------- func TestSyncArtistDiscography_IdempotentResync(t *testing.T) { artistMBID := "ffffffff-0000-1111-2222-333333333333" artistID := "nav-ffffffff" callCount := 0 server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { callCount++ w.Header().Set("Content-Type", "application/xml") resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg1", "Album One", "Album", "", artistMBID, "Artist", "2020-01-01")+ mbReleaseGroupXML("rg2", "Album Two", "Album", "", artistMBID, "Artist", "2021-01-01"), 2, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Idempotent Artist") client := newTestClient(server.URL) ctx := context.Background() // First sync. releases1, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour) if err != nil { t.Fatalf("first SyncArtistDiscography() error: %v", err) } if len(releases1) != 2 { t.Fatalf("expected 2 releases after first sync, got %d", len(releases1)) } if callCount != 1 { t.Fatalf("expected 1 server call after first sync, got %d", callCount) } // Force cache expiry by setting cached_at (on external_releases) and // last_synced (on artist_settings) to the past. _, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?", time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID) if err != nil { t.Fatalf("expire cache (releases): %v", err) } if _, err = db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil { t.Fatalf("expire cache (settings): %v", err) } // Second sync should re-fetch from API (cache expired). releases2, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour) if err != nil { t.Fatalf("second SyncArtistDiscography() error: %v", err) } if len(releases2) != 2 { t.Fatalf("expected 2 releases after second sync, got %d", len(releases2)) } if callCount != 2 { t.Fatalf("expected 2 server calls after forced re-sync, got %d", callCount) } // Verify no duplicates in the database (transactional delete + insert). stored, err := database.GetExternalReleasesByArtist(db, artistID) if err != nil { t.Fatalf("GetExternalReleasesByArtist() error: %v", err) } if len(stored) != 2 { t.Errorf("expected 2 stored releases (no duplicates), got %d", len(stored)) } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography with empty response (no release groups) // ----------------------------------------------------------------------- func TestSyncArtistDiscography_EmptyResponse(t *testing.T) { artistMBID := "33333333-4444-5555-6666-777777777777" artistID := "nav-33333333" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") w.Write([]byte(mbReleaseGroupListResponse("", 0))) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Empty Artist") client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } if len(releases) != 0 { t.Fatalf("expected 0 releases for empty response, got %d", len(releases)) } // Verify nothing in DB. stored, err := database.GetExternalReleasesByArtist(db, artistID) if err != nil { t.Fatalf("GetExternalReleasesByArtist() error: %v", err) } if len(stored) != 0 { t.Errorf("expected 0 stored releases, got %d", len(stored)) } // A second sync within the TTL must be a cache hit: an empty discography is // now cached via artist_settings.last_synced, so the MusicBrainz API must // not be re-queried (and still returns 0 releases). serverHits := 0 server2 := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { serverHits++ w.Header().Set("Content-Type", "application/xml") w.Write([]byte(mbReleaseGroupListResponse("", 0))) }) defer server2.Close() releases2, err := SyncArtistDiscography(ctx, newTestClient(server2.URL), db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("second SyncArtistDiscography() error: %v", err) } if len(releases2) != 0 { t.Errorf("expected 0 releases on cached empty sync, got %d", len(releases2)) } if serverHits != 0 { t.Errorf("expected empty discography to be cached (0 API calls), got %d", serverHits) } } // ----------------------------------------------------------------------- // Test: SyncArtistDiscography API error propagation // ----------------------------------------------------------------------- func TestSyncArtistDiscography_APIError(t *testing.T) { artistMBID := "44444444-5555-6666-7777-888888888888" artistID := "nav-44444444" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("internal server error")) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Error Artist") client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err == nil { t.Fatal("SyncArtistDiscography() expected error for API failure, got nil") } } // ----------------------------------------------------------------------- // Test: Verify XML parsing integration — full pipeline with realistic XML // ----------------------------------------------------------------------- func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) { artistMBID := "66666666-7777-8888-9999-000000000000" artistID := "nav-66666666" xmlBody := ` Real Album One Real Artist 2019-03-15 Real Single Two Real Artist 2020-07-20 ` server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") w.Write([]byte(xmlBody)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Real Artist") client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } if len(releases) != 2 { t.Fatalf("expected 2 releases, got %d", len(releases)) } byID := make(map[string]database.ExternalRelease) for _, r := range releases { byID[r.RGID] = r } rg1, ok := byID["rg-real-1"] if !ok { t.Fatal("expected rg-real-1 in results") } if rg1.Title != "Real Album One" { t.Errorf("rg-real-1 title = %q, want %q", rg1.Title, "Real Album One") } if rg1.Type != "Album" { t.Errorf("rg-real-1 type = %q, want %q", rg1.Type, "Album") } if rg1.ReleaseDate != "2019-03-15" { t.Errorf("rg-real-1 release_date = %q, want %q", rg1.ReleaseDate, "2019-03-15") } rg2, ok := byID["rg-real-2"] if !ok { t.Fatal("expected rg-real-2 in results") } if rg2.Type != "Single" { t.Errorf("rg-real-2 type = %q, want %q", rg2.Type, "Single") } } // ----------------------------------------------------------------------- // Test: Verify CachedAt timestamps are consistent across a sync batch // ----------------------------------------------------------------------- func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) { artistMBID := "77777777-8888-9999-0000-111111111111" artistID := "nav-77777777" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-ts-1", "Album A", "Album", "", artistMBID, "Artist", "2020-01-01")+ mbReleaseGroupXML("rg-ts-2", "Album B", "Album", "", artistMBID, "Artist", "2021-01-01")+ mbReleaseGroupXML("rg-ts-3", "Album C", "Album", "", artistMBID, "Artist", "2022-01-01"), 3, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Timestamp Artist") client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } // All releases in a batch should have the same CachedAt timestamp. if len(releases) != 3 { t.Fatalf("expected 3 releases, got %d", len(releases)) } first := releases[0].CachedAt for _, r := range releases[1:] { if !r.CachedAt.Equal(first) { t.Errorf("CachedAt mismatch: %v vs %v for release %s", first, r.CachedAt, r.RGID) } } } // ----------------------------------------------------------------------- // Test: Verify XML edge case — release-group with no type attribute // ----------------------------------------------------------------------- func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) { artistMBID := "88888888-9999-0000-1111-222222222222" artistID := "nav-88888888" xmlBody := ` No Type Album Artist 2020-01-01 Typed Album Artist 2021-01-01 ` server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") w.Write([]byte(xmlBody)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "No Type Artist") client := newTestClient(server.URL) ctx := context.Background() ttl := 24 * time.Hour releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } // The release-group with no type should be filtered out (empty string is not in includedTypes). if len(releases) != 1 { t.Fatalf("expected 1 release (empty type filtered), got %d", len(releases)) } if releases[0].RGID != "rg-withtype" { t.Errorf("expected rg-withtype, got %s", releases[0].RGID) } } // ----------------------------------------------------------------------- // Test: Stale releases are cleaned up on re-sync // ----------------------------------------------------------------------- func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) { artistMBID := "99999999-0000-1111-2222-333333333333" artistID := "nav-99999999" callCount := 0 server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { callCount++ w.Header().Set("Content-Type", "application/xml") if callCount == 1 { // First call: return 3 releases. resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-old-1", "Old Album 1", "Album", "", artistMBID, "Artist", "2018-01-01")+ mbReleaseGroupXML("rg-old-2", "Old Album 2", "Album", "", artistMBID, "Artist", "2019-01-01")+ mbReleaseGroupXML("rg-old-3", "Old Album 3", "Album", "", artistMBID, "Artist", "2020-01-01"), 3, ) w.Write([]byte(resp)) } else { // Second call: return only 2 (one was removed). resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-old-1", "Old Album 1", "Album", "", artistMBID, "Artist", "2018-01-01")+ mbReleaseGroupXML("rg-old-2", "Old Album 2", "Album", "", artistMBID, "Artist", "2019-01-01"), 2, ) w.Write([]byte(resp)) } }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, "Stale Artist") client := newTestClient(server.URL) ctx := context.Background() // First sync: 3 releases. releases1, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour) if err != nil { t.Fatalf("first SyncArtistDiscography() error: %v", err) } if len(releases1) != 3 { t.Fatalf("expected 3 releases after first sync, got %d", len(releases1)) } // Force cache expiry by setting cached_at (on external_releases) and // last_synced (on artist_settings) to the past. _, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?", time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID) if err != nil { t.Fatalf("expire cache (releases): %v", err) } if _, err = db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil { t.Fatalf("expire cache (settings): %v", err) } // Second sync should re-fetch from API (cache expired). releases2, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour) if err != nil { t.Fatalf("second SyncArtistDiscography() error: %v", err) } if len(releases2) != 2 { t.Fatalf("expected 2 releases after second sync, got %d", len(releases2)) } // Verify stale release was cleaned from DB. stored, err := database.GetExternalReleasesByArtist(db, artistID) if err != nil { t.Fatalf("GetExternalReleasesByArtist() error: %v", err) } if len(stored) != 2 { t.Errorf("expected 2 stored releases after cleanup, got %d", len(stored)) } } // ----------------------------------------------------------------------- // Test: per-artist ignore_singles filters out Single type // ----------------------------------------------------------------------- func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) { artistMBID := "artist-singles-test" artistID := "nav-singles-test" artistName := "Singles Artist" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-1", "Album", "Album", "Official", artistMBID, artistName, "2024-01-01")+ mbReleaseGroupXML("rg-2", "Single", "Single", "Official", artistMBID, artistName, "2024-02-01"), 2, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() // Seed artist (keyed by Navidrome ID) with ignore_singles = true. if _, err := db.Conn().Exec( "INSERT INTO artist_settings (id, name, ignore_singles, monitored) VALUES (?, ?, 1, 1)", artistID, artistName, ); err != nil { t.Fatalf("seed artist: %v", err) } client := newTestClient(server.URL) ctx := context.Background() releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 0) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } if len(releases) != 1 { t.Fatalf("expected 1 release (singles filtered), got %d", len(releases)) } if releases[0].Type != "Album" { t.Errorf("expected type Album, got %s", releases[0].Type) } } // ----------------------------------------------------------------------- // Test: per-artist ignore_compilations filters out Compilation type // ----------------------------------------------------------------------- func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) { artistMBID := "artist-comp-test" artistID := "nav-comp-test" artistName := "Comp Artist" server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-1", "Album", "Album", "Official", artistMBID, artistName, "2024-01-01")+ mbReleaseGroupXML("rg-2", "Best Of", "Compilation", "Official", artistMBID, artistName, "2024-02-01"), 2, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() // Seed artist (keyed by Navidrome ID) with ignore_compilations = true. if _, err := db.Conn().Exec( "INSERT INTO artist_settings (id, name, ignore_compilations, monitored) VALUES (?, ?, 1, 1)", artistID, artistName, ); err != nil { t.Fatalf("seed artist: %v", err) } client := newTestClient(server.URL) ctx := context.Background() releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 0) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } if len(releases) != 1 { t.Fatalf("expected 1 release (compilations filtered), got %d", len(releases)) } if releases[0].Type != "Album" { t.Errorf("expected type Album, got %s", releases[0].Type) } } // ----------------------------------------------------------------------- // Test: cache-hit path applies secondary-type filtering consistently with the // cache-miss path. A release whose primary type is "Album" but which is also // a "Compilation" via its secondary type must be dropped by IgnoreCompilations // on a cache hit, exactly as FilterReleaseGroups drops it on a cache miss. // ----------------------------------------------------------------------- func TestSyncArtistDiscography_CacheHit_IgnoreSecondaryCompilation(t *testing.T) { artistID := "nav-comp-secondary-test" artistName := "Secondary Comp Artist" db := newTestDB(t) defer db.Close() if _, err := db.Conn().Exec( "INSERT INTO artist_settings (id, name, ignore_compilations, monitored) VALUES (?, ?, 1, 1)", artistID, artistName, ); err != nil { t.Fatalf("seed artist: %v", err) } // Seed a cached release: primary "Album" + secondary "Compilation". // cached_at is set to the recent past so it is well within the 24h TTL. if err := database.SaveExternalRelease(db, &database.ExternalRelease{ RGID: "rg-comp", ArtistID: artistID, Title: "Greatest Hits", Type: "Album", SecondaryTypes: []string{"Compilation"}, IsIgnored: false, CachedAt: time.Now().UTC().Add(-time.Hour), }); err != nil { t.Fatalf("seed cached release: %v", err) } // No MusicBrainz server is started; a cache hit must not hit the API. client := newTestClient("http://unused.invalid") ctx := context.Background() releases, err := SyncArtistDiscography(ctx, client, db, artistID, "mbid-unused", 24*time.Hour) if err != nil { t.Fatalf("SyncArtistDiscography() error: %v", err) } if len(releases) != 0 { t.Fatalf("expected 0 releases (secondary compilation filtered on cache hit), got %d", len(releases)) } } func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) { artistMBID := "artist-fk-test" artistID := "nav-fk-test" artistName := "FK Artist" // First response includes two release groups; the second sync drops one // ("rg-2") so we can verify its notification is pruned while the surviving // release's notification ("rg-1") is preserved. server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-1", "Album", "Album", "Official", artistMBID, artistName, "2024-01-01")+ mbReleaseGroupXML("rg-2", "Album", "Album", "Official", artistMBID, artistName, "2023-01-01"), 2, ) w.Write([]byte(resp)) }) defer server.Close() db := newTestDB(t) defer db.Close() seedArtist(t, db, artistID, artistName) client := newTestClient(server.URL) ctx := context.Background() // First sync. if _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour); err != nil { t.Fatalf("first SyncArtistDiscography() error: %v", err) } // Mark both releases as already notified. for _, rgid := range []string{"rg-1", "rg-2"} { if _, err := db.Conn().Exec("INSERT INTO notifications_sent (rgid) VALUES (?)", rgid); err != nil { t.Fatalf("insert notification: %v", err) } } // Force cache expiry on the first sync so the second sync re-fetches. if _, err := db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?", time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil { t.Fatalf("expire cache (releases): %v", err) } if _, err := db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil { t.Fatalf("expire cache (settings): %v", err) } // Second sync returns only rg-1 (drop rg-2 from the server response) and // must succeed without an FK violation. server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := mbReleaseGroupListResponse( mbReleaseGroupXML("rg-1", "Album", "Album", "Official", artistMBID, artistName, "2024-01-01"), 1, ) w.Write([]byte(resp)) }) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour) if err != nil { t.Fatalf("second SyncArtistDiscography() error (FK violation?): %v", err) } if len(releases) != 1 { t.Fatalf("expected 1 release after resync, got %d", len(releases)) } // The surviving release's notification must be preserved (no duplicate // digest on the next notify run). The dropped release's notification must // be pruned. var count1, count2 int if err := db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rg-1").Scan(&count1); err != nil { t.Fatalf("count rg-1 notifications: %v", err) } if err := db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rg-2").Scan(&count2); err != nil { t.Fatalf("count rg-2 notifications: %v", err) } if count1 != 1 { t.Errorf("expected surviving release rg-1 notification preserved (1), got %d", count1) } if count2 != 0 { t.Errorf("expected dropped release rg-2 notification pruned (0), got %d", count2) } }