Files
NaviWatcher/internal/musicbrainz/sync_test.go

935 lines
30 KiB
Go

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 <primary-type>; 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 `<release-group id="` + id + `" type="` + rgType + `"` + statusAttr + `>` +
`<title>` + title + `</title>` +
`<primary-type>` + rgType + `</primary-type>` +
`<artist-credit><name-credit><artist id="` + artistID + `">` +
`<name>` + artistName + `</name>` +
`</artist></name-credit></artist-credit>` +
`<first-release-date>` + releaseDate + `</first-release-date>` +
`</release-group>`
}
// mbReleaseGroupListResponse builds a full MusicBrainz XML response for a release-group list.
func mbReleaseGroupListResponse(groups string, count int) string {
return `<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
<release-group-list count="` + strconv.Itoa(count) + `">` +
groups +
`</release-group-list>
</metadata>`
}
// 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 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: %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))
}
}
// -----------------------------------------------------------------------
// 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 := `<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<release-group-list count="2">
<release-group id="rg-real-1" type="Album">
<title>Real Album One</title>
<artist-credit>
<name-credit>
<artist id="` + artistMBID + `">
<name>Real Artist</name>
</artist>
</name-credit>
</artist-credit>
<first-release-date>2019-03-15</first-release-date>
</release-group>
<release-group id="rg-real-2" type="Single">
<title>Real Single Two</title>
<artist-credit>
<name-credit>
<artist id="` + artistMBID + `">
<name>Real Artist</name>
</artist>
</name-credit>
</artist-credit>
<first-release-date>2020-07-20</first-release-date>
</release-group>
</release-group-list>
</metadata>`
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 := `<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<release-group-list count="2">
<release-group id="rg-notype" type="">
<title>No Type Album</title>
<artist-credit>
<name-credit>
<artist id="` + artistMBID + `">
<name>Artist</name>
</artist>
</name-credit>
</artist-credit>
<first-release-date>2020-01-01</first-release-date>
</release-group>
<release-group id="rg-withtype" type="Album">
<title>Typed Album</title>
<artist-credit>
<name-credit>
<artist id="` + artistMBID + `">
<name>Artist</name>
</artist>
</name-credit>
</artist-credit>
<first-release-date>2021-01-01</first-release-date>
</release-group>
</release-group-list>
</metadata>`
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 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: %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 far in the past so it is still within any TTL (TTL 0).
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(),
}); 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", 0)
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"
server := newTestMBServer(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))
})
defer server.Close()
db := newTestDB(t)
defer db.Close()
seedArtist(t, db, artistID, artistName)
client := newTestClient(server.URL)
ctx := context.Background()
// First sync.
_, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err)
}
// Insert a notifications_sent row referencing the release.
_, err = db.Conn().Exec(
"INSERT INTO notifications_sent (rgid) VALUES (?)", "rg-1",
)
if err != nil {
t.Fatalf("insert notification: %v", err)
}
// Force cache expiry.
_, 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: %v", err)
}
// Second sync should succeed without FK violation.
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))
}
// Notification should have been cleaned up.
var count int
err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rg-1").Scan(&count)
if err != nil {
t.Fatalf("count notifications: %v", err)
}
if count != 0 {
t.Errorf("expected 0 notifications after resync, got %d", count)
}
}