package coverart import ( "context" "errors" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) // newLastfmTestProvider returns a fresh enabled provider configured // with the given base URL and API key, and restores lastfmBaseURL on // test cleanup. func newLastfmTestProvider(t *testing.T, baseURL, key string) *lastfmProvider { t.Helper() prevBase := lastfmBaseURL lastfmBaseURL = baseURL t.Cleanup(func() { lastfmBaseURL = prevBase }) p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond})} p.enabled.Store(true) p.apiKey.Store(&key) return p } // ── Identity / metadata ────────────────────────────────────────────────────── func TestLastfm_ID(t *testing.T) { p := &lastfmProvider{} if p.ID() != "lastfm" { t.Errorf("ID = %q, want lastfm", p.ID()) } if !p.RequiresAPIKey() { t.Error("RequiresAPIKey = false, want true") } if p.DefaultEnabled() { t.Error("DefaultEnabled = true, want false") } } // ── FetchArtistArt — happy paths ───────────────────────────────────────────── func TestLastfm_FetchArtistArt_ByMBID(t *testing.T) { mux := http.NewServeMux() srv := httptest.NewServer(mux) defer srv.Close() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("mbid") == "" { t.Error("expected mbid= query param, got none") } imgURL := "http://" + r.Host + "/img/a.jpg" w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"artist":{"name":"Radiohead","image":[ {"#text":"` + imgURL + `","size":"extralarge"} ]}}`)) }) mux.HandleFunc("/img/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write([]byte("artist-bytes")) }) p := newLastfmTestProvider(t, srv.URL+"/", "test-key") thumb, fanart, err := p.FetchArtistArt(context.Background(), ArtistRef{ MBID: "a74b1b7f-71a5-4011-9441-d0b5e4122711", Name: "Radiohead", }) if err != nil { t.Fatalf("err = %v, want nil", err) } if string(thumb) != "artist-bytes" { t.Errorf("thumb = %q, want artist-bytes", thumb) } if fanart != nil { t.Errorf("fanart = %v, want nil (Last.fm has no separate fanart)", fanart) } } func TestLastfm_FetchArtistArt_ByName(t *testing.T) { mux := http.NewServeMux() srv := httptest.NewServer(mux) defer srv.Close() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("mbid") != "" { t.Error("expected no mbid= when MBID is empty, got one") } if r.URL.Query().Get("artist") == "" { t.Error("expected artist= query param, got none") } imgURL := "http://" + r.Host + "/img/b.jpg" w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"artist":{"name":"Björk","image":[ {"#text":"` + imgURL + `","size":"large"} ]}}`)) }) mux.HandleFunc("/img/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write([]byte("bjork-bytes")) }) p := newLastfmTestProvider(t, srv.URL+"/", "test-key") thumb, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "Björk"}) if err != nil { t.Fatalf("err = %v, want nil", err) } if string(thumb) != "bjork-bytes" { t.Errorf("thumb = %q, want bjork-bytes", thumb) } } // ── FetchArtistArt — error/edge paths ──────────────────────────────────────── func TestLastfm_FetchArtistArt_PlaceholderImage(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"artist":{"name":"X","image":[ {"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"} ]}}`)) })) defer srv.Close() p := newLastfmTestProvider(t, srv.URL+"/", "test-key") _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "X"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound for placeholder image", err) } } func TestLastfm_FetchArtistArt_AlbumPlaceholderImage(t *testing.T) { // Second known placeholder hash (album cover default). srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"artist":{"name":"Y","image":[ {"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/c6f59c1e5e7240a4c0d427abd71f3dbb.jpg","size":"extralarge"} ]}}`)) })) defer srv.Close() p := newLastfmTestProvider(t, srv.URL+"/", "test-key") _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "Y"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound for album placeholder hash", err) } } func TestLastfm_FetchArtistArt_EmptyImageArray(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"artist":{"name":"Noimages","image":[]}}`)) })) defer srv.Close() p := newLastfmTestProvider(t, srv.URL+"/", "test-key") _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "Noimages"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound for empty image array", err) } } func TestLastfm_FetchArtistArt_ErrorResponse(t *testing.T) { // Last.fm error response: {"error": 6, "message": "Artist not found"} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"error":6,"message":"Artist not found"}`)) })) defer srv.Close() p := newLastfmTestProvider(t, srv.URL+"/", "test-key") _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "Ghost"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound for Last.fm error response", err) } } func TestLastfm_FetchArtistArt_EmptyAPIKey(t *testing.T) { // Enabled but no key: every call must return ErrNotFound, not a network error. p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond})} p.enabled.Store(true) emptyKey := "" p.apiKey.Store(&emptyKey) _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "X"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound when API key is empty", err) } } func TestLastfm_FetchArtistArt_Disabled(t *testing.T) { // Key set but disabled. p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond})} // enabled defaults to false key := "some-key" p.apiKey.Store(&key) _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "X"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound when disabled", err) } } func TestLastfm_FetchArtistArt_EmptyRefs(t *testing.T) { // Neither MBID nor Name: ErrNotFound without making any HTTP call. p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond})} p.enabled.Store(true) key := "test-key" p.apiKey.Store(&key) _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound for empty ArtistRef", err) } } func TestLastfm_FetchArtistArt_5xxTransient(t *testing.T) { var attempts atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() p := newLastfmTestProvider(t, srv.URL+"/", "test-key") _, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "X"}) if !errors.Is(err, ErrTransient) { t.Errorf("err = %v, want ErrTransient on 500", err) } if attempts.Load() < 2 { t.Errorf("attempts = %d, want >= 2 (retry should have run)", attempts.Load()) } } // ── FetchAlbumCover — happy path ───────────────────────────────────────────── func TestLastfm_FetchAlbumCover_Success(t *testing.T) { mux := http.NewServeMux() srv := httptest.NewServer(mux) defer srv.Close() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { imgURL := "http://" + r.Host + "/img/c.jpg" w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"album":{"name":"OK Computer","image":[ {"#text":"` + imgURL + `","size":"mega"} ]}}`)) }) mux.HandleFunc("/img/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write([]byte("album-bytes")) }) p := newLastfmTestProvider(t, srv.URL+"/", "test-key") body, err := p.FetchAlbumCover(context.Background(), AlbumRef{ ArtistName: "Radiohead", AlbumTitle: "OK Computer", }) if err != nil { t.Fatalf("err = %v, want nil", err) } if string(body) != "album-bytes" { t.Errorf("body = %q, want album-bytes", body) } } func TestLastfm_FetchAlbumCover_WithMBIDHint(t *testing.T) { mux := http.NewServeMux() srv := httptest.NewServer(mux) defer srv.Close() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("mbid") == "" { t.Error("expected mbid= hint in album query, got none") } imgURL := "http://" + r.Host + "/img/d.jpg" w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"album":{"name":"Kid A","image":[ {"#text":"` + imgURL + `","size":"extralarge"} ]}}`)) }) mux.HandleFunc("/img/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write([]byte("kida-bytes")) }) p := newLastfmTestProvider(t, srv.URL+"/", "test-key") body, err := p.FetchAlbumCover(context.Background(), AlbumRef{ MBID: "b80a8a53-c9bf-3a3a-9a8c-bf76bf9a553a", ArtistName: "Radiohead", AlbumTitle: "Kid A", }) if err != nil { t.Fatalf("err = %v, want nil", err) } if string(body) != "kida-bytes" { t.Errorf("body = %q, want kida-bytes", body) } } // ── FetchAlbumCover — missing fields ───────────────────────────────────────── func TestLastfm_FetchAlbumCover_MissingFields(t *testing.T) { p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond})} p.enabled.Store(true) key := "test-key" p.apiKey.Store(&key) cases := []AlbumRef{ {}, // nothing {ArtistName: "X"}, // no title {AlbumTitle: "Y"}, // no artist } for _, ref := range cases { _, err := p.FetchAlbumCover(context.Background(), ref) if !errors.Is(err, ErrNotFound) { t.Errorf("ref=%+v err=%v, want ErrNotFound", ref, err) } } } func TestLastfm_FetchAlbumCover_EmptyAPIKey(t *testing.T) { p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond})} p.enabled.Store(true) emptyKey := "" p.apiKey.Store(&emptyKey) _, err := p.FetchAlbumCover(context.Background(), AlbumRef{ArtistName: "X", AlbumTitle: "Y"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound when API key is empty", err) } } func TestLastfm_FetchAlbumCover_Disabled(t *testing.T) { p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond})} // enabled defaults to false key := "some-key" p.apiKey.Store(&key) _, err := p.FetchAlbumCover(context.Background(), AlbumRef{ArtistName: "X", AlbumTitle: "Y"}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound when disabled", err) } } // ── Rate-limit serialization ───────────────────────────────────────────────── func TestLastfm_RateLimit_Serializes(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") // Return error=6 so both calls resolve to ErrNotFound cleanly. _, _ = w.Write([]byte(`{"error":6,"message":"not found"}`)) })) defer srv.Close() prevBase := lastfmBaseURL lastfmBaseURL = srv.URL + "/" t.Cleanup(func() { lastfmBaseURL = prevBase }) // Build a provider WITH the production MinInterval so this test can // verify the rate-limit actually serializes. Other tests use // newLastfmTestProvider which leaves MinInterval=0 for speed. p := &lastfmProvider{client: newHTTPClient(httpClientOptions{ Name: "lastfm", HTTPClient: &http.Client{Timeout: 5 * time.Second}, MinInterval: lastfmMinPeriod, TreatAuthAsTransient: true, BaseBackoff: time.Millisecond, })} p.enabled.Store(true) key := "test-key" p.apiKey.Store(&key) start := time.Now() _, _, _ = p.FetchArtistArt(context.Background(), ArtistRef{Name: "A"}) _, _, _ = p.FetchArtistArt(context.Background(), ArtistRef{Name: "B"}) elapsed := time.Since(start) if elapsed < lastfmMinPeriod { t.Errorf("two back-to-back calls completed in %v, want at least %v (rate-limit didn't serialize)", elapsed, lastfmMinPeriod) } } // ── isLastfmPlaceholder — unit tests ───────────────────────────────────────── func TestIsLastfmPlaceholder(t *testing.T) { cases := []struct { name string rawURL string want bool }{ { name: "known artist star hash (png)", rawURL: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", want: true, }, { name: "known album cover hash (jpg)", rawURL: "https://lastfm.freetls.fastly.net/i/u/300x300/c6f59c1e5e7240a4c0d427abd71f3dbb.jpg", want: true, }, { name: "unknown (real) hash", rawURL: "https://lastfm.freetls.fastly.net/i/u/300x300/abcdef1234567890abcdef1234567890.jpg", want: false, }, { name: "uppercase known hash is still detected", rawURL: "https://lastfm.freetls.fastly.net/i/u/300x300/2A96CBD8B46E442FC41C2B86B821562F.png", want: true, }, { name: "malformed URL does not panic", rawURL: "://not a valid url", want: false, }, { name: "URL with query string and fragment", rawURL: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png?size=large#section", want: true, }, { name: "empty URL", rawURL: "", want: false, }, { name: "hash in path prefix, not basename, does not match", rawURL: "https://lastfm.freetls.fastly.net/2a96cbd8b46e442fc41c2b86b821562f/real_image.jpg", want: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := isLastfmPlaceholder(tc.rawURL) if got != tc.want { t.Errorf("isLastfmPlaceholder(%q) = %v, want %v", tc.rawURL, got, tc.want) } }) } } // ── pickLargestImage — unit tests ──────────────────────────────────────────── func TestPickLargestImage(t *testing.T) { cases := []struct { name string imgs []lastfmImage want string }{ { name: "mega preferred over all others", imgs: []lastfmImage{ {Text: "http://small", Size: "small"}, {Text: "http://mega", Size: "mega"}, {Text: "http://large", Size: "large"}, }, want: "http://mega", }, { name: "extralarge when no mega", imgs: []lastfmImage{ {Text: "http://extralarge", Size: "extralarge"}, {Text: "http://large", Size: "large"}, }, want: "http://extralarge", }, { name: "large when no mega or extralarge", imgs: []lastfmImage{ {Text: "http://large", Size: "large"}, {Text: "http://medium", Size: "medium"}, }, want: "http://large", }, { name: "medium fallback", imgs: []lastfmImage{ {Text: "http://medium", Size: "medium"}, {Text: "http://small", Size: "small"}, }, want: "http://medium", }, { name: "small last resort", imgs: []lastfmImage{ {Text: "http://small", Size: "small"}, }, want: "http://small", }, { name: "empty array returns empty", imgs: []lastfmImage{}, want: "", }, { name: "entries with empty text are skipped", imgs: []lastfmImage{ {Text: "", Size: "mega"}, {Text: "http://large", Size: "large"}, }, want: "http://large", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := pickLargestImage(tc.imgs) if got != tc.want { t.Errorf("pickLargestImage() = %q, want %q", got, tc.want) } }) } }