diff --git a/internal/api/api.go b/internal/api/api.go index 2f5281bf..2354c5d8 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 87e24cc8..b2644ea8 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 6a76a0ae..63f01f2b 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -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", diff --git a/internal/api/requests.go b/internal/api/requests.go new file mode 100644 index 00000000..09d25e50 --- /dev/null +++ b/internal/api/requests.go @@ -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)) +} diff --git a/internal/api/requests_test.go b/internal/api/requests_test.go new file mode 100644 index 00000000..b71ac9d5 --- /dev/null +++ b/internal/api/requests_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bbe77125..77c87add 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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())