- Fix artist-ID namespace mismatch in MusicBrainz provider: SyncArtistDiscography now stores the canonical Navidrome artist ID (artist_settings.id) as external_releases.artist_id instead of the MusicBrainz MBID. Previously the MBID was stored, which violated the FK to artist_settings and broke the scanner join (local_albums.artist_id is the Navidrome ID), causing every external release to be falsely reported as missing and the sync insert to fail at runtime. getArtistFilterOptions now also resolves by the Navidrome ID. - Resolve threshold in FindMissingReleases so the exported primitive honors the same zero-means-default contract as ScanArtist/ScanAll. - Remove dead maxLen==0 guard in scanner.Similarity. - Inline trivial buildPath helper; drop unused url import in client.go. - Replace hand-rolled itoa with strconv.Itoa in tests. - Rewrite SyncArtistDiscography tests to seed artist_settings with the Navidrome ID (tests previously seeded the MBID to mask the FK mismatch). - Fix TestFuzzySmoke to exercise the real dependency (fuzzy.LevenshteinDistance / scanner.Similarity) instead of an unused API. - Fix TestAppRun_ScanLogsMissingReleases to run the scan against a live context and assert the missing release is found. - Document cached_at column in Specification.md and note startup scan / required musicbrainz.user_agent in README. - Stop tracking .serena/ tooling config; add it to .gitignore.
887 lines
28 KiB
Go
887 lines
28 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 + `"`
|
|
}
|
|
return `<release-group id="` + id + `" type="` + rgType + `"` + statusAttr + `>` +
|
|
`<title>` + title + `</title>` +
|
|
`<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_FiltersExcludedStatuses(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")
|
|
// Include a Bootleg and a Promotion that should be filtered out.
|
|
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)
|
|
}
|
|
|
|
// Only the legit album should remain after filtering.
|
|
if len(releases) != 1 {
|
|
t.Fatalf("expected 1 release after filtering, got %d", len(releases))
|
|
}
|
|
if releases[0].RGID != "rg-legit" {
|
|
t.Errorf("expected RGID 'rg-legit', got %q", releases[0].RGID)
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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 should be excluded (not in includedTypes).
|
|
if len(releases) != 4 {
|
|
t.Fatalf("expected 4 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")
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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: resync with notifications_sent does not violate FK constraint
|
|
// -----------------------------------------------------------------------
|
|
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)
|
|
}
|
|
}
|