package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-chi/chi/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz" ) const ( searchArtistMBID = "a74b1b7f-71a5-4011-9441-d0b5e4122711" searchGroupMBID = "b1392450-e666-3926-a536-22c65f834433" searchRecMBID = "c1a0e1b2-0000-4000-8000-000000000001" ) // fakeMusicBrainz answers the three searches with one Radiohead result each, // or 503 when down is set. func fakeMusicBrainz(t *testing.T, down bool) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if down { w.WriteHeader(http.StatusServiceUnavailable) return } credit := `[{"name": "Radiohead", "artist": {"id": "` + searchArtistMBID + `"}}]` group := `{"id": "` + searchGroupMBID + `", "title": "OK Computer", "primary-type": "Album", "first-release-date": "1997-05-21", "artist-credit": ` + credit + `}` switch r.URL.Path { case "/artist": _, _ = w.Write([]byte(`{"artists": [{"id": "` + searchArtistMBID + `", "name": "Radiohead", "type": "Group", "country": "GB"}]}`)) case "/release-group": _, _ = w.Write([]byte(`{"release-groups": [` + group + `]}`)) case "/recording": _, _ = w.Write([]byte(`{"recordings": [{"id": "` + searchRecMBID + `", "title": "Karma Police", "artist-credit": ` + credit + `, "releases": [{"id": "r1", "status": "Official", "release-group": ` + group + `}]}]}`)) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(srv.Close) return srv } func catalogueHandlers(t *testing.T, down bool) *handlers { t.Helper() h, pool := testHandlers(t) resetRequests(t, h) mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1} h.catalogue = catalogue.New(pool, mb, h.logger) return h } func doCatalogueSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/catalogue/search?"+url.Values{"q": {q}, "kind": {kind}}.Encode(), nil) w := httptest.NewRecorder() h.handleCatalogueSearch(w, req) return w } func decodeResults(t *testing.T, w *httptest.ResponseRecorder) []catalogueSearchResult { t.Helper() if w.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) } var out []catalogueSearchResult if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v; body = %s", err, w.Body.String()) } return out } func errorCode(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() var resp struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v; body = %s", err, w.Body.String()) } return resp.Error.Code } func TestCatalogueSearch_RefusesABadKindOrNoQuery(t *testing.T) { h := catalogueHandlers(t, false) if w := doCatalogueSearch(t, h, "x", "playlist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "bad_kind" { t.Errorf("bad kind: %d %s", w.Code, w.Body.String()) } if w := doCatalogueSearch(t, h, " ", "artist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "missing_query" { t.Errorf("no query: %d %s", w.Code, w.Body.String()) } } func TestCatalogueSearch_EachKindAsksMusicBrainz(t *testing.T) { h := catalogueHandlers(t, false) artists := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist")) if len(artists) != 1 || artists[0].MBID != searchArtistMBID || artists[0].SecondaryText != "Group · GB" { t.Fatalf("artists = %+v", artists) } if artists[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Radiohead" { t.Errorf("artist art = %q: it must be Minstrel's own endpoint", artists[0].ImageURL) } albums := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album")) if len(albums) != 1 || albums[0].MBID != searchGroupMBID || albums[0].ArtistMBID != searchArtistMBID || albums[0].SecondaryText != "Radiohead · 1997 · Album" { t.Fatalf("albums = %+v", albums) } tracks := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track")) if len(tracks) != 1 || tracks[0].AlbumMBID != searchGroupMBID || tracks[0].ArtistMBID != searchArtistMBID || tracks[0].SecondaryText != "OK Computer · Radiohead" { t.Fatalf("tracks = %+v", tracks) } } func TestCatalogueSearch_MarksWhatIsHeldAndWhatIsRequested(t *testing.T) { h := catalogueHandlers(t, false) ctx := context.Background() q := dbq.New(h.pool) artistMBID := searchArtistMBID artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: dbtest.TestUserPrefix + "Radiohead", SortName: "Radiohead", Mbid: &artistMBID}) if err != nil { t.Fatal(err) } // The album is held under a release id; its release group is what matches. if _, err := h.pool.Exec(ctx, `INSERT INTO albums (title, sort_title, artist_id, mbid, release_group_mbid) VALUES ('OK Computer', 'OK Computer', $1, 'some-release-id', $2)`, artist.ID, searchGroupMBID); err != nil { t.Fatal(err) } if got := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist")); !got[0].InLibrary { t.Error("the artist is held") } if got := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album")); !got[0].InLibrary { t.Error("the album is held, matched by release group") } user := seedUser(t, h.pool, "requester", "pw", false) track := searchRecMBID if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{ UserID: user.ID, Kind: dbq.RequestKindTrack, ArtistMbid: searchArtistMBID, TrackMbid: &track, ArtistName: "Radiohead", }); err != nil { t.Fatal(err) } got := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track")) if got[0].InLibrary || !got[0].Requested { t.Errorf("track = %+v, want requested and not held", got[0]) } } func TestCatalogueSearch_UnreachableMusicBrainzIs503(t *testing.T) { h := catalogueHandlers(t, true) w := doCatalogueSearch(t, h, "radiohead", "artist") if w.Code != http.StatusServiceUnavailable || errorCode(t, w) != "musicbrainz_unreachable" { t.Fatalf("status %d, body %s", w.Code, w.Body.String()) } } // artSource is an artist-art source with one image. type artSource struct{ apiTestAlbumProvider } func (artSource) FetchArtistArt(_ context.Context, _ coverart.ArtistRef) ([]byte, []byte, error) { return []byte("\x89PNG\r\n\x1a\nrest"), nil, nil } type fakeArtSources struct{ artists []coverart.ArtistArtProvider } func (s fakeArtSources) EnabledArtistProviders() []coverart.ArtistArtProvider { return s.artists } func (fakeArtSources) EnabledAlbumProviders() []coverart.AlbumCoverProvider { return nil } func TestCatalogueArt_ServesTheImageOrAPlaceholder404(t *testing.T) { h := &handlers{catalogueArt: catalogue.NewArt(t.TempDir(), fakeArtSources{artists: []coverart.ArtistArtProvider{&artSource{}}})} r := chi.NewRouter() r.Get("/api/catalogue/art/{kind}/{mbid}", h.handleCatalogueArt) get := func(path string) *httptest.ResponseRecorder { w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) return w } if w := get("/api/catalogue/art/artist/" + searchArtistMBID + "?name=Radiohead"); w.Code != http.StatusOK || w.Header().Get("Content-Type") != "image/png" { t.Errorf("artist: %d %q", w.Code, w.Header().Get("Content-Type")) } // No album source is enabled: a 404, which the page shows as its placeholder. if w := get("/api/catalogue/art/release-group/" + searchGroupMBID); w.Code != http.StatusNotFound { t.Errorf("release group with no source: %d", w.Code) } for _, bad := range []string{"/api/catalogue/art/artist/not-an-id", "/api/catalogue/art/label/" + searchArtistMBID} { if w := get(bad); w.Code != http.StatusNotFound { t.Errorf("%s: %d", bad, w.Code) } } } func TestSuggestionArt_PointsAtMinstrelsOwnEndpoint(t *testing.T) { views := []suggestionView{{MBID: searchArtistMBID, Name: "Sigur Rós"}, {Name: "No MBID"}} resolveSuggestionArt(views) if views[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Sigur+R%C3%B3s" { t.Errorf("image_url = %q", views[0].ImageURL) } if views[1].ImageURL != "" { t.Errorf("no MBID, no image: %q", views[1].ImageURL) } } func TestSearchCache_ReusesAnAnswerButNotTheFlags(t *testing.T) { c := newSearchCache() now := time.Now() c.now = func() time.Time { return now } asks := 0 ask := func(context.Context, string, string) ([]catalogueSearchResult, error) { asks++ return []catalogueSearchResult{{MBID: "m"}}, nil } first, _ := c.get(context.Background(), "artist", "Radiohead", ask) first[0].InLibrary = true // the handler sets flags on what it is given again, _ := c.get(context.Background(), "artist", "radiohead", ask) if asks != 1 { t.Errorf("asks = %d: the same words in another case are the same search", asks) } if again[0].InLibrary { t.Error("a flag set on one answer leaked into the cache") } if _, _ = c.get(context.Background(), "album", "radiohead", ask); asks != 2 { t.Error("another kind is another search") } now = now.Add(catalogueSearchTTL + time.Second) if _, _ = c.get(context.Background(), "artist", "radiohead", ask); asks != 3 { t.Error("an old answer is asked again") } var none *searchCache if _, _ = none.get(context.Background(), "artist", "x", ask); asks != 4 { t.Error("a nil cache asks every time") } }