Files
minstrel/internal/api/requests_test.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
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>
2026-10-23 12:23:58 -04:00

397 lines
13 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// newRequestsRouter builds a test chi router wiring the /api/requests handlers
// with chi URL params but without RequireUser middleware. Tests inject a user
// into the context manually.
func newRequestsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Post("/api/requests", h.handleCreateRequest)
r.Get("/api/requests", h.handleListRequests)
r.Get("/api/requests/{id}", h.handleGetRequest)
r.Delete("/api/requests/{id}", h.handleCancelRequest)
return r
}
// withUser injects a user into the request context the same way RequireUser
// does.
func withUser(req *http.Request, user dbq.User) *http.Request {
return req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
}
// doCreateRequest fires POST /api/requests with the given JSON body as the
// given user.
func doCreateRequest(h *handlers, user dbq.User, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/requests", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
w := httptest.NewRecorder()
newRequestsRouter(h).ServeHTTP(w, req)
return w
}
// doListRequests fires GET /api/requests as the given user.
func doListRequests(h *handlers, user dbq.User) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/requests", nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newRequestsRouter(h).ServeHTTP(w, req)
return w
}
// doGetRequest fires GET /api/requests/:id as the given user.
func doGetRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/requests/"+uuidToString(id), nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newRequestsRouter(h).ServeHTTP(w, req)
return w
}
// doCancelRequest fires DELETE /api/requests/:id as the given user.
func doCancelRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, "/api/requests/"+uuidToString(id), nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newRequestsRouter(h).ServeHTTP(w, req)
return w
}
// createArtistRequest is a convenience wrapper that seeds a valid artist
// request via the handler and returns the parsed response.
func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
body := fmt.Sprintf(`{"kind":"artist","artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
w := doCreateRequest(h, user, body)
if w.Code != http.StatusCreated {
t.Fatalf("createArtistRequest: status = %d, want 201; body = %s", w.Code, w.Body.String())
}
var rv requestView
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
t.Fatalf("createArtistRequest: decode: %v; body = %s", err, w.Body.String())
}
return rv
}
// TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList verifies
// that a user with auto_approve_requests=true gets their request approved on
// creation: an artist request follows the artist at once.
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
if _, err := pool.Exec(context.Background(),
"UPDATE users SET auto_approve_requests = true WHERE id = $1", alice.ID); err != nil {
t.Fatalf("set auto_approve: %v", err)
}
// Re-fetch so the User row carried into the request context has the flag.
refreshed, err := dbq.New(pool).GetUserByID(context.Background(), alice.ID)
if err != nil {
t.Fatalf("reload user: %v", err)
}
body := `{"kind":"artist","artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
w := doCreateRequest(h, refreshed, body)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
}
var rv requestView
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
t.Fatalf("decode: %v", err)
}
if rv.Status != "approved" {
t.Errorf("status = %q, want approved", rv.Status)
}
if _, err := dbq.New(pool).GetFollow(context.Background(), "auto-mbid-1"); err != nil {
t.Errorf("the artist should be followed: %v", err)
}
}
// TestHandleCreateRequest_Artist_HappyPath verifies a valid artist request
// returns 201 with kind=artist and status=pending.
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
}
var rv requestView
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if string(rv.Kind) != "artist" {
t.Errorf("kind = %q, want artist", rv.Kind)
}
if rv.Status != "pending" {
t.Errorf("status = %q, want pending", rv.Status)
}
if rv.ArtistMBID != "artist-mbid-1" {
t.Errorf("artist_mbid = %q", rv.ArtistMBID)
}
if rv.ArtistName != "The Beatles" {
t.Errorf("artist_name = %q", rv.ArtistName)
}
if !rv.ID.Valid {
t.Error("id not set")
}
}
// TestHandleCreateRequest_AlbumWithoutAlbumMBID_400 verifies that sending
// kind=album without album_mbid returns 400 with mbid_required.
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Missing album_mbid and album_title.
body := `{"kind":"album","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.Error.Code != "mbid_required" {
t.Errorf("error.code = %q, want mbid_required", resp.Error.Code)
}
}
// TestHandleCreateRequest_TrackWithoutTrackFields_400 verifies that kind=track
// with missing track_mbid/track_title returns 400 with mbid_required.
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Provides album fields but misses track_mbid and track_title.
body := `{"kind":"track","artist_mbid":"artist-mbid-1","artist_name":"Beatles","album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.Error.Code != "mbid_required" {
t.Errorf("error.code = %q, want mbid_required", resp.Error.Code)
}
}
// TestHandleListRequests_OnlyOwnRequests verifies alice sees only her own
// requests, not bob's.
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
createArtistRequest(t, h, alice, "alice-mbid-1", "Alice Artist 1")
createArtistRequest(t, h, alice, "alice-mbid-2", "Alice Artist 2")
createArtistRequest(t, h, bob, "bob-mbid-1", "Bob Artist 1")
w := doListRequests(h, alice)
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", len(results))
}
for _, rv := range results {
if rv.UserID != alice.ID {
t.Errorf("result belongs to wrong user: %v", rv.UserID)
}
}
}
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own
// request.
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist")
w := doGetRequest(h, alice, rv.ID)
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.ID != rv.ID {
t.Errorf("id mismatch")
}
}
// TestHandleGetRequest_OtherUserNotAdmin_404 verifies a non-admin caller
// cannot see another user's request.
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
charlie := seedUser(t, pool, "charlie", "pw", false)
rv := createArtistRequest(t, h, bob, "bob-artist-mbid", "Bob's Artist")
// Charlie is not an admin, tries to fetch Bob's request.
w := doGetRequest(h, charlie, rv.ID)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.Error.Code != "request_not_found" {
t.Errorf("error.code = %q, want request_not_found", resp.Error.Code)
}
}
// TestHandleGetRequest_OtherUserAsAdmin_200 verifies an admin can fetch any
// user's request.
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
rv := createArtistRequest(t, h, bob, "bob-admin-mbid", "Bob's Artist For Admin")
// Admin fetches Bob's request — should succeed.
w := doGetRequest(h, admin, rv.ID)
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.ID != rv.ID {
t.Errorf("id mismatch")
}
}
// TestHandleCancelRequest_PendingHappyPath verifies that cancelling a pending
// request returns 200 with status=rejected.
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me")
w := doCancelRequest(h, alice, rv.ID)
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)
}
}
// TestHandleCancelRequest_NonPending_409 verifies that cancelling an already
// rejected request returns 409 with request_not_pending.
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice")
// First cancel transitions to rejected.
w1 := doCancelRequest(h, alice, rv.ID)
if w1.Code != http.StatusOK {
t.Fatalf("first cancel: status = %d, want 200; body = %s", w1.Code, w1.Body.String())
}
// Second cancel on a now-rejected row → ErrNotPending → 409. The cancel SQL
// checks user_id = $2 AND status = 'pending'; zero rows → ErrNotPending.
// For this test we use dbq directly to reject via admin so Cancel returns
// ErrNotPending rather than the ownership miss. We already did first cancel
// so row is rejected.
w2 := doCancelRequest(h, alice, rv.ID)
if w2.Code != http.StatusConflict {
t.Fatalf("second cancel: status = %d, want 409; body = %s", w2.Code, w2.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w2.Body.String())
}
if resp.Error.Code != "request_not_pending" {
t.Errorf("error.code = %q, want request_not_pending", resp.Error.Code)
}
}
// TestHandleCancelRequest_WrongUser_409 verifies that attempting to cancel
// another user's request returns 409 (not_pending from the ownership miss).
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
bob := seedUser(t, pool, "bob-cancel-other", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-wrong-user-mbid", "Alice's Artist")
// Bob tries to cancel Alice's request: SQL WHERE user_id=bob AND
// status=pending → 0 rows.
w := doCancelRequest(h, bob, rv.ID)
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body = %s", w.Code, w.Body.String())
}
}