feat(quotesdb): add admin moderation endpoints

Implements ticket 6c5904 — five admin-authenticated endpoints for
moderation workflows:

  GET  /api/admin/reports              paginated list of reported quotes
  GET  /api/admin/reports/:id          full quote + all report rows
  DELETE /api/admin/reports/:id/quote  unconditionally delete a quote
  POST /api/admin/reports/:id/hide     set hidden=1 on a quote
  DELETE /api/admin/reports/:id/reports clear all reports for a quote

All endpoints require X-Admin-Code header; 403 on missing/wrong code.

DB layer additions:
- QuoteRepository trait gains list_reports, get_reports_for_quote,
  admin_delete_quote, hide_quote, and clear_reports methods
- New ReportRow, ReportSummary, ReportListResult, and QuoteReports
  types added to db/mod.rs
- Implementations in native.rs (rusqlite) and d1.rs (Cloudflare D1)

Tests added:
- 14 unit handler tests using MockRepo (3 per endpoint covering
  success, 404, and 403 cases)
- 5 integration tests using real SQLite via NativeRepository
- 10 DB-layer unit tests in native.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent 8a538b1d28
commit 88f14b00f8

@ -717,4 +717,226 @@ impl QuoteRepository for D1Repository {
.map(|_| ()) .map(|_| ())
.map_err(|e| DbError::Internal(e.to_string())) .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<super::ReportListResult, DbError> {
#[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::<TotalRow>(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::<SummaryRow>()
.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<super::QuoteReports, DbError> {
#[derive(serde::Deserialize)]
struct ReportRowRaw {
id: String,
reason: Option<String>,
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::<QuoteRow>(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::<ReportRowRaw>()
.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::<CountRow>(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::<CountRow>(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::<CountRow>(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()))
}
} }

@ -42,6 +42,58 @@ pub struct ListResult {
pub total_count: u32, 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<String>,
/// 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<ReportSummary>,
/// 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<ReportRow>,
}
/// Outcome of a delete operation. /// Outcome of a delete operation.
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum DeleteResult { pub enum DeleteResult {
@ -189,4 +241,35 @@ pub trait QuoteRepository {
/// Returns `Err(DbError::NotFound)` if `quote_id` does not exist in the /// Returns `Err(DbError::NotFound)` if `quote_id` does not exist in the
/// `quotes` table. /// `quotes` table.
async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError>; 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<ReportListResult, DbError>;
/// 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<QuoteReports, DbError>;
/// 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>;
} }

@ -649,6 +649,205 @@ impl QuoteRepository for NativeRepository {
.await .await
.map_err(|e| DbError::Internal(e.to_string())) .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<super::ReportListResult, DbError> {
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::<Result<Vec<_>, _>>()?;
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<super::QuoteReports, DbError> {
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, &quote.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::<Result<Vec<_>, _>>()?;
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)] #[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(&quote.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(&quote.id, Some("reason one"))
.await
.unwrap();
repo.create_report(&quote.id, None).await.unwrap();
let qr = repo.get_reports_for_quote(&quote.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(&quote.id).await.unwrap();
let fetched = repo.get_quote(&quote.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(&quote.id).await.unwrap();
let fetched = repo.get_quote(&quote.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(&quote.id, Some("spam")).await.unwrap();
repo.create_report(&quote.id, None).await.unwrap();
repo.clear_reports(&quote.id).await.unwrap();
// The quote should still exist.
let fetched = repo.get_quote(&quote.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(&quote.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. /// `get_quote` (direct ID lookup) must return the quote even when it is hidden.
#[tokio::test] #[tokio::test]
async fn test_get_quote_returns_hidden_quote() { async fn test_get_quote_returns_hidden_quote() {

@ -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<Repo>,
headers: HeaderMap,
Query(params): Query<AdminReportsParams>,
) -> 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<Repo>,
Path(quote_id): Path<String>,
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(&quote_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<Repo>,
Path(quote_id): Path<String>,
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(&quote_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<Repo>,
Path(quote_id): Path<String>,
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(&quote_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<Repo>,
Path(quote_id): Path<String>,
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(&quote_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 ──────────────────────────────────────────────────────────────────── // ── Router ────────────────────────────────────────────────────────────────────
/// Build the Axum [`Router`] with all API routes wired to their handlers. /// Build the Axum [`Router`] with all API routes wired to their handlers.
@ -604,6 +755,24 @@ pub fn router(repo: Arc<dyn QuoteRepository + Send + Sync>) -> Router {
.route("/api/admin/lock", post(lock_submissions)) .route("/api/admin/lock", post(lock_submissions))
.route("/api/admin/unlock", post(unlock_submissions)) .route("/api/admin/unlock", post(unlock_submissions))
.route("/api/admin/reset-auth-code", post(reset_auth_code)) .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} // IMPORTANT: /random and /{id}/report must be registered before /{id}
// so static segments win over the dynamic capture. // so static segments win over the dynamic capture.
.route("/api/quotes/random", get(random_handler)) .route("/api/quotes/random", get(random_handler))
@ -843,6 +1012,62 @@ mod tests {
Err(DbError::NotFound) Err(DbError::NotFound)
} }
} }
async fn list_reports(&self, page: u32) -> Result<crate::db::ReportListResult, DbError> {
Ok(crate::db::ReportListResult {
reports: vec![],
page,
total_pages: 0,
total_count: 0,
})
}
async fn get_reports_for_quote(
&self,
quote_id: &str,
) -> Result<crate::db::QuoteReports, DbError> {
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 { fn sample_quote() -> Quote {
@ -1526,6 +1751,325 @@ mod tests {
"response must include auth_code after second reset" "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) ───────────────── // ── Integration tests (real NativeRepository + real SQLite) ─────────────────
@ -2378,4 +2922,235 @@ mod integration_tests {
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 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<dyn QuoteRepository + Send + Sync> = 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::<Request<Body>>::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::<Request<Body>>::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, &quote_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::<Request<Body>>::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, &quote_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::<Request<Body>>::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, &quote_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::<Request<Body>>::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::<Request<Body>>::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, &quote_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::<Request<Body>>::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::<Request<Body>>::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, &quote_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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::oneshot(app, check2_req)
.await
.unwrap();
let v2 = body_json(check2_resp).await;
assert_eq!(v2["total_count"], 0, "all reports should be cleared");
}
} }

Loading…
Cancel
Save