diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index 20363c5..d729735 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -717,4 +717,226 @@ impl QuoteRepository for D1Repository { .map(|_| ()) .map_err(|e| DbError::Internal(e.to_string())) } + + /// List all quotes that have at least one report, paginated (10 per page). + /// + /// Returns a [`super::ReportListResult`] ordered by most recent report + /// descending. Page numbers are 1-based. + async fn list_reports(&self, page: u32) -> Result { + #[derive(serde::Deserialize)] + struct TotalRow { + total: u32, + } + + #[derive(serde::Deserialize)] + struct SummaryRow { + quote_id: String, + text: String, + author: String, + report_count: u32, + latest_report_at: String, + } + + let page = page.max(1); + let offset = (page - 1) * 10; + + // Count distinct quoted with at least one report. + let total_row = self + .db + .prepare("SELECT COUNT(DISTINCT quote_id) AS total FROM reports") + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + let total_count = total_row.map(|r| r.total).unwrap_or(0); + let total_pages = total_count.div_ceil(10); + + // Aggregate: one row per quote, sorted by most recent report. + let raw_rows = self + .db + .prepare( + "SELECT q.id AS quote_id, q.text, q.author, COUNT(r.id) AS report_count, \ + MAX(r.created_at) AS latest_report_at \ + FROM reports r \ + JOIN quotes q ON q.id = r.quote_id \ + GROUP BY r.quote_id \ + ORDER BY latest_report_at DESC \ + LIMIT 10 OFFSET ?1", + ) + .bind(&[JsValue::from_f64(offset as f64)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + + let summaries = raw_rows + .into_iter() + .map(|r| { + let truncated = if r.text.chars().count() > 80 { + r.text.chars().take(80).collect() + } else { + r.text + }; + super::ReportSummary { + quote_id: r.quote_id, + text: truncated, + author: r.author, + report_count: r.report_count, + latest_report_at: r.latest_report_at, + } + }) + .collect(); + + Ok(super::ReportListResult { + reports: summaries, + page, + total_pages, + total_count, + }) + } + + /// Return the full quote plus all individual report rows for a quote. + /// + /// Reports are ordered oldest first by `created_at`. + /// Returns `Err(DbError::NotFound)` if no quote with `quote_id` exists. + async fn get_reports_for_quote(&self, quote_id: &str) -> Result { + #[derive(serde::Deserialize)] + struct ReportRowRaw { + id: String, + reason: Option, + created_at: String, + } + + // Fetch the quote. + let maybe_row = self + .db + .prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let row = maybe_row.ok_or(DbError::NotFound)?; + let tags = self.fetch_tags(&row.id).await?; + let quote = row.into_quote(tags); + + // Fetch all reports. + let report_rows = self + .db + .prepare( + "SELECT id, reason, created_at FROM reports \ + WHERE quote_id = ?1 ORDER BY created_at ASC", + ) + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + + let reports = report_rows + .into_iter() + .map(|r| super::ReportRow { + id: r.id, + reason: r.reason, + created_at: r.created_at, + }) + .collect(); + + Ok(super::QuoteReports { quote, reports }) + } + + /// Delete a quote unconditionally (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + /// Tags and reports are removed via `ON DELETE CASCADE`. + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> { + // Verify the quote exists first. + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + self.db + .prepare("DELETE FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Set `hidden = 1` on a quote (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> { + // Verify the quote exists first. + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + self.db + .prepare("UPDATE quotes SET hidden = 1 WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Delete all reports for a quote without deleting the quote itself. + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> { + // Verify the quote exists first. + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + self.db + .prepare("DELETE FROM reports WHERE quote_id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } } diff --git a/quotesdb/src/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs index 7f727be..c8fe3cc 100644 --- a/quotesdb/src/bin/api/db/mod.rs +++ b/quotesdb/src/bin/api/db/mod.rs @@ -42,6 +42,58 @@ pub struct ListResult { pub total_count: u32, } +/// A single report row returned by admin report queries. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportRow { + /// Unique report ID (NanoID). + pub id: String, + /// Optional human-readable reason supplied by the reporter. + pub reason: Option, + /// ISO timestamp when the report was created. + pub created_at: String, +} + +/// Summary of a reported quote, returned in the paginated reports list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportSummary { + /// The ID of the reported quote. + pub quote_id: String, + /// Abbreviated quote text (first 80 chars). + pub text: String, + /// Author of the reported quote. + pub author: String, + /// Total number of reports against this quote. + pub report_count: u32, + /// ISO timestamp of the most recent report. + pub latest_report_at: String, +} + +/// A paginated list of reported quotes. +/// +/// Returned by [`QuoteRepository::list_reports`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportListResult { + /// Summaries of reported quotes on this page. + pub reports: Vec, + /// Current page number (1-based). + pub page: u32, + /// Total number of pages. + pub total_pages: u32, + /// Total number of quotes with at least one report. + pub total_count: u32, +} + +/// Full details for a reported quote: the quote itself plus all report rows. +/// +/// Returned by [`QuoteRepository::get_reports_for_quote`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuoteReports { + /// The full quote object. + pub quote: quotesdb::Quote, + /// All reports submitted against this quote, ordered oldest first. + pub reports: Vec, +} + /// Outcome of a delete operation. #[derive(Debug, PartialEq)] pub enum DeleteResult { @@ -189,4 +241,35 @@ pub trait QuoteRepository { /// Returns `Err(DbError::NotFound)` if `quote_id` does not exist in the /// `quotes` table. async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError>; + + /// List all quotes that have at least one report, paginated (10 per page). + /// + /// Each entry includes: quote ID, truncated text, author, report count, + /// and the timestamp of the most recent report. Page numbers are 1-based. + async fn list_reports(&self, page: u32) -> Result; + + /// Return the full quote plus all individual report rows for a quote. + /// + /// Reports are ordered oldest first by `created_at`. + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn get_reports_for_quote(&self, quote_id: &str) -> Result; + + /// Delete a quote unconditionally (admin action). + /// + /// Does not verify per-quote auth code — caller must authenticate as admin + /// before invoking this method. Tags and reports are removed automatically + /// by the `ON DELETE CASCADE` constraints. + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError>; + + /// Set `hidden = 1` on a quote (admin action). + /// + /// Does not verify per-quote auth code — caller must authenticate as admin. + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError>; + + /// Delete all reports for a quote without deleting the quote itself. + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError>; } diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index 4eeb5dc..dc873e4 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -649,6 +649,205 @@ impl QuoteRepository for NativeRepository { .await .map_err(|e| DbError::Internal(e.to_string())) } + + /// List all quotes that have at least one report, paginated (10 per page). + /// + /// Returns a [`super::ReportListResult`] with aggregated summary rows — one + /// row per quote — ordered by most recent report descending (most urgent + /// first). Page numbers are 1-based. + async fn list_reports(&self, page: u32) -> Result { + let page = page.max(1); + let offset = (page - 1) * 10; + + self.conn + .call(move |conn| { + // Count distinct quotes with at least one report. + let total_count: u32 = + conn.query_row("SELECT COUNT(DISTINCT quote_id) FROM reports", [], |row| { + row.get(0) + })?; + + let total_pages = total_count.div_ceil(10); + + // Aggregate: one row per quote, sorted by most recent report. + let mut stmt = conn.prepare( + "SELECT q.id, q.text, q.author, COUNT(r.id) AS report_count, \ + MAX(r.created_at) AS latest_report_at \ + FROM reports r \ + JOIN quotes q ON q.id = r.quote_id \ + GROUP BY r.quote_id \ + ORDER BY latest_report_at DESC \ + LIMIT 10 OFFSET ?1", + )?; + let summaries = stmt + .query_map(rusqlite::params![offset], |row| { + let text: String = row.get(1)?; + let truncated = if text.chars().count() > 80 { + text.chars().take(80).collect() + } else { + text + }; + Ok(super::ReportSummary { + quote_id: row.get(0)?, + text: truncated, + author: row.get(2)?, + report_count: row.get(3)?, + latest_report_at: row.get(4)?, + }) + })? + .collect::, _>>()?; + + Ok(super::ReportListResult { + reports: summaries, + page, + total_pages, + total_count, + }) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Return the full quote plus all individual report rows for a quote. + /// + /// Reports are ordered oldest first by `created_at`. + /// Returns `Err(DbError::NotFound)` if no quote with `quote_id` exists. + async fn get_reports_for_quote(&self, quote_id: &str) -> Result { + let quote_id = quote_id.to_owned(); + + self.conn + .call(move |conn| { + // Fetch the quote itself. + let maybe_quote = conn + .query_row( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + |row| { + let tags = vec![]; // fetched below + row_to_quote(row, tags) + }, + ) + .optional()?; + + let mut quote = maybe_quote.ok_or(rusqlite::Error::QueryReturnedNoRows)?; + quote.tags = fetch_tags_for_quote(conn, "e.id)?; + + // Fetch all reports for this quote. + let mut stmt = conn.prepare( + "SELECT id, reason, created_at FROM reports \ + WHERE quote_id = ?1 ORDER BY created_at ASC", + )?; + let reports = stmt + .query_map(rusqlite::params![quote_id], |row| { + Ok(super::ReportRow { + id: row.get(0)?, + reason: row.get(1)?, + created_at: row.get(2)?, + }) + })? + .collect::, _>>()?; + + Ok(super::QuoteReports { quote, reports }) + }) + .await + .map_err(|e| match e { + tokio_rusqlite::Error::Rusqlite(rusqlite::Error::QueryReturnedNoRows) => { + DbError::NotFound + } + other => DbError::Internal(other.to_string()), + }) + } + + /// Delete a quote unconditionally (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + /// Tags and reports are removed via `ON DELETE CASCADE`. + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + + self.conn + .call(move |conn| { + let rows = conn.execute( + "DELETE FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + )?; + Ok(rows) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + .and_then(|rows| { + if rows == 0 { + Err(DbError::NotFound) + } else { + Ok(()) + } + }) + } + + /// Set `hidden = 1` on a quote (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + + self.conn + .call(move |conn| { + let rows = conn.execute( + "UPDATE quotes SET hidden = 1 WHERE id = ?1", + rusqlite::params![quote_id], + )?; + Ok(rows) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + .and_then(|rows| { + if rows == 0 { + Err(DbError::NotFound) + } else { + Ok(()) + } + }) + } + + /// Delete all reports for a quote without deleting the quote itself. + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + + // First verify the quote exists. + let exists = self + .conn + .call({ + let quote_id = quote_id.clone(); + move |conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + |row| row.get(0), + )?; + Ok(count > 0) + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + if !exists { + return Err(DbError::NotFound); + } + + self.conn + .call(move |conn| { + conn.execute( + "DELETE FROM reports WHERE quote_id = ?1", + rusqlite::params![quote_id], + )?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } } #[cfg(test)] @@ -1138,6 +1337,152 @@ mod tests { ); } + // ── Admin moderation DB method tests (ticket 6c5904) ────────────────────── + + /// `list_reports` returns an empty [`super::ReportListResult`] when there + /// are no reports in the database. + #[tokio::test] + async fn test_list_reports_empty() { + let repo = in_memory_repo().await; + let result = repo.list_reports(1).await.unwrap(); + assert_eq!(result.total_count, 0); + assert_eq!(result.total_pages, 0); + assert!(result.reports.is_empty()); + } + + /// `list_reports` returns a summary row for a quote that has been reported. + #[tokio::test] + async fn test_list_reports_with_report() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Reported", "Author")) + .await + .unwrap(); + repo.create_report("e.id, Some("spam")).await.unwrap(); + + let result = repo.list_reports(1).await.unwrap(); + assert_eq!(result.total_count, 1); + assert_eq!(result.reports[0].quote_id, quote.id); + assert_eq!(result.reports[0].report_count, 1); + } + + /// `get_reports_for_quote` returns `Err(DbError::NotFound)` for an unknown + /// quote ID. + #[tokio::test] + async fn test_get_reports_for_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.get_reports_for_quote("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + + /// `get_reports_for_quote` returns the quote and all associated reports. + #[tokio::test] + async fn test_get_reports_for_quote_with_data() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Text", "Author")) + .await + .unwrap(); + repo.create_report("e.id, Some("reason one")) + .await + .unwrap(); + repo.create_report("e.id, None).await.unwrap(); + + let qr = repo.get_reports_for_quote("e.id).await.unwrap(); + assert_eq!(qr.quote.id, quote.id); + assert_eq!(qr.reports.len(), 2); + // First report (oldest) should have a reason. + assert_eq!(qr.reports[0].reason.as_deref(), Some("reason one")); + } + + /// `admin_delete_quote` removes the quote and returns `Ok(())`. + #[tokio::test] + async fn test_admin_delete_quote_success() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Delete me", "Author")) + .await + .unwrap(); + repo.admin_delete_quote("e.id).await.unwrap(); + let fetched = repo.get_quote("e.id).await.unwrap(); + assert!(fetched.is_none(), "quote should be deleted"); + } + + /// `admin_delete_quote` returns `Err(DbError::NotFound)` for an unknown ID. + #[tokio::test] + async fn test_admin_delete_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.admin_delete_quote("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + + /// `hide_quote` sets `hidden = true` on the quote. + #[tokio::test] + async fn test_hide_quote_success() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Visible", "Author")) + .await + .unwrap(); + assert!(!quote.hidden, "quote should start visible"); + repo.hide_quote("e.id).await.unwrap(); + let fetched = repo.get_quote("e.id).await.unwrap().unwrap(); + assert!(fetched.hidden, "quote should be hidden after hide_quote"); + } + + /// `hide_quote` returns `Err(DbError::NotFound)` for an unknown ID. + #[tokio::test] + async fn test_hide_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.hide_quote("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + + /// `clear_reports` removes all reports for a quote but leaves the quote. + #[tokio::test] + async fn test_clear_reports_success() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Spammy", "Author")) + .await + .unwrap(); + repo.create_report("e.id, Some("spam")).await.unwrap(); + repo.create_report("e.id, None).await.unwrap(); + + repo.clear_reports("e.id).await.unwrap(); + + // The quote should still exist. + let fetched = repo.get_quote("e.id).await.unwrap(); + assert!( + fetched.is_some(), + "quote must still exist after clear_reports" + ); + + // The reports should be gone. + let qr = repo.get_reports_for_quote("e.id).await.unwrap(); + assert!(qr.reports.is_empty(), "reports should be cleared"); + } + + /// `clear_reports` returns `Err(DbError::NotFound)` for an unknown quote ID. + #[tokio::test] + async fn test_clear_reports_not_found() { + let repo = in_memory_repo().await; + let result = repo.clear_reports("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + /// `get_quote` (direct ID lookup) must return the quote even when it is hidden. #[tokio::test] async fn test_get_quote_returns_hidden_quote() { diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index 21bbc07..1f5b549 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -581,6 +581,157 @@ async fn report_handler( } } +/// Query parameters for `GET /api/admin/reports`. +#[derive(Debug, Deserialize)] +struct AdminReportsParams { + /// 1-based page number. Defaults to 1. + #[serde(default = "default_page")] + page: u32, +} + +/// `GET /api/admin/reports` — paginated list of reported quotes. +/// +/// Returns a [`ReportListResult`] with 10 entries per page. Each entry +/// contains the quote ID, truncated text, author, total report count, and +/// the timestamp of the most recent report. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn list_reports_handler( + State(repo): State, + headers: HeaderMap, + Query(params): Query, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.list_reports(params.page).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(e) => db_error_response(e), + } +} + +/// `GET /api/admin/reports/:quote_id` — full quote and all reports for it. +/// +/// Returns a JSON object with `quote` and `reports` fields. Reports are +/// ordered oldest first. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn get_quote_reports_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.get_reports_for_quote("e_id).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +/// `DELETE /api/admin/reports/:quote_id/quote` — delete a quote as admin. +/// +/// Deletes the quote unconditionally (no per-quote auth code required). +/// Tags and reports are removed automatically via `ON DELETE CASCADE`. +/// Returns `204 No Content` on success. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn admin_delete_quote_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.admin_delete_quote("e_id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +/// `POST /api/admin/reports/:quote_id/hide` — hide a quote. +/// +/// Sets `hidden = 1` on the quote so it is excluded from public listing. +/// Returns `200 OK` with `{"hidden": true}` on success. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn hide_quote_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.hide_quote("e_id).await { + Ok(()) => Json(serde_json::json!({ "hidden": true })).into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +/// `DELETE /api/admin/reports/:quote_id/reports` — clear all reports for a quote. +/// +/// Removes all report rows for the given quote without deleting the quote +/// itself. Returns `204 No Content` on success. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn clear_reports_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.clear_reports("e_id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + // ── Router ──────────────────────────────────────────────────────────────────── /// Build the Axum [`Router`] with all API routes wired to their handlers. @@ -604,6 +755,24 @@ pub fn router(repo: Arc) -> Router { .route("/api/admin/lock", post(lock_submissions)) .route("/api/admin/unlock", post(unlock_submissions)) .route("/api/admin/reset-auth-code", post(reset_auth_code)) + // Admin moderation endpoints — report management. + .route("/api/admin/reports", get(list_reports_handler)) + .route( + "/api/admin/reports/{quote_id}", + get(get_quote_reports_handler), + ) + .route( + "/api/admin/reports/{quote_id}/quote", + delete(admin_delete_quote_handler), + ) + .route( + "/api/admin/reports/{quote_id}/hide", + post(hide_quote_handler), + ) + .route( + "/api/admin/reports/{quote_id}/reports", + delete(clear_reports_handler), + ) // IMPORTANT: /random and /{id}/report must be registered before /{id} // so static segments win over the dynamic capture. .route("/api/quotes/random", get(random_handler)) @@ -843,6 +1012,62 @@ mod tests { Err(DbError::NotFound) } } + + async fn list_reports(&self, page: u32) -> Result { + Ok(crate::db::ReportListResult { + reports: vec![], + page, + total_pages: 0, + total_count: 0, + }) + } + + async fn get_reports_for_quote( + &self, + quote_id: &str, + ) -> Result { + let quotes = self.quotes.lock().unwrap(); + let maybe = quotes.iter().find(|(q, _)| q.id == quote_id); + match maybe { + None => Err(DbError::NotFound), + Some((q, _)) => Ok(crate::db::QuoteReports { + quote: q.clone(), + reports: vec![], + }), + } + } + + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> { + let mut quotes = self.quotes.lock().unwrap(); + let pos = quotes.iter().position(|(q, _)| q.id == quote_id); + match pos { + None => Err(DbError::NotFound), + Some(i) => { + quotes.remove(i); + Ok(()) + } + } + } + + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> { + let mut quotes = self.quotes.lock().unwrap(); + match quotes.iter_mut().find(|(q, _)| q.id == quote_id) { + None => Err(DbError::NotFound), + Some((q, _)) => { + q.hidden = true; + Ok(()) + } + } + } + + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> { + let quotes = self.quotes.lock().unwrap(); + if quotes.iter().any(|(q, _)| q.id == quote_id) { + Ok(()) + } else { + Err(DbError::NotFound) + } + } } fn sample_quote() -> Quote { @@ -1526,6 +1751,325 @@ mod tests { "response must include auth_code after second reset" ); } + + // ── GET /api/admin/reports handler tests ────────────────────────────────── + + /// `GET /api/admin/reports` with a valid admin code returns `200` and a + /// [`ReportListResult`] JSON body (empty list since MockRepo returns no rows). + #[tokio::test] + async fn test_list_reports_correct_code_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["total_count"], 0); + assert!(v["reports"].is_array()); + } + + /// `GET /api/admin/reports` with no `X-Admin-Code` header returns `403`. + #[tokio::test] + async fn test_list_reports_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// `GET /api/admin/reports` with a wrong admin code returns `403`. + #[tokio::test] + async fn test_list_reports_wrong_code_returns_403() { + let repo = MockRepo::with_admin_code("real-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "wrong-code") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── GET /api/admin/reports/:quote_id handler tests ──────────────────────── + + /// `GET /api/admin/reports/:quote_id` with a valid code and existing quote + /// returns `200` with the quote and an empty reports list. + #[tokio::test] + async fn test_get_quote_reports_found_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + // Seed a quote into the mock. + repo.create_quote(quotesdb::CreateQuoteInput { + text: "Test".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + // Retrieve the quote id. + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::GET) + .uri(format!("/api/admin/reports/{quote_id}")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["quote"]["id"], quote_id.as_str()); + assert!(v["reports"].is_array()); + } + + /// `GET /api/admin/reports/:quote_id` for a nonexistent quote returns `404`. + #[tokio::test] + async fn test_get_quote_reports_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports/nonexistent") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `GET /api/admin/reports/:quote_id` with no admin code returns `403`. + #[tokio::test] + async fn test_get_quote_reports_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports/any-id") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── DELETE /api/admin/reports/:quote_id/quote handler tests ─────────────── + + /// `DELETE /api/admin/reports/:quote_id/quote` with a valid code deletes + /// the quote and returns `204 No Content`. + #[tokio::test] + async fn test_admin_delete_quote_returns_204() { + let repo = MockRepo::with_admin_code("admin-secret"); + repo.create_quote(quotesdb::CreateQuoteInput { + text: "Delete me".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/quote")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // Verify the quote is gone. + let quotes = repo.quotes.lock().unwrap(); + assert!(quotes.is_empty(), "quote should have been deleted"); + } + + /// `DELETE /api/admin/reports/:quote_id/quote` for a nonexistent quote + /// returns `404`. + #[tokio::test] + async fn test_admin_delete_quote_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/nonexistent/quote") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `DELETE /api/admin/reports/:quote_id/quote` with no admin code returns + /// `403`. + #[tokio::test] + async fn test_admin_delete_quote_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/any-id/quote") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── POST /api/admin/reports/:quote_id/hide handler tests ────────────────── + + /// `POST /api/admin/reports/:quote_id/hide` with a valid code sets the + /// quote hidden and returns `200` with `{"hidden": true}`. + #[tokio::test] + async fn test_hide_quote_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + repo.create_quote(quotesdb::CreateQuoteInput { + text: "Hide me".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::POST) + .uri(format!("/api/admin/reports/{quote_id}/hide")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["hidden"], true); + + // Verify the quote is now hidden in the mock. + let quotes = repo.quotes.lock().unwrap(); + assert!(quotes[0].0.hidden, "quote should be marked hidden"); + } + + /// `POST /api/admin/reports/:quote_id/hide` for a nonexistent quote + /// returns `404`. + #[tokio::test] + async fn test_hide_quote_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reports/nonexistent/hide") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `POST /api/admin/reports/:quote_id/hide` with no admin code returns + /// `403`. + #[tokio::test] + async fn test_hide_quote_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reports/any-id/hide") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── DELETE /api/admin/reports/:quote_id/reports handler tests ───────────── + + /// `DELETE /api/admin/reports/:quote_id/reports` with a valid code clears + /// all reports and returns `204 No Content`. + #[tokio::test] + async fn test_clear_reports_returns_204() { + let repo = MockRepo::with_admin_code("admin-secret"); + repo.create_quote(quotesdb::CreateQuoteInput { + text: "Reported".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/reports")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + } + + /// `DELETE /api/admin/reports/:quote_id/reports` for a nonexistent quote + /// returns `404`. + #[tokio::test] + async fn test_clear_reports_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/nonexistent/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `DELETE /api/admin/reports/:quote_id/reports` with no admin code returns + /// `403`. + #[tokio::test] + async fn test_clear_reports_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/any-id/reports") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } } // ── Integration tests (real NativeRepository + real SQLite) ───────────────── @@ -2378,4 +2922,235 @@ mod integration_tests { .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } + + // ── Admin moderation endpoint integration tests (ticket 6c5904) ─────────── + + /// Build a `test_router` and seed a known admin auth code so integration + /// tests can authenticate without reading the printed code. + async fn test_router_with_admin(admin_code: &str) -> (Router, NamedTempFile) { + let f = NamedTempFile::new().expect("failed to create temp db file"); + let repo = connection::open(f.path().to_str().expect("non-utf8 temp path")) + .await + .expect("failed to open test database"); + repo.run_migrations().await.expect("migrations failed"); + repo.seed_admin_auth_code(admin_code) + .await + .expect("failed to seed admin code"); + repo.seed_submissions_locked() + .await + .expect("failed to seed submissions lock"); + let repo: Arc = Arc::new(repo); + (router(repo), f) + } + + /// Submit a report for a quote via `POST /api/quotes/:id/report`. + async fn report_quote(app: Router, quote_id: &str) -> Router { + let req = Request::builder() + .method(Method::POST) + .uri(format!("/api/quotes/{quote_id}/report")) + .header("Content-Type", "application/json") + .body(Body::from(r#"{"reason":"spam"}"#)) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::CREATED, + "report_quote: unexpected status" + ); + app + } + + /// `GET /api/admin/reports` returns `200` with an empty list when there + /// are no reported quotes. + #[tokio::test] + async fn integration_list_reports_empty_returns_200() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["total_count"], 0); + assert_eq!(v["reports"].as_array().unwrap().len(), 0); + } + + /// `GET /api/admin/reports` returns a summary entry after a report has + /// been submitted for a quote. + #[tokio::test] + async fn integration_list_reports_with_report_returns_entry() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Reported quote", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["total_count"], 1); + let entry = &v["reports"][0]; + assert_eq!(entry["quote_id"], quote_id.as_str()); + assert_eq!(entry["report_count"], 1); + } + + /// `GET /api/admin/reports/:quote_id` returns `200` with the full quote + /// and all report rows. + #[tokio::test] + async fn integration_get_quote_reports_returns_full_detail() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Flagged", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + let req = Request::builder() + .method(Method::GET) + .uri(format!("/api/admin/reports/{quote_id}")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["quote"]["id"], quote_id.as_str()); + assert_eq!(v["reports"].as_array().unwrap().len(), 1); + assert_eq!(v["reports"][0]["reason"], "spam"); + } + + /// `DELETE /api/admin/reports/:quote_id/quote` deletes the quote; a + /// subsequent `GET /api/quotes/:id` returns `404`. + #[tokio::test] + async fn integration_admin_delete_quote_removes_quote() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "To delete", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + // Delete via admin endpoint. + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/quote")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Confirm the quote is gone. + let req2 = Request::builder() + .method(Method::GET) + .uri(format!("/api/quotes/{quote_id}")) + .body(Body::empty()) + .unwrap(); + let resp2 = ServiceExt::>::oneshot(app, req2) + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::NOT_FOUND); + } + + /// `POST /api/admin/reports/:quote_id/hide` sets `hidden = true`; the + /// `hidden` field on the quote is `true` when fetched via GET afterward. + #[tokio::test] + async fn integration_hide_quote_sets_hidden_flag() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Hide me", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + // Hide the quote. + let req = Request::builder() + .method(Method::POST) + .uri(format!("/api/admin/reports/{quote_id}/hide")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["hidden"], true); + + // Verify via GET. + let req2 = Request::builder() + .method(Method::GET) + .uri(format!("/api/quotes/{quote_id}")) + .body(Body::empty()) + .unwrap(); + let resp2 = ServiceExt::>::oneshot(app, req2) + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::OK); + let v2 = body_json(resp2).await; + assert_eq!(v2["hidden"], true); + } + + /// `DELETE /api/admin/reports/:quote_id/reports` clears reports; the list + /// is empty afterward. + #[tokio::test] + async fn integration_clear_reports_empties_report_list() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Spammy", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + // Confirm there is one report before clearing. + let check_req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let check_resp = ServiceExt::>::oneshot(app.clone(), check_req) + .await + .unwrap(); + let v = body_json(check_resp).await; + assert_eq!( + v["total_count"], 1, + "should have one report before clearing" + ); + + // Clear reports. + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/reports")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Confirm no reports remain. + let check2_req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let check2_resp = ServiceExt::>::oneshot(app, check2_req) + .await + .unwrap(); + let v2 = body_json(check2_resp).await; + assert_eq!(v2["total_count"], 0, "all reports should be cleared"); + } }