- 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.
434 lines
13 KiB
Go
434 lines
13 KiB
Go
package musicbrainz
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"golang.org/x/time/rate"
|
|
"naviwatcher/internal/config"
|
|
)
|
|
|
|
// ---------- FilterReleaseGroups tests ----------
|
|
|
|
func TestFilterReleaseGroups_ExcludesBootlegPromotionPseudo(t *testing.T) {
|
|
groups := []ReleaseGroup{
|
|
{ID: "rg-1", Title: "Official Album", Type: "Album", Status: "Official"},
|
|
{ID: "rg-2", Title: "Bootleg Live", Type: "Album", Status: "Bootleg"},
|
|
{ID: "rg-3", Title: "Promo CD", Type: "Single", Status: "Promotion"},
|
|
{ID: "rg-4", Title: "Pseudo Release", Type: "Album", Status: "Pseudo-Release"},
|
|
}
|
|
|
|
result := FilterReleaseGroups(groups, FilterOptions{})
|
|
|
|
if len(result) != 1 {
|
|
t.Fatalf("FilterReleaseGroups() returned %d groups, want 1", len(result))
|
|
}
|
|
if result[0].ID != "rg-1" {
|
|
t.Errorf("FilterReleaseGroups()[0].ID = %q, want %q", result[0].ID, "rg-1")
|
|
}
|
|
}
|
|
|
|
func TestFilterReleaseGroups_IncludesOnlyAllowedTypes(t *testing.T) {
|
|
groups := []ReleaseGroup{
|
|
{ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"},
|
|
{ID: "rg-2", Title: "Single", Type: "Single", Status: "Official"},
|
|
{ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"},
|
|
{ID: "rg-4", Title: "Compilation", Type: "Compilation", Status: "Official"},
|
|
{ID: "rg-5", Title: "Soundtrack", Type: "Soundtrack", Status: "Official"},
|
|
{ID: "rg-6", Title: "Live", Type: "Live", Status: "Official"},
|
|
{ID: "rg-7", Title: "Remix", Type: "Remix", Status: "Official"},
|
|
}
|
|
|
|
result := FilterReleaseGroups(groups, FilterOptions{})
|
|
|
|
if len(result) != 4 {
|
|
t.Fatalf("FilterReleaseGroups() returned %d groups, want 4", len(result))
|
|
}
|
|
|
|
allowedIDs := map[string]bool{"rg-1": true, "rg-2": true, "rg-3": true, "rg-4": true}
|
|
for _, rg := range result {
|
|
if !allowedIDs[rg.ID] {
|
|
t.Errorf("unexpected group %q in filtered results", rg.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilterReleaseGroups_IgnoreSingles(t *testing.T) {
|
|
groups := []ReleaseGroup{
|
|
{ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"},
|
|
{ID: "rg-2", Title: "Single", Type: "Single", Status: "Official"},
|
|
{ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"},
|
|
}
|
|
|
|
result := FilterReleaseGroups(groups, FilterOptions{IgnoreSingles: true})
|
|
|
|
if len(result) != 2 {
|
|
t.Fatalf("FilterReleaseGroups() returned %d groups, want 2", len(result))
|
|
}
|
|
for _, rg := range result {
|
|
if rg.Type == "Single" {
|
|
t.Errorf("single %q should have been filtered out", rg.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilterReleaseGroups_IgnoreCompilations(t *testing.T) {
|
|
groups := []ReleaseGroup{
|
|
{ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"},
|
|
{ID: "rg-2", Title: "Compilation", Type: "Compilation", Status: "Official"},
|
|
{ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"},
|
|
}
|
|
|
|
result := FilterReleaseGroups(groups, FilterOptions{IgnoreCompilations: true})
|
|
|
|
if len(result) != 2 {
|
|
t.Fatalf("FilterReleaseGroups() returned %d groups, want 2", len(result))
|
|
}
|
|
for _, rg := range result {
|
|
if rg.Type == "Compilation" {
|
|
t.Errorf("compilation %q should have been filtered out", rg.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- NormalizeString tests ----------
|
|
|
|
func TestNormalizeString_Basic(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
// Lowercase conversion
|
|
{"DARK SIDE OF THE MOON", "dark side of the moon"},
|
|
// Special character removal
|
|
{"Dark Side of the Moon!", "dark side of the moon"},
|
|
{"Dark-Side-of-the-Moon", "dark side of the moon"},
|
|
{"Dark_Side_of_the_Moon", "dark side of the moon"},
|
|
// Bracket removal
|
|
{"Dark Side of the Moon [Deluxe Edition]", "dark side of the moon"},
|
|
{"Dark Side of the Moon [Remastered 2020]", "dark side of the moon"},
|
|
{"Album [2023 Remix]", "album"},
|
|
// Parenthesis removal
|
|
{"Dark Side of the Moon (Deluxe)", "dark side of the moon"},
|
|
{"Album (Remastered)", "album"},
|
|
// Year removal
|
|
{"Dark Side of the Moon 1973", "dark side of the moon"},
|
|
{"Album 2020 Remastered", "album remastered"},
|
|
// Space collapsing
|
|
{"Dark Side of the Moon", "dark side of the moon"},
|
|
// Trim
|
|
{" Dark Side of the Moon ", "dark side of the moon"},
|
|
// Combined
|
|
{"The Dark Side of the Moon [2011 Remaster] (Deluxe Edition)", "the dark side of the moon"},
|
|
// Empty
|
|
{"", ""},
|
|
// Only special chars
|
|
{"!@#$%^&*()", ""},
|
|
// Digits that are not years should stay
|
|
{"30 Seconds to Mars", "30 seconds to mars"},
|
|
{"1941 - The Greatest Hits", "the greatest hits"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := NormalizeString(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("NormalizeString(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeArtistName(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"Pink Floyd", "pink floyd"},
|
|
{"The Beatles", "beatles"},
|
|
{"A Perfect Circle", "perfect circle"},
|
|
{"An Orchestra", "orchestra"},
|
|
{" The Who ", "who"},
|
|
{"THE WHO", "who"},
|
|
// No stripping needed
|
|
{"Radiohead", "radiohead"},
|
|
// Already stripped
|
|
{"Beatles", "beatles"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := NormalizeArtistName(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("NormalizeArtistName(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------- ReleaseGroup.ToExternalRelease tests ----------
|
|
|
|
func TestReleaseGroup_ToExternalRelease(t *testing.T) {
|
|
rg := ReleaseGroup{
|
|
ID: "rg-uuid-1",
|
|
Title: "Dark Side of the Moon",
|
|
Type: "Album",
|
|
Status: "Official",
|
|
ArtistID: "mbid-artist-uuid-1",
|
|
ArtistName: "Pink Floyd",
|
|
ReleaseDate: "1973-03-01",
|
|
}
|
|
|
|
// ToExternalRelease stores the canonical artist key (Navidrome ID), not the
|
|
// MusicBrainz ArtistID, so external_releases.artist_id matches artist_settings.
|
|
const navidromeArtistID = "navidrome-artist-uuid-1"
|
|
er := rg.ToExternalRelease(navidromeArtistID)
|
|
|
|
if er.RGID != "rg-uuid-1" {
|
|
t.Errorf("RGID = %q, want %q", er.RGID, "rg-uuid-1")
|
|
}
|
|
if er.ArtistID != navidromeArtistID {
|
|
t.Errorf("ArtistID = %q, want %q", er.ArtistID, navidromeArtistID)
|
|
}
|
|
if er.Title != "Dark Side of the Moon" {
|
|
t.Errorf("Title = %q, want %q", er.Title, "Dark Side of the Moon")
|
|
}
|
|
if er.Type != "Album" {
|
|
t.Errorf("Type = %q, want %q", er.Type, "Album")
|
|
}
|
|
if er.ReleaseDate != "1973-03-01" {
|
|
t.Errorf("ReleaseDate = %q, want %q", er.ReleaseDate, "1973-03-01")
|
|
}
|
|
}
|
|
|
|
// ---------- GetArtistReleaseGroups tests ----------
|
|
|
|
func TestGetArtistReleaseGroups_Success(t *testing.T) {
|
|
artistMBID := "artist-uuid-test"
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
|
<release-group-list count="2">
|
|
<release-group id="rg-uuid-1" type="Album">
|
|
<title>Dark Side of the Moon</title>
|
|
<first-release-date>1973-03-01</first-release-date>
|
|
<artist-credit>
|
|
<name-credit>
|
|
<artist id="` + artistMBID + `">
|
|
<name>Pink Floyd</name>
|
|
</artist>
|
|
</name-credit>
|
|
</artist-credit>
|
|
</release-group>
|
|
<release-group id="rg-uuid-2" type="Single">
|
|
<title>Another Brick in the Wall</title>
|
|
<first-release-date>1979-11-30</first-release-date>
|
|
<artist-credit>
|
|
<name-credit>
|
|
<artist id="` + artistMBID + `">
|
|
<name>Pink Floyd</name>
|
|
</artist>
|
|
</name-credit>
|
|
</artist-credit>
|
|
</release-group>
|
|
</release-group-list>
|
|
</metadata>`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.MusicBrainzConfig{
|
|
UserAgent: "naviwatcher/0.1.0 (test@example.com)",
|
|
}
|
|
|
|
rl := rate.NewLimiter(rate.Limit(1000), 1000)
|
|
client := &MusicBrainzClient{
|
|
httpClient: server.Client(),
|
|
userAgent: cfg.UserAgent,
|
|
baseURL: server.URL,
|
|
rateLimiter: rl,
|
|
}
|
|
|
|
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistReleaseGroups() error = %v", err)
|
|
}
|
|
|
|
if len(groups) != 2 {
|
|
t.Fatalf("GetArtistReleaseGroups() returned %d groups, want 2", len(groups))
|
|
}
|
|
|
|
if groups[0].Title != "Dark Side of the Moon" {
|
|
t.Errorf("groups[0].Title = %q, want %q", groups[0].Title, "Dark Side of the Moon")
|
|
}
|
|
if groups[1].Title != "Another Brick in the Wall" {
|
|
t.Errorf("groups[1].Title = %q, want %q", groups[1].Title, "Another Brick in the Wall")
|
|
}
|
|
}
|
|
|
|
func TestGetArtistReleaseGroups_Pagination(t *testing.T) {
|
|
artistMBID := "artist-page-test"
|
|
requestCount := 0
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestCount++
|
|
offset := r.URL.Query().Get("offset")
|
|
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
|
|
if offset == "0" || offset == "" {
|
|
// First page: return 100 results (full page, matching limit) to trigger pagination
|
|
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
|
<release-group-list count="101">`
|
|
for i := 0; i < 100; i++ {
|
|
xml += `
|
|
<release-group id="rg-page1-` + string(rune('0'+i%10)) + `" type="Album">
|
|
<title>Page 1 Album</title>
|
|
<first-release-date>2020-01-01</first-release-date>
|
|
<artist-credit>
|
|
<name-credit>
|
|
<artist id="` + artistMBID + `"><name>Artist</name></artist>
|
|
</name-credit>
|
|
</artist-credit>
|
|
</release-group>`
|
|
}
|
|
xml += `
|
|
</release-group-list>
|
|
</metadata>`
|
|
w.Write([]byte(xml))
|
|
} else {
|
|
// Second page: return only 1 result (< limit, signaling last page)
|
|
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
|
<release-group-list count="101">
|
|
<release-group id="rg-page2" type="Album">
|
|
<title>Page 2 Album</title>
|
|
<first-release-date>2021-01-01</first-release-date>
|
|
<artist-credit>
|
|
<name-credit>
|
|
<artist id="` + artistMBID + `"><name>Artist</name></artist>
|
|
</name-credit>
|
|
</artist-credit>
|
|
</release-group>
|
|
</release-group-list>
|
|
</metadata>`))
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.MusicBrainzConfig{
|
|
UserAgent: "naviwatcher/0.1.0 (test@example.com)",
|
|
}
|
|
|
|
rl := rate.NewLimiter(rate.Limit(1000), 1000)
|
|
client := &MusicBrainzClient{
|
|
httpClient: server.Client(),
|
|
userAgent: cfg.UserAgent,
|
|
baseURL: server.URL,
|
|
rateLimiter: rl,
|
|
}
|
|
|
|
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistReleaseGroups() error = %v", err)
|
|
}
|
|
|
|
// Should have fetched 2 pages: 100 from first + 1 from second = 101 total
|
|
if len(groups) != 101 {
|
|
t.Fatalf("GetArtistReleaseGroups() returned %d groups, want 101", len(groups))
|
|
}
|
|
|
|
if requestCount != 2 {
|
|
t.Errorf("expected 2 paginated requests, got %d", requestCount)
|
|
}
|
|
}
|
|
|
|
func TestGetArtistReleaseGroups_EmptyResult(t *testing.T) {
|
|
artistMBID := "artist-empty"
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
|
<release-group-list count="0">
|
|
</release-group-list>
|
|
</metadata>`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.MusicBrainzConfig{
|
|
UserAgent: "naviwatcher/0.1.0 (test@example.com)",
|
|
}
|
|
|
|
rl := rate.NewLimiter(rate.Limit(1000), 1000)
|
|
client := &MusicBrainzClient{
|
|
httpClient: server.Client(),
|
|
userAgent: cfg.UserAgent,
|
|
baseURL: server.URL,
|
|
rateLimiter: rl,
|
|
}
|
|
|
|
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistReleaseGroups() error = %v", err)
|
|
}
|
|
|
|
if len(groups) != 0 {
|
|
t.Errorf("GetArtistReleaseGroups() returned %d groups, want 0", len(groups))
|
|
}
|
|
}
|
|
|
|
func TestGetArtistReleaseGroups_ServerError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte("Rate limit exceeded"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.MusicBrainzConfig{
|
|
UserAgent: "naviwatcher/0.1.0 (test@example.com)",
|
|
}
|
|
|
|
rl := rate.NewLimiter(rate.Limit(1000), 1000)
|
|
client := &MusicBrainzClient{
|
|
httpClient: server.Client(),
|
|
userAgent: cfg.UserAgent,
|
|
baseURL: server.URL,
|
|
rateLimiter: rl,
|
|
}
|
|
|
|
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
|
if err == nil {
|
|
t.Fatal("GetArtistReleaseGroups() expected error for server error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestGetArtistReleaseGroups_InvalidXML(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
w.Write([]byte(`this is not valid xml`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.MusicBrainzConfig{
|
|
UserAgent: "naviwatcher/0.1.0 (test@example.com)",
|
|
}
|
|
|
|
rl := rate.NewLimiter(rate.Limit(1000), 1000)
|
|
client := &MusicBrainzClient{
|
|
httpClient: server.Client(),
|
|
userAgent: cfg.UserAgent,
|
|
baseURL: server.URL,
|
|
rateLimiter: rl,
|
|
}
|
|
|
|
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
|
if err == nil {
|
|
t.Fatal("GetArtistReleaseGroups() expected error for invalid XML, got nil")
|
|
}
|
|
}
|