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/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"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/playevents"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// 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()))
|
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) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
api.Post("/auth/login", h.handleLogin)
|
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("/likes/ids", h.handleGetLikedIDs)
|
||||||
|
|
||||||
authed.Get("/lidarr/search", h.handleLidarrSearch)
|
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 {
|
type handlers struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
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/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"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/playevents"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,7 +56,9 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
||||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
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
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
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{
|
paths := []string{
|
||||||
"/api/artists",
|
"/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/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"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/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||||
@@ -56,7 +57,9 @@ func (s *Server) Router() http.Handler {
|
|||||||
s.EventsCfg.SkipMaxCompletionRatio,
|
s.EventsCfg.SkipMaxCompletionRatio,
|
||||||
s.EventsCfg.SkipMaxDurationPlayedMs,
|
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) {
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
admin.Use(auth.RequireUser(s.Pool))
|
admin.Use(auth.RequireUser(s.Pool))
|
||||||
admin.Use(auth.RequireAdmin())
|
admin.Use(auth.RequireAdmin())
|
||||||
|
|||||||
Reference in New Issue
Block a user