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) 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) 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 release group %q (type %q) passed filter", rg.ID, rg.Type) } } } func TestFilterReleaseGroups_Empty(t *testing.T) { result := FilterReleaseGroups(nil) if len(result) != 0 { t.Errorf("FilterReleaseGroups(nil) returned %d groups, want 0", len(result)) } } func TestFilterReleaseGroups_AllExcluded(t *testing.T) { groups := []ReleaseGroup{ {ID: "rg-1", Title: "Bootleg", Type: "Album", Status: "Bootleg"}, {ID: "rg-2", Title: "Promo", Type: "Single", Status: "Promotion"}, {ID: "rg-3", Title: "Soundtrack", Type: "Soundtrack", Status: "Official"}, } result := FilterReleaseGroups(groups) if len(result) != 0 { t.Errorf("FilterReleaseGroups() returned %d groups, want 0 (all excluded)", len(result)) } } func TestFilterReleaseGroups_NoStatus(t *testing.T) { // Release groups with empty status should pass (not excluded) groups := []ReleaseGroup{ {ID: "rg-1", Title: "Unknown Status", Type: "Album", Status: ""}, } result := FilterReleaseGroups(groups) if len(result) != 1 { t.Errorf("FilterReleaseGroups() returned %d groups, want 1 (empty status is not excluded)", len(result)) } } // ---------- IsStatusExcluded tests ---------- func TestIsStatusExcluded(t *testing.T) { tests := []struct { status string excluded bool }{ {"Bootleg", true}, {"Promotion", true}, {"Pseudo-Release", true}, {"Official", false}, {"", false}, {"official", false}, // case-sensitive: only exact match } for _, tt := range tests { t.Run(tt.status, func(t *testing.T) { got := IsStatusExcluded(tt.status) if got != tt.excluded { t.Errorf("IsStatusExcluded(%q) = %v, want %v", tt.status, got, tt.excluded) } }) } } // ---------- IsTypeIncluded tests ---------- func TestIsTypeIncluded(t *testing.T) { tests := []struct { rgType string included bool }{ {"Album", true}, {"Single", true}, {"EP", true}, {"Compilation", true}, {"Soundtrack", false}, {"Live", false}, {"Remix", false}, {"", false}, } for _, tt := range tests { t.Run(tt.rgType, func(t *testing.T) { got := IsTypeIncluded(tt.rgType) if got != tt.included { t.Errorf("IsTypeIncluded(%q) = %v, want %v", tt.rgType, got, tt.included) } }) } } // ---------- Artist type filter tests ---------- func TestFilterReleaseGroupsWithArtistFilter_AllEnabled(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"}, } filter := DefaultArtistTypeFilter("artist-1") result := FilterReleaseGroupsWithArtistFilter(groups, filter) if len(result) != 4 { t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 4", len(result)) } } func TestFilterReleaseGroupsWithArtistFilter_ExcludeSingles(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"}, } filter := &ArtistTypeFilter{ ArtistID: "artist-1", IncludeSingles: false, IncludeCompilations: true, IncludeEP: true, } result := FilterReleaseGroupsWithArtistFilter(groups, filter) if len(result) != 2 { t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 2", len(result)) } for _, rg := range result { if rg.Type == "Single" { t.Errorf("Single %q should have been excluded", rg.ID) } } } func TestFilterReleaseGroupsWithArtistFilter_ExcludeCompilations(t *testing.T) { groups := []ReleaseGroup{ {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, {ID: "rg-2", Title: "Compilation", Type: "Compilation", Status: "Official"}, } filter := &ArtistTypeFilter{ ArtistID: "artist-1", IncludeSingles: true, IncludeCompilations: false, IncludeEP: true, } result := FilterReleaseGroupsWithArtistFilter(groups, filter) if len(result) != 1 { t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 1", len(result)) } if result[0].ID != "rg-1" { t.Errorf("expected rg-1, got %s", result[0].ID) } } func TestFilterReleaseGroupsWithArtistFilter_ExcludeEP(t *testing.T) { groups := []ReleaseGroup{ {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, {ID: "rg-2", Title: "EP", Type: "EP", Status: "Official"}, } filter := &ArtistTypeFilter{ ArtistID: "artist-1", IncludeSingles: true, IncludeCompilations: true, IncludeEP: false, } result := FilterReleaseGroupsWithArtistFilter(groups, filter) if len(result) != 1 { t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 1", len(result)) } if result[0].ID != "rg-1" { t.Errorf("expected rg-1, got %s", result[0].ID) } } func TestFilterReleaseGroupsWithArtistFilter_NilFilter(t *testing.T) { groups := []ReleaseGroup{ {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, {ID: "rg-2", Title: "Soundtrack", Type: "Soundtrack", Status: "Official"}, } result := FilterReleaseGroupsWithArtistFilter(groups, nil) if len(result) != 1 { t.Fatalf("FilterReleaseGroupsWithArtistFilter(nil filter) returned %d groups, want 1", len(result)) } } func TestDefaultArtistTypeFilter(t *testing.T) { filter := DefaultArtistTypeFilter("artist-1") if filter.ArtistID != "artist-1" { t.Errorf("ArtistID = %q, want %q", filter.ArtistID, "artist-1") } if !filter.IncludeSingles { t.Error("IncludeSingles should be true by default") } if !filter.IncludeCompilations { t.Error("IncludeCompilations should be true by default") } if !filter.IncludeEP { t.Error("IncludeEP should be true by default") } } func TestIsTypeIncludedForArtist(t *testing.T) { tests := []struct { name string rgType string filter *ArtistTypeFilter included bool }{ { name: "Album always included", rgType: "Album", filter: DefaultArtistTypeFilter("artist-1"), included: true, }, { name: "Single included with filter", rgType: "Single", filter: DefaultArtistTypeFilter("artist-1"), included: true, }, { name: "Single excluded", rgType: "Single", filter: &ArtistTypeFilter{ IncludeSingles: false, IncludeCompilations: true, IncludeEP: true, }, included: false, }, { name: "Soundtrack excluded", rgType: "Soundtrack", filter: DefaultArtistTypeFilter("artist-1"), included: false, }, { name: "Nil filter falls back to base", rgType: "Album", filter: nil, included: true, }, { name: "Nil filter excludes non-base types", rgType: "Soundtrack", filter: nil, included: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsTypeIncludedForArtist(tt.rgType, tt.filter) if got != tt.included { t.Errorf("IsTypeIncludedForArtist(%q) = %v, want %v", tt.rgType, got, tt.included) } }) } } // ---------- 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: "artist-uuid-1", ArtistName: "Pink Floyd", ReleaseDate: "1973-03-01", } er := rg.ToExternalRelease() if er.RGID != "rg-uuid-1" { t.Errorf("RGID = %q, want %q", er.RGID, "rg-uuid-1") } if er.ArtistID != "artist-uuid-1" { t.Errorf("ArtistID = %q, want %q", er.ArtistID, "artist-uuid-1") } 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(` Dark Side of the Moon 1973-03-01 Pink Floyd Another Brick in the Wall 1979-11-30 Pink Floyd `)) })) 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, } defer client.Close() 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 // We generate multiple release-group elements in the XML xml := ` ` for i := 0; i < 100; i++ { xml += ` Page 1 Album 2020-01-01 Artist ` } xml += ` ` w.Write([]byte(xml)) } else { // Second page: return only 1 result (< limit, signaling last page) w.Write([]byte(` Page 2 Album 2021-01-01 Artist `)) } })) 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, } defer client.Close() 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(` `)) })) 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, } defer client.Close() 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, } defer client.Close() _, 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, } defer client.Close() _, err := client.GetArtistReleaseGroups(context.Background(), "artist-1") if err == nil { t.Fatal("GetArtistReleaseGroups() expected error for invalid XML, got nil") } }