release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m34s
release / integration (push) Failing after 5m34s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m44s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will never have, is left off: never matched again and not counted. A re-tagged album is matched again on the next listing. The Collection's verdict now says when library albums need matching. 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 (#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)
|
||
}
|
||
}
|
||
|
||
// The Collection's catalogue (#5572): 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, _ := testHandlersWithClientFn(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 := 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)
|
||
}
|
||
}
|
||
|
||
// The Unmatched section (#5579): 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, _ := testHandlersWithClientFn(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)
|
||
}
|
||
}
|