Files
bvandeusen 6fedd495be feat(api): /api/quarantine user + admin endpoints + Service wiring
User-facing /api/quarantine handlers (POST flag, DELETE unflag, GET mine)
plus the five /api/admin/quarantine endpoints (queue, resolve, delete-file,
delete-via-lidarr, actions). Mount() and the handlers struct now accept the
lidarrquarantine.Service, constructed in server.go alongside the existing
lidarrrequests wiring.
2026-04-30 20:19:39 -04:00

306 lines
9.7 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// newQuarantineRouter builds a test router for the user-facing /api/quarantine
// endpoints. Tests inject the user into context manually (RequireUser is
// applied in real Mount but skipped here so unit tests can target the handler
// path directly).
func newQuarantineRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Post("/api/quarantine", h.handleFlag)
r.Delete("/api/quarantine/{track_id}", h.handleUnflag)
r.Get("/api/quarantine/mine", h.handleListMyQuarantine)
return r
}
// doFlag fires POST /api/quarantine with body as the given user.
func doFlag(h *handlers, user dbq.User, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/quarantine", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
return w
}
// doUnflag fires DELETE /api/quarantine/{track_id} as the given user.
func doUnflag(h *handlers, user dbq.User, trackID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, "/api/quarantine/"+trackID, nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
return w
}
// doListMine fires GET /api/quarantine/mine as the given user.
func doListMine(h *handlers, user dbq.User) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/quarantine/mine", nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
return w
}
// seedQuarantineTrack creates a minimal artist+album+track for quarantine
// tests. Uniqueness is forced by appending unique into title fields.
func seedQuarantineTrack(t *testing.T, h *handlers, unique string) dbq.Track {
t.Helper()
artist := seedArtist(t, h.pool, "Q Artist "+unique)
album := seedAlbum(t, h.pool, artist.ID, "Q Album "+unique, 0)
return seedTrack(t, h.pool, album.ID, artist.ID, "Q Track "+unique, 1, 30000)
}
// TestFlag_HappyPath verifies POST /api/quarantine returns 201 and the row
// shows up in ListMine.
func TestFlag_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "happy")
body := fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip","notes":"crackly"}`, uuidToString(track.ID))
w := doFlag(h, alice, body)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
}
var got quarantineView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.Reason != "bad_rip" {
t.Errorf("reason = %q, want bad_rip", got.Reason)
}
if got.Notes == nil || *got.Notes != "crackly" {
t.Errorf("notes = %v, want 'crackly'", got.Notes)
}
if got.TrackID != uuidToString(track.ID) {
t.Errorf("track_id = %q, want %q", got.TrackID, uuidToString(track.ID))
}
// Verify it shows up in ListMine.
wm := doListMine(h, alice)
if wm.Code != http.StatusOK {
t.Fatalf("ListMine status = %d, body = %s", wm.Code, wm.Body.String())
}
var rows []quarantineMineView
if err := json.Unmarshal(wm.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode mine: %v; body = %s", err, wm.Body.String())
}
if len(rows) != 1 {
t.Fatalf("mine len = %d, want 1", len(rows))
}
if rows[0].TrackID != uuidToString(track.ID) {
t.Errorf("mine track_id = %q, want %q", rows[0].TrackID, uuidToString(track.ID))
}
if rows[0].TrackTitle != track.Title {
t.Errorf("mine track_title = %q, want %q", rows[0].TrackTitle, track.Title)
}
}
// TestFlag_BadReason verifies an unknown reason returns 400 bad_reason.
func TestFlag_BadReason(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "badreason")
body := fmt.Sprintf(`{"track_id":%q,"reason":"garbage"}`, uuidToString(track.ID))
w := doFlag(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 != "bad_reason" {
t.Errorf("error.code = %q, want bad_reason", resp.Error.Code)
}
}
// TestFlag_TrackNotFound verifies a nonexistent track id returns 404.
func TestFlag_TrackNotFound(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
// Made-up but well-formed UUID — track won't exist in the DB.
body := `{"track_id":"00000000-0000-0000-0000-000000000099","reason":"bad_rip"}`
w := doFlag(h, alice, body)
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 != "track_not_found" {
t.Errorf("error.code = %q, want track_not_found", resp.Error.Code)
}
}
// TestUnflag_HappyPath verifies DELETE returns 204 after a flag.
func TestUnflag_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "unflag")
body := fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(track.ID))
if w := doFlag(h, alice, body); w.Code != http.StatusCreated {
t.Fatalf("flag failed: %d, body = %s", w.Code, w.Body.String())
}
w := doUnflag(h, alice, uuidToString(track.ID))
if w.Code != http.StatusNoContent {
t.Fatalf("unflag status = %d, want 204; body = %s", w.Code, w.Body.String())
}
// Confirm it's gone.
mine := doListMine(h, alice)
var rows []quarantineMineView
_ = json.Unmarshal(mine.Body.Bytes(), &rows)
if len(rows) != 0 {
t.Errorf("after unflag, mine len = %d, want 0", len(rows))
}
}
// TestUnflag_NotFound verifies DELETE on an un-flagged track returns 404
// quarantine_not_found.
func TestUnflag_NotFound(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "unflagnf")
w := doUnflag(h, alice, uuidToString(track.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 != "quarantine_not_found" {
t.Errorf("error.code = %q, want quarantine_not_found", resp.Error.Code)
}
}
// TestListMine_OK seeds two flags on alice and one on bob, verifies alice
// only sees her own.
func TestListMine_OK(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
t1 := seedQuarantineTrack(t, h, "lm-1")
t2 := seedQuarantineTrack(t, h, "lm-2")
t3 := seedQuarantineTrack(t, h, "lm-3")
for _, body := range []string{
fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(t1.ID)),
fmt.Sprintf(`{"track_id":%q,"reason":"wrong_tags"}`, uuidToString(t2.ID)),
} {
if w := doFlag(h, alice, body); w.Code != http.StatusCreated {
t.Fatalf("alice flag: %d, body = %s", w.Code, w.Body.String())
}
}
bobBody := fmt.Sprintf(`{"track_id":%q,"reason":"duplicate"}`, uuidToString(t3.ID))
if w := doFlag(h, bob, bobBody); w.Code != http.StatusCreated {
t.Fatalf("bob flag: %d, body = %s", w.Code, w.Body.String())
}
w := doListMine(h, alice)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var rows []quarantineMineView
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(rows) != 2 {
t.Fatalf("len = %d, want 2 (alice's only)", len(rows))
}
for _, r := range rows {
if r.TrackID == uuidToString(t3.ID) {
t.Errorf("alice can see bob's flag")
}
}
}
// TestQuarantineEndpointsRequireAuth verifies the /api/quarantine endpoints
// return 401 when no user is in context (covers RequireUser failure mode
// uniformly across the three handlers).
func TestQuarantineEndpointsRequireAuth(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
cases := []struct {
name string
method string
path string
body string
}{
{"flag", http.MethodPost, "/api/quarantine", `{"track_id":"00000000-0000-0000-0000-000000000001","reason":"bad_rip"}`},
{"unflag", http.MethodDelete, "/api/quarantine/00000000-0000-0000-0000-000000000001", ""},
{"mine", http.MethodGet, "/api/quarantine/mine", ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
var reqBody *bytes.Buffer
if tc.body != "" {
reqBody = bytes.NewBufferString(tc.body)
} else {
reqBody = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(tc.method, tc.path, reqBody)
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
// No user injected into context.
req = req.WithContext(context.Background())
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("%s: status = %d, want 401; body = %s", tc.name, w.Code, w.Body.String())
}
})
}
}