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:
+15
-8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user