feat: implement MusicBrainz API endpoints, filtering, and normalization

- Add GetArtistReleaseGroups method with pagination support
- Implement status filtering (exclude Bootleg/Promotion/Pseudo-Release)
- Implement type filtering (include Album/Single/EP/Compilation)
- Add per-artist type filtering hooks (ArtistTypeFilter) for Web UI
- Add NormalizeString and NormalizeArtistName for fuzzy matching prep
- Add ReleaseGroup.ToExternalRelease conversion method
- Write comprehensive table-driven tests for filtering logic
- Write tests for normalization functions (18 cases)
- Write tests for GetArtistReleaseGroups (success, pagination, empty, errors)
- All tests pass (47 total across project), go vet clean
This commit is contained in:
2026-05-26 12:31:07 +03:00
parent b0f69d3a4f
commit e624bb0eaf
3 changed files with 894 additions and 8 deletions

View File

@@ -0,0 +1,662 @@
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(`<?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,
}
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 := `<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
<release-group-list count="150">`
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="150">
<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,
}
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(`<?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,
}
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")
}
}