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
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>
339 lines
12 KiB
Go
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)
|
|
}
|
|
}
|