Files
NaviWatcher/internal/musicbrainz/api_test.go
Vladimir Zagainov 8a5b58a817 fix: address code review findings
- 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.
2026-07-19 18:41:18 +03:00

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")
}
}