package acoustid import ( "compress/gzip" "context" "errors" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // docSample is the meta=recordings response printed in AcoustID's own // documentation (https://acoustid.org/webservice), artists and release groups // as given. const docSample = `{ "status": "ok", "results": [{ "score": 1.0, "id": "9ff43b6a-4f16-427c-93c2-92307ca505e0", "recordings": [{ "duration": 639, "releasegroups": [{"type": "Album", "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", "title": "Before the Dawn Heals Us"}], "title": "Lower Your Eyelids to Die With the Sun", "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}] }] }] }` // severalSample is the shape the worker has to disambiguate: one audio linked // to two recordings (an album and a single release), the album recording // reached again through a weaker second result, and a third result that // AcoustID knows but nobody has tied to MusicBrainz. const severalSample = `{ "status": "ok", "results": [ {"score": 0.71, "id": "a2", "recordings": [{"id": "rec-album", "title": "Song", "duration": 200.4}]}, {"score": 0.93, "id": "a1", "recordings": [ {"id": "rec-album", "title": "Song", "duration": 200.4, "artists": [{"name": "A"}, {"name": "B"}]}, {"id": "rec-single", "title": "Song (radio edit)", "duration": 181} ]}, {"score": 0.88, "id": "a3"} ] }` func TestParseLookup(t *testing.T) { cases := []struct { name string body string want []Candidate }{ {"single match", docSample, []Candidate{{ Recording: Recording{ ID: "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", Title: "Lower Your Eyelids to Die With the Sun", DurationSec: 639, Artists: []string{"M83"}, }, Score: 1.0, }}}, {"several recordings", severalSample, []Candidate{ {Recording: Recording{ID: "rec-album", Title: "Song", DurationSec: 200}, Score: 0.93}, {Recording: Recording{ID: "rec-single", Title: "Song (radio edit)", DurationSec: 181}, Score: 0.93}, }}, // Choosing a threshold is the worker's; the parser carries the score. {"below threshold", `{"status":"ok","results":[{"score":0.31,"id":"x","recordings":[{"id":"rec"}]}]}`, []Candidate{{Recording: Recording{ID: "rec"}, Score: 0.31}}}, {"no results", `{"status":"ok","results":[]}`, []Candidate{}}, {"results with no recordings", `{"status":"ok","results":[{"score":0.99,"id":"x"}]}`, []Candidate{}}, } for _, c := range cases { got, err := parseLookup(http.StatusOK, []byte(c.body)) if err != nil { t.Errorf("%s: err = %v", c.name, err) continue } if !sameCandidates(got, c.want) { t.Errorf("%s:\n got %+v\nwant %+v", c.name, got, c.want) } } } // The first result's recording is the album one with both artists; the // weaker repeat must not replace its fields, only fail to lower its score. func TestParseLookup_RepeatedRecordingKeepsBestScoreAndFirstFields(t *testing.T) { got, err := parseLookup(http.StatusOK, []byte(severalSample)) if err != nil { t.Fatal(err) } if got[0].ID != "rec-album" || got[0].Score != 0.93 { t.Errorf("album recording = %+v, want score 0.93", got[0]) } } func TestParseLookup_Errors(t *testing.T) { cases := []struct { name string status int body string want error }{ {"invalid key", 400, `{"status":"error","error":{"code":4,"message":"invalid API key"}}`, ErrInvalidKey}, {"unknown application", 400, `{"status":"error","error":{"code":17,"message":"unknown application"}}`, ErrInvalidKey}, {"bad fingerprint", 400, `{"status":"error","error":{"code":3,"message":"invalid fingerprint"}}`, ErrInvalidFingerprint}, {"rate limited", 429, `{"status":"error","error":{"code":14,"message":"rate limit (3.000000 requests per second) exceeded, try again later"}}`, ErrUnavailable}, {"down", 503, `{"status":"error","error":{"code":13,"message":"service currently unavailable, try again later"}}`, ErrUnavailable}, {"internal error", 500, `{"status":"error","error":{"code":5,"message":"internal error"}}`, ErrUnavailable}, {"proxy page", 502, `Bad Gateway`, ErrUnavailable}, } for _, c := range cases { _, err := parseLookup(c.status, []byte(c.body)) if !errors.Is(err, c.want) { t.Errorf("%s: err = %v, want %v", c.name, err, c.want) } } // A code this client caused (here a missing parameter) is neither the // key nor the service, so it must not read as either: retrying repeats it. _, err := parseLookup(400, []byte(`{"status":"error","error":{"code":2,"message":"missing required parameter \"fingerprint\""}}`)) if err == nil || errors.Is(err, ErrUnavailable) || errors.Is(err, ErrInvalidKey) || !strings.Contains(err.Error(), "missing required parameter") { t.Errorf("missing parameter: err = %v", err) } } // The request is what AcoustID documents: a gzip form POST carrying the key, // whole-second duration, fingerprint and meta=recordings. func TestLookup_SendsACompressedForm(t *testing.T) { var form url.Values srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.Header.Get("Content-Encoding") != "gzip" { t.Errorf("request %s with Content-Encoding %q, want a gzip POST", r.Method, r.Header.Get("Content-Encoding")) } zr, err := gzip.NewReader(r.Body) if err != nil { t.Fatalf("body is not gzip: %v", err) } raw, _ := io.ReadAll(zr) form, _ = url.ParseQuery(string(raw)) _, _ = io.WriteString(w, docSample) })) defer srv.Close() got, err := New(srv.URL).Lookup(context.Background(), "key123", "AQADtE", 241) if err != nil || len(got) != 1 { t.Fatalf("Lookup = %+v, %v", got, err) } for k, want := range map[string]string{ "client": "key123", "duration": "241", "fingerprint": "AQADtE", "meta": "recordings", "format": "json", } { if form.Get(k) != want { t.Errorf("form %s = %q, want %q", k, form.Get(k), want) } } } func TestLookup_DeadlineIsUnavailable(t *testing.T) { release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release })) defer srv.Close() defer close(release) c := New(srv.URL) c.http.Timeout = 50 * time.Millisecond _, err := c.Lookup(context.Background(), "k", "f", 1) if !errors.Is(err, ErrUnavailable) { t.Errorf("err = %v, want ErrUnavailable", err) } } // A cancelled caller is reported as such, never as the service being down. func TestLookup_CancelledCallerIsNotUnavailable(t *testing.T) { release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release })) defer srv.Close() defer close(release) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() _, err := New(srv.URL).Lookup(ctx, "k", "f", 1) if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrUnavailable) { t.Errorf("err = %v, want the caller's own deadline", err) } } func TestLookup_KeepsToTheRateLimit(t *testing.T) { var at []time.Time srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { at = append(at, time.Now()) _, _ = io.WriteString(w, `{"status":"ok","results":[]}`) })) defer srv.Close() c := New(srv.URL) for range 3 { if _, err := c.Lookup(context.Background(), "k", "f", 1); err != nil { t.Fatal(err) } } for i := 1; i < len(at); i++ { // A little slack for the server's own clock reading. if gap := at[i].Sub(at[i-1]); gap < minInterval-10*time.Millisecond { t.Errorf("requests %d and %d were %s apart, want at least %s", i-1, i, gap, minInterval) } } } func sameCandidates(a, b []Candidate) bool { if len(a) != len(b) { return false } for i := range a { if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].DurationSec != b[i].DurationSec || a[i].Score != b[i].Score || strings.Join(a[i].Artists, "|") != strings.Join(b[i].Artists, "|") { return false } } return true }