feat(api): add /api/requests user-facing CRUD

Implements POST/GET/GET:id/DELETE /api/requests handlers delegating to
lidarrrequests.Service; wires routes in RequireUser group and threads
the service through Mount and server.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 17:40:49 -04:00
parent 9192eb9f0a
commit 03cbd4d0c1
6 changed files with 583 additions and 11 deletions
+15 -8
View File
@@ -14,15 +14,16 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg}
h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs}
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
@@ -54,15 +55,21 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/likes/ids", h.handleGetLikedIDs)
authed.Get("/lidarr/search", h.handleLidarrSearch)
authed.Post("/requests", h.handleCreateRequest)
authed.Get("/requests", h.handleListRequests)
authed.Get("/requests/{id}", h.handleGetRequest)
authed.Delete("/requests/{id}", h.handleCancelRequest)
})
})
}
type handlers struct {
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
rng func() float64
lidarrCfg *lidarrconfig.Service
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
}
+4 -1
View File
@@ -23,6 +23,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -55,7 +56,9 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
ContextWeight: 2.0, SimilarityWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrconfig.New(pool)}
lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs}
return h, pool
}
+1 -1
View File
@@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests)
paths := []string{
"/api/artists",
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
)
// requestView is the JSON shape returned by all /api/requests handlers.
// Field names follow the lowercase snake_case convention for /api/* responses.
type requestView struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Status string `json:"status"`
Kind string `json:"kind"`
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
LidarrTrackMBID *string `json:"lidarr_track_mbid,omitempty"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
TrackTitle *string `json:"track_title,omitempty"`
QualityProfileID *int32 `json:"quality_profile_id,omitempty"`
RootFolderPath *string `json:"root_folder_path,omitempty"`
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
Notes *string `json:"notes,omitempty"`
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
RequestedAt pgtype.Timestamptz `json:"requested_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func requestViewFrom(row dbq.LidarrRequest) requestView {
return requestView{
ID: row.ID,
UserID: row.UserID,
Status: string(row.Status),
Kind: string(row.Kind),
LidarrArtistMBID: row.LidarrArtistMbid,
LidarrAlbumMBID: row.LidarrAlbumMbid,
LidarrTrackMBID: row.LidarrTrackMbid,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
TrackTitle: row.TrackTitle,
QualityProfileID: row.QualityProfileID,
RootFolderPath: row.RootFolderPath,
DecidedAt: row.DecidedAt,
DecidedBy: row.DecidedBy,
Notes: row.Notes,
CompletedAt: row.CompletedAt,
MatchedTrackID: row.MatchedTrackID,
MatchedAlbumID: row.MatchedAlbumID,
MatchedArtistID: row.MatchedArtistID,
RequestedAt: row.RequestedAt,
UpdatedAt: row.UpdatedAt,
}
}
// createRequestBody is the decoded JSON for POST /api/requests.
type createRequestBody struct {
Kind string `json:"kind"`
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
LidarrAlbumMBID string `json:"lidarr_album_mbid"`
LidarrTrackMBID string `json:"lidarr_track_mbid"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
TrackTitle string `json:"track_title"`
}
// handleCreateRequest implements POST /api/requests.
func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
var body createRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
row, err := h.lidarrRequests.Create(r.Context(), user.ID, lidarrrequests.CreateParams{
Kind: body.Kind,
LidarrArtistMBID: body.LidarrArtistMBID,
LidarrAlbumMBID: body.LidarrAlbumMBID,
LidarrTrackMBID: body.LidarrTrackMBID,
ArtistName: body.ArtistName,
AlbumTitle: body.AlbumTitle,
TrackTitle: body.TrackTitle,
})
if err != nil {
if errors.Is(err, lidarrrequests.ErrInvalidKindFields) {
writeErr(w, http.StatusBadRequest, "mbid_required", err.Error())
return
}
h.logger.Error("api: create request", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "create failed")
return
}
writeJSON(w, http.StatusCreated, requestViewFrom(row))
}
// handleListRequests implements GET /api/requests — returns caller's own requests.
func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100)
if err != nil {
h.logger.Error("api: list requests", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
return
}
out := make([]requestView, 0, len(rows))
for _, row := range rows {
out = append(out, requestViewFrom(row))
}
writeJSON(w, http.StatusOK, out)
}
// handleGetRequest implements GET /api/requests/:id.
// The caller must own the request, or be an admin.
func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid request id")
return
}
row, err := dbq.New(h.pool).GetLidarrRequestByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "request_not_found", "request not found")
return
}
h.logger.Error("api: get request", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "get failed")
return
}
// Allow if caller owns the request or is an admin.
if row.UserID != user.ID && !user.IsAdmin {
writeErr(w, http.StatusNotFound, "request_not_found", "request not found")
return
}
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
// handleCancelRequest implements DELETE /api/requests/:id.
// Cancels the caller's own pending request.
func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid request id")
return
}
row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID)
if err != nil {
switch {
case errors.Is(err, lidarrrequests.ErrNotPending):
writeErr(w, http.StatusConflict, "request_not_pending", "request is not pending")
case errors.Is(err, lidarrrequests.ErrNotFound):
writeErr(w, http.StatusNotFound, "request_not_found", "request not found")
default:
h.logger.Error("api: cancel request", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "cancel failed")
}
return
}
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
+356
View File
@@ -0,0 +1,356 @@
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","lidarr_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_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)
resetLidarrState(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
body := `{"kind":"artist","lidarr_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.LidarrArtistMBID != "artist-mbid-1" {
t.Errorf("lidarr_artist_mbid = %q", rv.LidarrArtistMBID)
}
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 lidarr_album_mbid returns 400 with mbid_required.
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Missing lidarr_album_mbid and album_title.
body := `{"kind":"album","lidarr_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)
resetLidarrState(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Provides album fields but misses track_mbid and track_title.
body := `{"kind":"track","lidarr_artist_mbid":"artist-mbid-1","artist_name":"Beatles","lidarr_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)
resetLidarrState(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)
resetLidarrState(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)
resetLidarrState(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)
resetLidarrState(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)
resetLidarrState(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)
resetLidarrState(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)
resetLidarrState(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())
}
}
+4 -1
View File
@@ -18,6 +18,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/web"
@@ -56,7 +57,9 @@ func (s *Server) Router() http.Handler {
s.EventsCfg.SkipMaxCompletionRatio,
s.EventsCfg.SkipMaxDurationPlayedMs,
)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrconfig.New(s.Pool))
lidarrCfg := lidarrconfig.New(s.Pool)
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, nil, nil)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs)
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireUser(s.Pool))
admin.Use(auth.RequireAdmin())