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()) } }) } }