Files
minstrel/internal/api/admin_wanted_test.go
T
bvandeusenandClaude Opus 5.5 a4fbe44bd5
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m35s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m10s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: the Wanted view lists artists A–Z with their albums inside, each album's tracks a click away (M502 #5572)
The list runs like the library: artists by the library's sort name, each
artist's albums oldest first, grouped under the artist (linked into the
library when it holds them). A filter narrows it to an artist or album by
name, on the server, so it finds what has not been scrolled to. Tracks
opens an album's track list: the library's own for an album it holds, a
lost file marked, otherwise MusicBrainz's standard edition, named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:43:32 -04:00

214 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/wanted", h.handleListWants)
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, withUser(httptest.NewRequest(http.MethodGet, path, nil), user))
return w
}
func seedWant(t *testing.T, h *handlers, group, artistMBID, artist, title, date string) dbq.Want {
t.Helper()
w, err := wanted.WantWith(context.Background(), dbq.New(h.pool), wanted.WantParams{
GroupMBID: group, ArtistMBID: artistMBID, ArtistName: artist, Title: title,
PrimaryType: "Album", FirstReleaseDate: date, Reason: wanted.ReasonFollow,
})
if err != nil {
t.Fatal(err)
}
return w
}
func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted?"+url.Values{"q": {q}}.Encode(), user)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var page wantsPage
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
return page
}
// The list runs like the library (#5572): artists A–Z by the library's sort
// name where the library holds the artist, each artist's albums oldest
// first; q narrows it to an artist or an album.
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
beatles := "a0000000-0000-4000-8000-000000000002"
if _, err := dbq.New(h.pool).UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000001", "a0000000-0000-4000-8000-000000000003", "Zeds", "Zed Album", "2001")
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969-09-26")
seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963-03-22")
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
page := listWanted(t, h, admin, "")
var got []string
for _, it := range page.Items {
got = append(got, it.ArtistName+"/"+it.Title)
}
want := "[The Beatles/Please Please Me The Beatles/Abbey Road Camel/Moonmadness Zeds/Zed Album]"
if fmt.Sprint(got) != want {
t.Errorf("order = %v, want %s", got, want)
}
if page.Items[0].ArtistID == "" || page.Items[2].ArtistID != "" {
t.Errorf("artist ids = %q, %q: only an artist the library holds has one", page.Items[0].ArtistID, page.Items[2].ArtistID)
}
page = listWanted(t, h, admin, "BEATLES")
if len(page.Items) != 2 || page.Total != 2 {
t.Errorf("q=BEATLES: %d items, total %d; want the two Beatles albums", len(page.Items), page.Total)
}
page = listWanted(t, h, admin, "moon")
if len(page.Items) != 1 || page.Items[0].Title != "Moonmadness" || page.Total != 1 {
t.Errorf("q=moon: %+v, total %d", page.Items, page.Total)
}
if page = listWanted(t, h, admin, "nothing like it"); len(page.Items) != 0 || page.Total != 0 {
t.Errorf("no match: %d items, total %d", len(page.Items), page.Total)
}
}
// fakeEditions is a MusicBrainz holding one group with two editions, the
// shorter official one the standard, or 503 when down is set.
func fakeEditions(t *testing.T, group string, 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
}
switch {
case r.URL.Path == "/release-group/"+group:
_, _ = fmt.Fprintf(w, `{"id": %q, "title": "Moonmadness", "primary-type": "Album", "first-release-date": "1976"}`, group)
case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == group:
_, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
{"id": "r-deluxe", "title": "Moonmadness", "status": "Official", "date": "2009", "country": "GB",
"media": [{"position": 1, "format": "CD", "tracks": [
{"position": 1, "number": "1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}},
{"position": 3, "number": "3", "title": "Bonus", "length": 200000, "recording": {"id": "rec-3"}}]}]},
{"id": "r-orig", "title": "Moonmadness", "status": "Official", "date": "1976-03", "country": "GB",
"media": [{"position": 1, "format": "12\" Vinyl", "tracks": [
{"position": 1, "number": "A1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
{"position": 2, "number": "A2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}}]}]}]}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantTracksView) {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted/"+id+"/tracks", user)
var out wantTracksView
if w.Code == http.StatusOK {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
}
return w.Code, out
}
// An album not yet held shows MusicBrainz's standard edition: the shortest
// official one.
func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
const group = "g0000000-0000-4000-8000-000000000010"
for _, down := range []bool{false, true} {
h, pool := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger)
w := seedWant(t, h, group, "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
if down {
if code != http.StatusServiceUnavailable {
t.Errorf("MusicBrainz down: status %d, want 503", code)
}
continue
}
if code != http.StatusOK || out.Source != "musicbrainz" || len(out.Tracks) != 2 {
t.Fatalf("status %d, %+v: want the two-track original", code, out)
}
if out.Edition != "1976 · 12\" Vinyl · GB" {
t.Errorf("edition = %q", out.Edition)
}
if tr := out.Tracks[1]; tr.Disc != 1 || tr.Number != 2 || tr.Title != "Song Within a Song" || tr.LengthMs != 433000 {
t.Errorf("track 2 = %+v", tr)
}
}
}
// A held album shows the library's own tracks, a lost file marked.
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
mbid := "a0000000-0000-4000-8000-000000000001"
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Camel", SortName: "Camel", Mbid: &mbid})
if err != nil {
t.Fatal(err)
}
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Moonmadness", SortTitle: "Moonmadness", ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
for i, title := range []string{"Aristillus", "Song Within a Song"} {
num := int32(i + 1)
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 120000,
FilePath: fmt.Sprintf("/music/camel/%02d.flac", i+1), FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
}
if _, err := h.pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE title = 'Aristillus'`); err != nil {
t.Fatal(err)
}
w := seedWant(t, h, "g0000000-0000-4000-8000-000000000011", mbid, "Camel", "Moonmadness", "1976")
if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album.ID}); err != nil {
t.Fatal(err)
}
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
if code != http.StatusOK || out.Source != "library" || len(out.Tracks) != 2 {
t.Fatalf("status %d, %+v", code, out)
}
if !out.Tracks[0].Missing || out.Tracks[1].Missing || out.Tracks[1].Number != 2 {
t.Errorf("tracks = %+v: the first is missing, the second on disk", out.Tracks)
}
}