Files
minstrel/internal/coverart/provider_theaudiodb_test.go
T
bvandeusen 8f64bcdb5f feat(server/m7-cover-sources): TheAudioDB provider
New album-cover and artist-art source implementing
AlbumCoverProvider, ArtistArtProvider, and TestableProvider. Hits
the documented /album-mb.php and /artist-mb.php JSON endpoints by
MBID, then GETs the returned image URLs (CDN, key-less). 2 req/sec
rate limit per the upstream's documented free/test-key tier;
mutex+lastCall serialises ALL HTTP calls regardless of method so
JSON metadata fetches and image GETs share the same budget.

429 / 5xx triggers exponential backoff with jitter (250ms × 2^attempt
± 20%) up to 3 retries before surfacing ErrTransient. 401 / 403
surface as ErrTransient (auth issues are config errors, not "this
album has no art" — the row stays NULL across passes so the operator
sees pending count not decreasing as the diagnostic signal).

Artist art is atomic at the JSON metadata step (one round-trip
yields both URLs); the two image GETs are independent — partial
success returns whatever bytes landed and ErrNotFound only if
neither URL is present in the response.

Configure() falls back to the documented public test key (2) when
the operator's APIKey is empty, per the no-coercive-settings
principle. Test connection hits a stable popular release MBID
(Pink Floyd, "The Dark Side of the Moon").

theAudioDBBaseURL is a var (not const) so tests can override it.
2026-05-06 12:39:50 -04:00

291 lines
9.1 KiB
Go

package coverart
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// withTheAudioDBBaseURL temporarily redirects theAudioDBBaseURL to a
// test server, returning a function the caller defers to restore.
func withTheAudioDBBaseURL(t *testing.T, mockURL string) {
t.Helper()
old := theAudioDBBaseURL
theAudioDBBaseURL = mockURL
t.Cleanup(func() { theAudioDBBaseURL = old })
}
func newTheAudioDBProviderForTest(t *testing.T, mockURL string) *theAudioDBProvider {
t.Helper()
resetRegistryForTests()
t.Cleanup(resetRegistryForTests)
withTheAudioDBBaseURL(t, mockURL)
p := &theAudioDBProvider{
client: &http.Client{Timeout: 5 * time.Second},
}
Register(p)
p.enabled.Store(true)
key := theAudioDBTestKey
p.apiKey.Store(&key)
return p
}
func TestTheAudioDB_ID(t *testing.T) {
resetRegistryForTests()
defer resetRegistryForTests()
p := &theAudioDBProvider{}
if p.ID() != "theaudiodb" {
t.Errorf("ID = %q, want theaudiodb", p.ID())
}
if !p.RequiresAPIKey() {
t.Error("RequiresAPIKey = false, want true")
}
if !p.DefaultEnabled() {
t.Error("DefaultEnabled = false, want true")
}
}
func TestTheAudioDB_FetchAlbumCover_Success(t *testing.T) {
imageBody := []byte("FAKE_JPEG")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/album-mb.php"):
w.Header().Set("Content-Type", "application/json")
// Use the test server's own URL for the image, so getImage
// hits the same server.
imageURL := "http://" + r.Host + "/img/album.jpg"
_ = json.NewEncoder(w).Encode(map[string]any{
"album": []map[string]any{
{"strAlbumThumb": imageURL},
},
})
case r.URL.Path == "/img/album.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(imageBody)
default:
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusBadRequest)
}
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
body, err := p.FetchAlbumCover(context.Background(), "test-mbid")
if err != nil {
t.Fatalf("FetchAlbumCover err = %v, want nil", err)
}
if string(body) != string(imageBody) {
t.Errorf("body = %q, want %q", body, imageBody)
}
}
func TestTheAudioDB_FetchAlbumCover_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":null}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, err := p.FetchAlbumCover(context.Background(), "missing-mbid")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestTheAudioDB_FetchAlbumCover_NullThumbURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":[{"strAlbumThumb":null}]}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, err := p.FetchAlbumCover(context.Background(), "mbid")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound when thumb URL is null", err)
}
}
func TestTheAudioDB_FetchAlbumCover_429TriggersRetry(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
if n == 1 {
http.Error(w, "rate limit", http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":null}`)) // second attempt: empty (still ErrNotFound, but past the retry)
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, err := p.FetchAlbumCover(context.Background(), "mbid")
if attempts.Load() < 2 {
t.Errorf("attempts = %d, want >= 2 (retry should have run)", attempts.Load())
}
// Final result is ErrNotFound (empty album response on retry).
if !errors.Is(err, ErrNotFound) {
t.Errorf("final err = %v, want ErrNotFound", err)
}
}
func TestTheAudioDB_FetchArtistArt_BothPresent(t *testing.T) {
thumbBody := []byte("THUMB")
fanartBody := []byte("FANART")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/artist-mb.php"):
thumbURL := "http://" + r.Host + "/img/thumb.jpg"
fanartURL := "http://" + r.Host + "/img/fanart.jpg"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"artists": []map[string]any{
{"strArtistThumb": thumbURL, "strArtistFanart": fanartURL},
},
})
case r.URL.Path == "/img/thumb.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(thumbBody)
case r.URL.Path == "/img/fanart.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(fanartBody)
default:
http.Error(w, "unexpected: "+r.URL.Path, http.StatusBadRequest)
}
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
thumb, fanart, err := p.FetchArtistArt(context.Background(), "mbid")
if err != nil {
t.Fatalf("err = %v", err)
}
if string(thumb) != "THUMB" {
t.Errorf("thumb = %q, want THUMB", thumb)
}
if string(fanart) != "FANART" {
t.Errorf("fanart = %q, want FANART", fanart)
}
}
func TestTheAudioDB_FetchArtistArt_ThumbOnly(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/artist-mb.php"):
thumbURL := "http://" + r.Host + "/img/thumb.jpg"
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"artists":[{"strArtistThumb":"` + thumbURL + `","strArtistFanart":null}]}`))
case r.URL.Path == "/img/thumb.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("T"))
default:
http.Error(w, "unexpected: "+r.URL.Path, http.StatusBadRequest)
}
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
thumb, fanart, err := p.FetchArtistArt(context.Background(), "mbid")
if err != nil {
t.Fatalf("err = %v", err)
}
if string(thumb) != "T" {
t.Errorf("thumb = %q, want T", thumb)
}
if fanart != nil {
t.Errorf("fanart = %v, want nil", fanart)
}
}
func TestTheAudioDB_FetchArtistArt_BothEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"artists":[{"strArtistThumb":null,"strArtistFanart":null}]}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, _, err := p.FetchArtistArt(context.Background(), "mbid")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestTheAudioDB_DefaultsToTestKeyOnEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Inspect the URL — should contain "/2/" (the test key).
if !strings.Contains(r.URL.Path, "/"+theAudioDBTestKey+"/") {
t.Errorf("URL path %q missing test key", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":null}`))
}))
defer srv.Close()
resetRegistryForTests()
defer resetRegistryForTests()
withTheAudioDBBaseURL(t, srv.URL)
p := &theAudioDBProvider{client: &http.Client{Timeout: 5 * time.Second}}
Register(p)
// Configure with empty key — should fall back to test key.
if err := p.Configure(ProviderSettings{Enabled: true, APIKey: ""}); err != nil {
t.Fatalf("Configure: %v", err)
}
_, _ = p.FetchAlbumCover(context.Background(), "mbid")
}
func TestTheAudioDB_DisabledReturnsNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit when provider is disabled")
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
p.enabled.Store(false)
_, err := p.FetchAlbumCover(context.Background(), "any")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound (disabled)", err)
}
_, _, err = p.FetchArtistArt(context.Background(), "any")
if !errors.Is(err, ErrNotFound) {
t.Errorf("artist err = %v, want ErrNotFound (disabled)", err)
}
}
func TestTheAudioDB_TestConnection_SuccessOnEmptyAlbum(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":null}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
if err := p.TestConnection(context.Background()); err != nil {
t.Errorf("TestConnection err = %v, want nil (empty album response is still 200)", err)
}
}
func TestTheAudioDB_TestConnection_FailureOn401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
if err := p.TestConnection(context.Background()); err == nil {
t.Error("TestConnection err = nil, want non-nil for 401")
}
}