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>
336 lines
12 KiB
Go
336 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"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
|
|
}
|
|
|
|
// seedPendingArtistRequest creates a pending artist-kind 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, _ := testHandlers(t)
|
|
resetRequests(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)
|
|
}
|
|
if rv.Username != alice.Username {
|
|
t.Errorf("result username = %q, want %q (the requester)", rv.Username, alice.Username)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected
|
|
// returns only rejected rows.
|
|
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
resetRequests(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, _ := testHandlers(t)
|
|
resetRequests(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, _ := testHandlers(t)
|
|
resetRequests(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, _ := testHandlers(t)
|
|
resetRequests(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, _ := testHandlers(t)
|
|
resetRequests(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, _ := testHandlers(t)
|
|
resetRequests(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, _ := testHandlers(t)
|
|
resetRequests(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, _ := testHandlers(t)
|
|
resetRequests(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)
|
|
}
|
|
}
|