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) } }