Files
minstrel/internal/api/admin_requests_test.go
T
bvandeusenandClaude Opus 5.5 04515a977c
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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
feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:22 -04:00

339 lines
12 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// newAdminRequestsRouter builds a test chi router with the three admin/requests
// endpoints. RequireAdmin middleware is applied. Tests inject a user into
// context directly (skipping RequireUser).
func newAdminRequestsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/requests", h.handleListAdminRequests)
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
})
return r
}
// doAdminRequestReq fires an HTTP request at the admin requests router with
// the given user injected into context (bypassing RequireUser).
func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
var reqBody *bytes.Buffer
if body != nil {
reqBody = bytes.NewBuffer(body)
} else {
reqBody = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(method, path, reqBody)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req = withUser(req, user)
w := httptest.NewRecorder()
newAdminRequestsRouter(h).ServeHTTP(w, req)
return w
}
// testHandlersWithClientFn is the request handlers' test fixture.
func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) {
t.Helper()
return testHandlers(t)
}
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
// for the given user and returns its parsed requestView.
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
return createArtistRequest(t, h, user, artistMBID, artistName)
}
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
// and verifies that GET without ?status returns only the 2 pending rows.
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
createArtistRequest(t, h, alice, "mbid-pending-1", "Pending Artist 1")
createArtistRequest(t, h, alice, "mbid-pending-2", "Pending Artist 2")
// Reject one request directly via service.
rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me")
_, err := h.requests.Reject(context.Background(), rv3.ID, admin.ID, "test")
if err != nil {
t.Fatalf("reject seed: %v", err)
}
w := doAdminRequestReq(t, h, http.MethodGet, "/api/admin/requests", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var results []requestView
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2 (pending only)", len(results))
}
for _, rv := range results {
if rv.Status != "pending" {
t.Errorf("result status = %q, want pending", rv.Status)
}
}
}
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
// only rejected rows.
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1")
rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1")
_, err := h.requests.Reject(context.Background(), rv2.ID, admin.ID, "")
if err != nil {
t.Fatalf("reject seed: %v", err)
}
w := doAdminRequestReq(t, h, http.MethodGet, "/api/admin/requests?status=rejected", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var results []requestView
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1 rejected row", len(results))
}
if results[0].Status != "rejected" {
t.Errorf("status = %q, want rejected", results[0].Status)
}
}
// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid
// ?status returns 400.
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
admin := seedUser(t, h.pool, "admin", "pw", true)
w := doAdminRequestReq(t, h, http.MethodGet, "/api/admin/requests?status=garbage", nil, admin)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "invalid_status" {
t.Errorf("error = %q, want invalid_status", resp["error"])
}
}
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
// POST /approve → 409 request_not_pending.
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending")
// Pre-reject the row.
_, err := h.requests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
if err != nil {
t.Fatalf("pre-reject: %v", err)
}
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "request_not_pending" {
t.Errorf("error = %q, want request_not_pending", resp["error"])
}
}
// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns
// 200 and the row transitions to rejected with notes saved.
func TestHandleRejectRequest_HappyPath(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-reject", "Reject Me")
body := []byte(`{"notes":"low-quality release; will look for the remaster"}`)
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/reject", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got requestView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.Status != "rejected" {
t.Errorf("status = %q, want rejected", got.Status)
}
if got.Notes == nil || *got.Notes != "low-quality release; will look for the remaster" {
t.Errorf("notes = %v, want expected string", got.Notes)
}
}
// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body
// returns 200 (notes are optional).
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-reject-nonotes", "Reject No Notes")
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/reject", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got requestView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.Status != "rejected" {
t.Errorf("status = %q, want rejected", got.Status)
}
}
// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that
// all 3 admin request endpoints reject non-admin users with 403.
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
nonAdmin := seedUser(t, h.pool, "regular", "pw", false)
// Use a placeholder UUID for the ID endpoints.
fakeID := "00000000-0000-0000-0000-000000000001"
cases := []struct {
method string
path string
body []byte
}{
{http.MethodGet, "/api/admin/requests", nil},
{http.MethodPost, "/api/admin/requests/" + fakeID + "/approve", nil},
{http.MethodPost, "/api/admin/requests/" + fakeID + "/reject", nil},
}
for _, tc := range cases {
tc := tc
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := doAdminRequestReq(t, h, tc.method, tc.path, tc.body, nonAdmin)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "not_authorized" {
t.Errorf("error = %q, want not_authorized", resp["error"])
}
})
}
}
// TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts
// its release group on the wanted list, as the requester's.
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
w := doCreateRequest(h, alice, `{"kind":"album","artist_mbid":"artist-x","artist_name":"Gorillaz","album_mbid":"group-x","album_title":"Demon Days"}`)
if w.Code != http.StatusCreated {
t.Fatalf("create: %d %s", w.Code, w.Body.String())
}
var rv requestView
_ = json.Unmarshal(w.Body.Bytes(), &rv)
resp := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if resp.Code != http.StatusOK {
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
}
want, err := dbq.New(h.pool).GetWantByReleaseGroup(context.Background(), "group-x")
if err != nil {
t.Fatalf("no want recorded: %v", err)
}
if want.Title != "Demon Days" || want.CreatedBy != alice.ID || want.Reason != "request" {
t.Errorf("want = %+v", want)
}
}
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
// followed on the profile the admin picked; an unknown profile is a 400.
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
profile, err := dbq.New(h.pool).CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
})
if err != nil {
t.Fatal(err)
}
rv := seedPendingArtistRequest(t, h, alice, "artist-profiled", "Profiled")
path := "/api/admin/requests/" + uuidToString(rv.ID) + "/approve"
if resp := doAdminRequestReq(t, h, http.MethodPost, path, []byte(`{"profile_id":"00000000-0000-4000-8000-000000000000"}`), admin); resp.Code != http.StatusBadRequest {
t.Fatalf("unknown profile: %d %s", resp.Code, resp.Body.String())
}
body := []byte(`{"profile_id":"` + uuidToString(profile.ID) + `"}`)
if resp := doAdminRequestReq(t, h, http.MethodPost, path, body, admin); resp.Code != http.StatusOK {
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
}
f, err := dbq.New(h.pool).GetFollow(ctx, "artist-profiled")
if err != nil || f.ProfileID != profile.ID || !f.WantExisting {
t.Errorf("follow = %+v, %v: on Albums only, wanting what exists", f, err)
}
}