Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
372 lines
15 KiB
Go
372 lines
15 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"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()
|
||
return doWanted(t, h, http.MethodGet, path, "", user)
|
||
}
|
||
|
||
func doWanted(t *testing.T, h *handlers, method, path, body 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/artists", h.handleListWantArtists)
|
||
admin.Get("/wanted/unmatched", h.handleListUnmatched)
|
||
admin.Post("/wanted/unmatched/{id}/match", h.handleMatchUnmatched)
|
||
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
|
||
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
|
||
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
|
||
})
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, withUser(httptest.NewRequest(method, path, strings.NewReader(body)), 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: 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, _ := testHandlers(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)
|
||
}
|
||
}
|
||
|
||
// The Collection's catalogue: every artist on the list A–Z, each
|
||
// with how many of their albums are held; an artist's page lists every album
|
||
// of theirs, held or not, under that artist.
|
||
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||
ctx := context.Background()
|
||
q := dbq.New(h.pool)
|
||
beatles := "a0000000-0000-4000-8000-000000000002"
|
||
camel := "a0000000-0000-4000-8000-000000000001"
|
||
if _, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969")
|
||
held := seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963")
|
||
if _, err := h.pool.Exec(ctx, `UPDATE wants SET fulfilled_at = now() WHERE id = $1`, held.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", camel, "Camel", "Moonmadness", "1976")
|
||
if _, err := h.pool.Exec(ctx, `INSERT INTO follows (artist_mbid, artist_name, profile_id)
|
||
SELECT $1, 'Camel', id FROM release_type_profiles WHERE is_default`, camel); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
w := doWantedReq(t, h, "/api/admin/wanted/artists", admin)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||
}
|
||
var page wantArtistsPage
|
||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(page.Items) != 2 || page.Total != 2 || page.Counts.Artists != 2 {
|
||
t.Fatalf("catalogue = %+v, total %d, artists %d", page.Items, page.Total, page.Counts.Artists)
|
||
}
|
||
b, c := page.Items[0], page.Items[1]
|
||
if b.ArtistName != "The Beatles" || b.SortName != "Beatles, The" || b.Albums != 2 || b.Held != 1 || b.Wanted != 1 ||
|
||
b.ArtistID == "" || b.Following || b.ImageURL == "" {
|
||
t.Errorf("Beatles = %+v", b)
|
||
}
|
||
if c.ArtistName != "Camel" || c.Albums != 1 || c.Held != 0 || !c.Following || c.ArtistID != "" {
|
||
t.Errorf("Camel = %+v", c)
|
||
}
|
||
|
||
// q finds an artist by an album's title.
|
||
w = doWantedReq(t, h, "/api/admin/wanted/artists?q=moon", admin)
|
||
page = wantArtistsPage{}
|
||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(page.Items) != 1 || page.Items[0].ArtistName != "Camel" {
|
||
t.Errorf("q=moon: %+v", page.Items)
|
||
}
|
||
|
||
// The Beatles' page: both albums, held or not, headed by the artist.
|
||
w = doWantedReq(t, h, "/api/admin/wanted?state=all&artist="+beatles, admin)
|
||
var albums wantsPage
|
||
if err := json.Unmarshal(w.Body.Bytes(), &albums); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(albums.Items) != 2 || albums.Items[0].Title != "Please Please Me" || albums.Items[0].HeldAt == nil ||
|
||
albums.Artist == nil || albums.Artist.Held != 1 {
|
||
t.Errorf("artist page = %+v, head %+v", albums.Items, albums.Artist)
|
||
}
|
||
if w := doWantedReq(t, h, "/api/admin/wanted?state=all&artist=nobody", admin); w.Code != http.StatusNotFound {
|
||
t.Errorf("an artist with nothing on the list: status %d, want 404", w.Code)
|
||
}
|
||
}
|
||
|
||
// 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 := testHandlers(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, _ := testHandlers(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)
|
||
}
|
||
}
|
||
|
||
// The Unmatched section: the albums matching could not place, a
|
||
// local band's album left off and put back, and a link that names no
|
||
// MusicBrainz album refused before MusicBrainz is asked.
|
||
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||
ctx := context.Background()
|
||
q := dbq.New(h.pool)
|
||
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Annadale", SortName: "Annadale"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Dial Tone", SortTitle: "Dial Tone", ArtistID: artist.ID})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||
Title: "Intro", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 120000,
|
||
FilePath: "/music/annadale/01.flac", FileSize: 1, FileFormat: "flac",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
|
||
AlbumID: album.ID, Outcome: "unmatched", Detail: "MusicBrainz has no album by that name", Matcher: 1}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
id := uuidToString(album.ID)
|
||
|
||
list := func() []unmatchedAlbumView {
|
||
t.Helper()
|
||
w := doWantedReq(t, h, "/api/admin/wanted/unmatched", admin)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||
}
|
||
var out struct {
|
||
Items []unmatchedAlbumView `json:"items"`
|
||
}
|
||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return out.Items
|
||
}
|
||
if got := list(); len(got) != 1 || got[0].AlbumID != id || got[0].Artist != "Annadale" || got[0].Tracks != 1 ||
|
||
got[0].LeftOff || got[0].Detail == "" || got[0].DecidedAt == "" {
|
||
t.Fatalf("unmatched = %+v", got)
|
||
}
|
||
|
||
for _, c := range []struct {
|
||
path, body string
|
||
code int
|
||
}{
|
||
{"/api/admin/wanted/unmatched/" + id + "/match", `{"ref":"Dial Tone"}`, http.StatusBadRequest},
|
||
{"/api/admin/wanted/unmatched/" + id + "/match", `nope`, http.StatusBadRequest},
|
||
{"/api/admin/wanted/unmatched/not-a-uuid/leave-off", "", http.StatusBadRequest},
|
||
{"/api/admin/wanted/unmatched/" + id + "/put-back", "", http.StatusNotFound},
|
||
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNoContent},
|
||
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNotFound},
|
||
} {
|
||
if w := doWanted(t, h, http.MethodPost, c.path, c.body, admin); w.Code != c.code {
|
||
t.Errorf("POST %s %s = %d, want %d; body = %s", c.path, c.body, w.Code, c.code, w.Body.String())
|
||
}
|
||
}
|
||
if got := list(); len(got) != 1 || !got[0].LeftOff {
|
||
t.Fatalf("left off = %+v", got)
|
||
}
|
||
if w := doWanted(t, h, http.MethodPost, "/api/admin/wanted/unmatched/"+id+"/put-back", "", admin); w.Code != http.StatusNoContent {
|
||
t.Fatalf("put back = %d; body = %s", w.Code, w.Body.String())
|
||
}
|
||
if got := list(); len(got) != 1 || got[0].LeftOff {
|
||
t.Errorf("put back = %+v", got)
|
||
}
|
||
|
||
user := seedUser(t, h.pool, "listener", "pw", false)
|
||
if w := doWantedReq(t, h, "/api/admin/wanted/unmatched", user); w.Code != http.StatusForbidden {
|
||
t.Errorf("a listener reads the unmatched section: %d", w.Code)
|
||
}
|
||
}
|