517 lines
17 KiB
Go
517 lines
17 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|