//! HTTP request handlers for the `quotesdb` API. //! //! Each handler maps to one route in the API specification. The [`router`] //! function assembles the Axum [`Router`] with all routes in the required //! order — in particular, `GET /api/quotes/random` is registered **before** //! `GET /api/quotes/:id` to prevent "random" being captured as an id. //! //! All handlers share a [`crate::db::QuoteRepository`] via Axum's state //! mechanism, wrapped in an [`Arc`] to allow cheap cloning across tasks. use std::sync::Arc; use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Json, Response}, routing::{delete, get, post, put}, Router, }; use serde::{Deserialize, Serialize}; use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; use crate::db::{DeleteResult, QuoteRepository}; // ── Shared application state ────────────────────────────────────────────────── /// Type alias for the shared repository handle. /// /// `Send + Sync` are required by Axum's native router so the state can be /// shared across Tokio tasks. `NativeRepository` satisfies both bounds. type Repo = Arc; // ── Error response helpers ───────────────────────────────────────────────────── /// JSON envelope for all API error responses. /// /// Serialised as `{"error": "..."}` with the appropriate HTTP status code. #[derive(Debug, Serialize)] struct ErrorBody { error: String, } /// Build a JSON error response with the given status code and message. fn error_response(status: StatusCode, msg: impl Into) -> Response { (status, Json(ErrorBody { error: msg.into() })).into_response() } /// Map a [`crate::db::DbError`] to an appropriate HTTP error response. fn db_error_response(err: crate::db::DbError) -> Response { use crate::db::DbError; match err { DbError::NotFound => error_response(StatusCode::NOT_FOUND, "not found"), DbError::Forbidden => error_response(StatusCode::FORBIDDEN, "forbidden"), DbError::Internal(msg) => error_response(StatusCode::INTERNAL_SERVER_ERROR, msg), } } // ── Response types ──────────────────────────────────────────────────────────── /// Response body returned by the create (PUT) endpoint. /// /// Includes the full [`Quote`] plus the `auth_code` string (only time it is /// sent to the client). #[derive(Debug, Serialize)] struct CreateResponse { /// The created quote (without auth_code in the embedded struct). quote: Quote, /// The auth code for future update/delete operations. Store it. auth_code: String, } // ── Query parameter structs ──────────────────────────────────────────────────── /// Query parameters for `GET /api/quotes`. #[derive(Debug, Deserialize)] struct ListParams { /// 1-based page number. Defaults to 1. #[serde(default = "default_page")] page: u32, /// Filter by author name (case-insensitive). author: Option, /// Filter by tag. tag: Option, /// Only include quotes dated on or after this year. date_after_year: Option, /// Narrows after-bound to this month (1–12). Requires `date_after_year`. date_after_month: Option, /// Narrows after-bound to this day (1–31). Requires `date_after_year` and `date_after_month`. date_after_day: Option, /// Only include quotes dated on or before this year. date_before_year: Option, /// Narrows before-bound to this month (1–12). Requires `date_before_year`. date_before_month: Option, /// Narrows before-bound to this day (1–31). Requires `date_before_year` and `date_before_month`. date_before_day: Option, } fn default_page() -> u32 { 1 } /// Build an ISO date prefix string from optional year/month/day components. /// /// Returns `None` if no year is given. For before-bounds, missing month /// defaults to `12` and missing day defaults to `31` so the bound is /// inclusive of the entire specified year/month. /// /// # Examples /// /// ```ignore /// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string())); /// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string())); /// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string())); /// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string())); /// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None); /// ``` fn build_date_bound( year: Option, month: Option, day: Option, is_before: bool, ) -> Option { match (year, month, day) { (None, _, _) => None, (Some(y), None, _) => { if is_before { Some(format!("{y:04}-12-31")) } else { Some(format!("{y:04}")) } } (Some(y), Some(m), None) => { if is_before { Some(format!("{y:04}-{m:02}-31")) } else { Some(format!("{y:04}-{m:02}")) } } (Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")), } } // ── Handlers ────────────────────────────────────────────────────────────────── /// `GET /api/` — return the OpenAPI specification as JSON. /// /// The spec is embedded at compile time from `api/openapi.yaml` (converted to /// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw /// spec string. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn openapi_handler() -> Response { const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json")); ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], OPENAPI_JSON, ) .into_response() } /// `GET /api/status` — return whether quote submissions are currently locked. /// /// This endpoint requires no authentication and is intended to be called by /// the UI on mount for both the `/submit` and `/admin` pages. Returns a JSON /// object with a single boolean field: /// /// ```json /// { "submissions_locked": false } /// ``` /// /// Returns `500 Internal Server Error` if the database query fails. #[cfg_attr(target_arch = "wasm32", worker::send)] pub async fn get_status(State(repo): State) -> Response { match repo.get_submissions_locked().await { Ok(locked) => Json(serde_json::json!({ "submissions_locked": locked })).into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } /// `GET /api/quotes` — list quotes with optional filtering and pagination. /// /// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and /// month/day variants) query parameters. Defaults to page 1 and no filters. /// Returns [`crate::db::ListResult`] serialised as JSON. /// /// Returns `400 Bad Request` when date component ordering is violated (e.g. /// `date_after_month` provided without `date_after_year`). #[cfg_attr(target_arch = "wasm32", worker::send)] async fn list_handler(State(repo): State, Query(params): Query) -> Response { // Validate: month requires year, day requires year+month if params.date_after_month.is_some() && params.date_after_year.is_none() { return error_response( StatusCode::BAD_REQUEST, "date_after_month requires date_after_year", ); } if params.date_after_day.is_some() && (params.date_after_year.is_none() || params.date_after_month.is_none()) { return error_response( StatusCode::BAD_REQUEST, "date_after_day requires date_after_year and date_after_month", ); } if params.date_before_month.is_some() && params.date_before_year.is_none() { return error_response( StatusCode::BAD_REQUEST, "date_before_month requires date_before_year", ); } if params.date_before_day.is_some() && (params.date_before_year.is_none() || params.date_before_month.is_none()) { return error_response( StatusCode::BAD_REQUEST, "date_before_day requires date_before_year and date_before_month", ); } let date_after = build_date_bound( params.date_after_year, params.date_after_month, params.date_after_day, false, ); let date_before = build_date_bound( params.date_before_year, params.date_before_month, params.date_before_day, true, ); match repo .list_quotes( params.page, params.author.as_deref(), params.tag.as_deref(), date_after.as_deref(), date_before.as_deref(), ) .await { Ok(result) => (StatusCode::OK, Json(result)).into_response(), Err(e) => db_error_response(e), } } /// `GET /api/quotes/random` — return a random quote. /// /// Returns `404` when the database is empty. /// /// **Registration order:** this route must be registered before /// `GET /api/quotes/:id` in the router to avoid "random" being matched as an /// id parameter. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn random_handler(State(repo): State) -> Response { match repo.get_random_quote().await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "no quotes in database"), Err(e) => db_error_response(e), } } /// `GET /api/quotes/:id` — retrieve a single quote by NanoID. /// /// Returns `404` when no quote has the given id. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn get_quote_handler(State(repo): State, Path(id): Path) -> Response { match repo.get_quote(&id).await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "quote not found"), Err(e) => db_error_response(e), } } /// Verify a Cloudflare Turnstile token against the siteverify API. /// /// Returns `true` if the token is valid, `false` otherwise. /// Failures are treated conservatively as invalid (returns `false`). #[cfg(not(target_arch = "wasm32"))] async fn verify_turnstile(token: &str, secret: &str) -> bool { #[derive(serde::Deserialize)] struct TurnstileResponse { success: bool, } let params = [("secret", secret), ("response", token)]; let Ok(client) = reqwest::Client::builder().build() else { return false; }; let Ok(resp) = client .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") .form(¶ms) .send() .await else { return false; }; let Ok(body) = resp.json::().await else { return false; }; body.success } /// `PUT /api/quotes` — create a new quote. /// /// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created` /// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only /// time it is returned — the client must store it. /// /// Returns `423 Locked` with `{"error": "submissions are closed"}` when the /// admin has locked new submissions via `POST /api/admin/lock`. /// /// When the `TURNSTILE_SECRET_KEY` environment variable is set, a valid /// Cloudflare Turnstile token must be provided in the `cf_turnstile_token` /// field. This check is skipped on wasm32 targets (Workers runtime). #[cfg_attr(target_arch = "wasm32", worker::send)] async fn create_handler(State(repo): State, Json(input): Json) -> Response { // Pre-flight: reject new submissions when locked. match repo.get_submissions_locked().await { Ok(true) => { return ( StatusCode::LOCKED, Json(serde_json::json!({ "error": "submissions are closed" })), ) .into_response(); } Ok(false) => {} Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), } // Verify Cloudflare Turnstile token (native builds only; skipped on wasm32). #[cfg(not(target_arch = "wasm32"))] { if let Ok(secret) = std::env::var("TURNSTILE_SECRET_KEY") { let token = match input.cf_turnstile_token.as_deref() { Some(t) if !t.is_empty() => t.to_owned(), _ => { return error_response(StatusCode::BAD_REQUEST, "CAPTCHA token required") .into_response() } }; let verified = verify_turnstile(&token, &secret).await; if !verified { return error_response(StatusCode::FORBIDDEN, "CAPTCHA verification failed") .into_response(); } } } match repo.create_quote(input).await { Ok((quote, auth_code)) => ( StatusCode::CREATED, Json(CreateResponse { quote, auth_code }), ) .into_response(), Err(e) => db_error_response(e), } } /// Extract the `X-Auth-Code` header value from the request headers. /// /// Returns `None` if the header is absent or cannot be decoded as UTF-8. fn extract_auth_code(headers: &HeaderMap) -> Option { headers .get("X-Auth-Code") .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned()) } /// Extract the `X-Admin-Code` header value from the request headers. /// /// Returns `None` if the header is absent or cannot be decoded as UTF-8. fn extract_admin_code(headers: &HeaderMap) -> Option { headers .get("X-Admin-Code") .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned()) } /// Verify that the supplied admin code matches the one stored in the repository. /// /// Fetches the current admin code via [`QuoteRepository::get_admin_auth_code`] /// and compares it with the supplied code using standard string equality. /// Returns `true` if the codes match, `false` if the code is wrong, missing, /// or the database query fails. async fn verify_admin_code(repo: &Repo, code: &str) -> bool { match repo.get_admin_auth_code().await { Ok(Some(stored)) => stored == code, _ => false, } } /// `POST /api/quotes/:id` — update an existing quote. /// /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// `404` if the quote does not exist, or `200` with the updated quote. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn update_handler( State(repo): State, Path(id): Path, headers: HeaderMap, Json(input): Json, ) -> Response { let Some(auth_code) = extract_auth_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required"); }; match repo.update_quote(&id, input, &auth_code).await { Ok(quote) => (StatusCode::OK, Json(quote)).into_response(), Err(e) => db_error_response(e), } } /// `DELETE /api/quotes/:id` — delete a quote. /// /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// `404` if not found, or `204 No Content` on success. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn delete_handler( State(repo): State, Path(id): Path, headers: HeaderMap, ) -> Response { let Some(auth_code) = extract_auth_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required"); }; match repo.delete_quote(&id, &auth_code).await { Ok(DeleteResult::Deleted) => StatusCode::NO_CONTENT.into_response(), Ok(DeleteResult::NotFound) => error_response(StatusCode::NOT_FOUND, "quote not found"), Ok(DeleteResult::Forbidden) => error_response(StatusCode::FORBIDDEN, "forbidden"), Err(e) => db_error_response(e), } } /// `POST /api/admin/lock` — lock new quote submissions. /// /// Requires the `X-Admin-Code` header. Sets `submissions_locked = true` in /// the repository and returns the updated lock state as JSON: /// /// ```json /// { "submissions_locked": true } /// ``` /// /// Returns `403 Forbidden` if the header is missing or the code is incorrect. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn lock_submissions(State(repo): State, 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.set_submissions_locked(true).await { Ok(()) => Json(serde_json::json!({ "submissions_locked": true })).into_response(), Err(e) => db_error_response(e), } } /// `POST /api/admin/unlock` — unlock new quote submissions. /// /// Requires the `X-Admin-Code` header. Sets `submissions_locked = false` in /// the repository and returns the updated lock state as JSON: /// /// ```json /// { "submissions_locked": false } /// ``` /// /// Returns `403 Forbidden` if the header is missing or the code is incorrect. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn unlock_submissions(State(repo): State, 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.set_submissions_locked(false).await { Ok(()) => Json(serde_json::json!({ "submissions_locked": false })).into_response(), Err(e) => db_error_response(e), } } /// Request body for `POST /api/admin/reset-auth-code`. #[derive(Debug, Deserialize)] struct ResetAuthCodeRequest { /// New admin auth code. If omitted, the server generates a fresh 4-word passphrase. new_code: Option, } /// Response body returned by `POST /api/admin/reset-auth-code`. #[derive(Debug, Serialize)] struct ResetAuthCodeResponse { /// The new admin auth code that is now in effect. auth_code: String, } /// `POST /api/admin/reset-auth-code` — replace the stored admin auth code. /// /// Requires the `X-Admin-Code` header containing the **current** admin /// passphrase. If the header matches the stored code, the code is replaced /// with either the supplied `new_code` value or a freshly generated 4-word /// passphrase when `new_code` is omitted. /// /// The new code is returned in the response body: /// /// ```json /// { "auth_code": "word-word-word-word" } /// ``` /// /// Returns `403 Forbidden` in two cases: /// - Missing `X-Admin-Code` header — the handler returns `403` immediately, /// before any database call. /// - Wrong code — the DB layer (`update_admin_auth_code`) returns /// `DbError::Forbidden` when the supplied code does not match the stored /// value, which the handler maps to `403`. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn reset_auth_code( State(repo): State, headers: HeaderMap, Json(payload): Json, ) -> Response { let admin_code = match extract_admin_code(&headers) { Some(c) => c, None => return StatusCode::FORBIDDEN.into_response(), }; match repo .update_admin_auth_code(&admin_code, payload.new_code.as_deref()) .await { Ok(new_code) => Json(ResetAuthCodeResponse { auth_code: new_code, }) .into_response(), Err(crate::db::DbError::Forbidden) => StatusCode::FORBIDDEN.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } /// Request body for `POST /api/quotes/:id/report`. /// /// All fields are optional — a report can be submitted without a reason. #[derive(Debug, Deserialize)] struct ReportInput { /// Optional human-readable reason for the report. At most 256 characters. reason: Option, } /// `POST /api/quotes/:id/report` — submit a moderation report for a quote. /// /// The request body is a JSON object with an optional `reason` field. The body /// itself is also optional — omitting it entirely (or sending `{}`) is valid. /// /// Returns `201 Created` on success, `400 Bad Request` if the reason exceeds /// 256 characters, `404 Not Found` if no quote with the given ID exists, or /// `500 Internal Server Error` on a database failure. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn report_handler( State(repo): State, Path(id): Path, body: Option>, ) -> Response { let reason = body.and_then(|Json(input)| input.reason); // Validate reason length — enforced here before the DB call. if reason.as_deref().map(|r| r.chars().count()).unwrap_or(0) > 256 { return error_response( StatusCode::BAD_REQUEST, "reason must be at most 256 characters", ); } match repo.create_report(&id, reason.as_deref()).await { Ok(()) => StatusCode::CREATED.into_response(), Err(crate::db::DbError::NotFound) => { error_response(StatusCode::NOT_FOUND, "quote not found") } Err(e) => db_error_response(e), } } /// 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. /// /// Route registration order is important: `GET /api/quotes/random` must /// appear before `GET /api/quotes/:id` so Axum's static segment wins over /// the dynamic `:id` capture. /// /// The repository must implement `Send + Sync` so it can be shared across /// Tokio tasks by Axum's state mechanism. [`NativeRepository`] satisfies /// both bounds via `tokio_rusqlite::Connection`. /// /// [`NativeRepository`]: crate::db::NativeRepository pub fn router(repo: Arc) -> Router { Router::new() // Meta .route("/api/", get(openapi_handler)) // Public status — exposes whether submissions are currently locked. .route("/api/status", get(get_status)) // Admin endpoints — toggle the global submissions lock and reset auth code. .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)) .route("/api/quotes/{id}/report", post(report_handler)) .route("/api/quotes/{id}", get(get_quote_handler)) .route("/api/quotes", get(list_handler)) .route("/api/quotes", put(create_handler)) .route("/api/quotes/{id}", post(update_handler)) .route("/api/quotes/{id}", delete(delete_handler)) .with_state(repo) } #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{Method, Request}, }; use tower::util::ServiceExt; // for `oneshot` use crate::db::{DbError, DeleteResult, ListResult}; use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; // ── Mock repository for handler tests ───────────────────────────────────── /// A simple mock [`QuoteRepository`] for unit-testing handlers. /// /// Tracks in-memory state for quotes, the admin auth code, and the /// submissions-locked flag so all trait methods can be exercised without a /// real database. struct MockRepo { quotes: std::sync::Mutex>, /// Stored admin super auth code (`None` until seeded). admin_auth_code: std::sync::Mutex>, /// Whether new quote submissions are currently locked. submissions_locked: std::sync::Mutex, } impl MockRepo { fn empty() -> Repo { Arc::new(Self { quotes: std::sync::Mutex::new(vec![]), admin_auth_code: std::sync::Mutex::new(None), submissions_locked: std::sync::Mutex::new(false), }) } fn with_quote(quote: Quote, auth: &str) -> Repo { Arc::new(Self { quotes: std::sync::Mutex::new(vec![(quote, auth.to_owned())]), admin_auth_code: std::sync::Mutex::new(None), submissions_locked: std::sync::Mutex::new(false), }) } /// Build a [`Repo`] pre-seeded with the given admin auth code. fn with_admin_code(code: &str) -> Arc { Arc::new(Self { quotes: std::sync::Mutex::new(vec![]), admin_auth_code: std::sync::Mutex::new(Some(code.to_owned())), submissions_locked: std::sync::Mutex::new(false), }) } /// Build a [`Repo`] with submissions locked to the given state. fn with_submissions_locked(locked: bool) -> Repo { Arc::new(Self { quotes: std::sync::Mutex::new(vec![]), admin_auth_code: std::sync::Mutex::new(None), submissions_locked: std::sync::Mutex::new(locked), }) } } #[async_trait::async_trait] impl QuoteRepository for MockRepo { async fn run_migrations(&self) -> Result<(), DbError> { Ok(()) } async fn list_quotes( &self, page: u32, _author: Option<&str>, _tag: Option<&str>, _date_after: Option<&str>, _date_before: Option<&str>, ) -> Result { let quotes = self.quotes.lock().unwrap(); let all: Vec = quotes.iter().map(|(q, _)| q.clone()).collect(); Ok(ListResult { quotes: all.clone(), page, total_pages: 1, total_count: all.len() as u32, }) } async fn get_quote(&self, id: &str) -> Result, DbError> { let quotes = self.quotes.lock().unwrap(); Ok(quotes .iter() .find(|(q, _)| q.id == id) .map(|(q, _)| q.clone())) } async fn get_random_quote(&self) -> Result, DbError> { let quotes = self.quotes.lock().unwrap(); Ok(quotes.first().map(|(q, _)| q.clone())) } async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> { let auth = input .auth_code .clone() .unwrap_or_else(|| "test-auth".to_owned()); let quote = Quote { id: "test-id".to_owned(), text: input.text, author: input.author, source: input.source, date: input.date, tags: input.tags, hidden: false, created_at: "2024-01-01T00:00:00".to_owned(), updated_at: "2024-01-01T00:00:00".to_owned(), }; self.quotes .lock() .unwrap() .push((quote.clone(), auth.clone())); Ok((quote, auth)) } async fn update_quote( &self, id: &str, input: UpdateQuoteInput, auth_code: &str, ) -> Result { let mut quotes = self.quotes.lock().unwrap(); let entry = quotes.iter_mut().find(|(q, _)| q.id == id); match entry { None => Err(DbError::NotFound), Some((q, stored_auth)) => { if stored_auth.as_str() != auth_code { return Err(DbError::Forbidden); } if let Some(t) = input.text { q.text = t; } if let Some(a) = input.author { q.author = a; } q.source = input.source; q.date = input.date; if let Some(tags) = input.tags { q.tags = tags; } if let Some(h) = input.hidden { q.hidden = h; } Ok(q.clone()) } } } async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { let mut quotes = self.quotes.lock().unwrap(); let pos = quotes.iter().position(|(q, _)| q.id == id); match pos { None => Ok(DeleteResult::NotFound), Some(i) => { let (_, stored) = "es[i]; if stored.as_str() != auth_code { return Ok(DeleteResult::Forbidden); } quotes.remove(i); Ok(DeleteResult::Deleted) } } } async fn get_admin_auth_code(&self) -> Result, DbError> { Ok(self.admin_auth_code.lock().unwrap().clone()) } async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> { let mut guard = self.admin_auth_code.lock().unwrap(); if guard.is_none() { *guard = Some(code.to_owned()); } Ok(()) } async fn update_admin_auth_code( &self, current: &str, new_code: Option<&str>, ) -> Result { let mut guard = self.admin_auth_code.lock().unwrap(); match guard.as_deref() { Some(stored) if stored == current => { let replacement = new_code .map(|s| s.to_owned()) .unwrap_or_else(|| "new-mock-code".to_owned()); *guard = Some(replacement.clone()); Ok(replacement) } _ => Err(DbError::Forbidden), } } async fn get_submissions_locked(&self) -> Result { Ok(*self.submissions_locked.lock().unwrap()) } async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> { *self.submissions_locked.lock().unwrap() = locked; Ok(()) } async fn seed_submissions_locked(&self) -> Result<(), DbError> { Ok(()) } async fn create_report( &self, quote_id: &str, _reason: Option<&str>, ) -> Result<(), DbError> { let quotes = self.quotes.lock().unwrap(); if quotes.iter().any(|(q, _)| q.id == quote_id) { Ok(()) } else { 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 { Quote { id: "abc-123".to_owned(), text: "Sample text".to_owned(), author: "Sample Author".to_owned(), source: None, date: None, tags: vec![], hidden: false, created_at: "2024-01-01T00:00:00".to_owned(), updated_at: "2024-01-01T00:00:00".to_owned(), } } // ── Helper to send requests to the router ────────────────────────────────── async fn send(app: Router, req: Request) -> (StatusCode, String) { let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); let status = resp.status(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); (status, String::from_utf8_lossy(&body).to_string()) } #[tokio::test] async fn test_openapi_endpoint() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::GET) .uri("/api/") .body(Body::empty()) .unwrap(); let (status, body) = send(app, req).await; assert_eq!(status, StatusCode::OK); // Should be valid JSON let _: serde_json::Value = serde_json::from_str(&body).unwrap(); } #[tokio::test] async fn test_list_quotes() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let req = Request::builder() .method(Method::GET) .uri("/api/quotes") .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"], 1); } #[tokio::test] async fn test_random_quote_not_found() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_random_quote_found() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::OK); } #[tokio::test] async fn test_get_quote_not_found() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/nonexistent") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_get_quote_found() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/abc-123") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::OK); } #[tokio::test] async fn test_create_quote() { let app = router(MockRepo::empty()); let body = serde_json::json!({ "text": "New quote", "author": "Author", "tags": [] }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::CREATED); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert!(v["auth_code"].is_string()); assert_eq!(v["quote"]["text"], "New quote"); } /// `PUT /api/quotes` while `submissions_locked = true` returns `423 Locked` /// with `{"error": "submissions are closed"}`. #[tokio::test] async fn test_create_quote_locked_returns_423() { let app = router(MockRepo::with_submissions_locked(true)); let body = serde_json::json!({ "text": "Locked quote", "author": "Author", "tags": [] }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::LOCKED); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert_eq!(v["error"], "submissions are closed"); } /// `PUT /api/quotes` while `submissions_locked = false` returns `201 Created` /// (existing behaviour is unchanged). #[tokio::test] async fn test_create_quote_unlocked_returns_201() { let app = router(MockRepo::with_submissions_locked(false)); let body = serde_json::json!({ "text": "Unlocked quote", "author": "Author", "tags": [] }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::CREATED); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert!(v["auth_code"].is_string()); } /// After unlocking (`submissions_locked = false` after being `true`), /// `PUT /api/quotes` succeeds again with `201 Created`. #[tokio::test] async fn test_create_quote_after_unlock_returns_201() { // Build a repo that starts locked. let repo = MockRepo::with_submissions_locked(true); // Unlock it. repo.set_submissions_locked(false) .await .expect("set_submissions_locked should not fail"); let app = router(repo); let body = serde_json::json!({ "text": "Re-enabled quote", "author": "Author", "tags": [] }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::CREATED); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert!(v["auth_code"].is_string()); assert_eq!(v["quote"]["text"], "Re-enabled quote"); } #[tokio::test] async fn test_update_quote_missing_auth() { let app = router(MockRepo::with_quote(sample_quote(), "correct")); let body = serde_json::json!({"text": "Updated"}); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/abc-123") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_update_quote_wrong_auth() { let app = router(MockRepo::with_quote(sample_quote(), "correct")); let body = serde_json::json!({"text": "Updated"}); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/abc-123") .header("Content-Type", "application/json") .header("X-Auth-Code", "wrong") .body(Body::from(body.to_string())) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_update_quote_success() { let app = router(MockRepo::with_quote(sample_quote(), "correct")); let body = serde_json::json!({"text": "Updated text"}); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/abc-123") .header("Content-Type", "application/json") .header("X-Auth-Code", "correct") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::OK); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert_eq!(v["text"], "Updated text"); } #[tokio::test] async fn test_delete_quote_missing_auth() { let app = router(MockRepo::with_quote(sample_quote(), "correct")); let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/abc-123") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_delete_quote_success() { let app = router(MockRepo::with_quote(sample_quote(), "correct")); let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/abc-123") .header("X-Auth-Code", "correct") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NO_CONTENT); } #[tokio::test] async fn test_delete_quote_not_found() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/nonexistent") .header("X-Auth-Code", "any") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NOT_FOUND); } // ── Admin DB method tests ────────────────────────────────────────────────── /// `get_submissions_locked` returns `false` when the repo has just been /// created (no `set_submissions_locked` call has been made yet). #[tokio::test] async fn test_get_submissions_locked_default_false() { let repo = MockRepo::empty(); let locked = repo .get_submissions_locked() .await .expect("get_submissions_locked should not fail"); assert!(!locked, "submissions should be unlocked by default"); } /// After calling `set_submissions_locked(true)`, `get_submissions_locked` /// must return `true`. #[tokio::test] async fn test_set_and_get_submissions_locked() { let repo = MockRepo::empty(); repo.set_submissions_locked(true) .await .expect("set_submissions_locked should not fail"); let locked = repo .get_submissions_locked() .await .expect("get_submissions_locked should not fail"); assert!( locked, "submissions should be locked after set_submissions_locked(true)" ); } /// `update_admin_auth_code` with the correct current code succeeds and /// returns the new code. #[tokio::test] async fn test_update_admin_auth_code_correct_current_succeeds() { let repo = MockRepo::with_admin_code("old-code"); let new_code = repo .update_admin_auth_code("old-code", Some("brand-new-code")) .await .expect("update_admin_auth_code should succeed when current matches"); assert_eq!(new_code, "brand-new-code"); // The stored code should now be the new one. let stored = repo .get_admin_auth_code() .await .expect("get_admin_auth_code should not fail"); assert_eq!(stored.as_deref(), Some("brand-new-code")); } /// `update_admin_auth_code` with the wrong current code returns /// `Err(DbError::Forbidden)`. #[tokio::test] async fn test_update_admin_auth_code_wrong_current_forbidden() { let repo = MockRepo::with_admin_code("real-code"); let result = repo .update_admin_auth_code("wrong-code", Some("irrelevant")) .await; assert!( matches!(result, Err(DbError::Forbidden)), "expected Forbidden, got {result:?}", ); } // ── GET /api/status handler tests ───────────────────────────────────────── /// `GET /api/status` returns `200` with `{"submissions_locked": false}` when /// the repo's submissions lock is unset (the default `false` state). #[tokio::test] async fn test_get_status_unlocked() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::GET) .uri("/api/status") .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["submissions_locked"], false); } /// `GET /api/status` returns `200` with `{"submissions_locked": true}` after /// the lock has been enabled via `set_submissions_locked(true)`. #[tokio::test] async fn test_get_status_locked() { let repo = MockRepo::empty(); repo.set_submissions_locked(true) .await .expect("set_submissions_locked should not fail"); let app = router(repo); let req = Request::builder() .method(Method::GET) .uri("/api/status") .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["submissions_locked"], true); } /// `get_submissions_locked` returns `false` for a freshly created repo /// (graceful default — no DB row or explicit seed needed). #[tokio::test] async fn test_get_submissions_locked_default_is_false() { let repo = MockRepo::empty(); let locked = repo .get_submissions_locked() .await .expect("get_submissions_locked should not fail"); assert!(!locked, "submissions should default to unlocked"); } // ── POST /api/admin/lock handler tests ──────────────────────────────────── /// `POST /api/admin/lock` with the correct admin code returns `200` and /// `{ "submissions_locked": true }`. #[tokio::test] async fn test_lock_submissions_correct_code_returns_200() { let repo = MockRepo::with_admin_code("admin-secret"); let app = router(repo); let req = Request::builder() .method(Method::POST) .uri("/api/admin/lock") .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["submissions_locked"], true); } /// `POST /api/admin/unlock` with the correct admin code returns `200` and /// `{ "submissions_locked": false }`. #[tokio::test] async fn test_unlock_submissions_correct_code_returns_200() { let repo = MockRepo::with_admin_code("admin-secret"); // Start in locked state. repo.set_submissions_locked(true) .await .expect("set_submissions_locked should not fail"); let app = router(repo); let req = Request::builder() .method(Method::POST) .uri("/api/admin/unlock") .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["submissions_locked"], false); } /// `POST /api/admin/lock` with a wrong admin code returns `403`. #[tokio::test] async fn test_lock_submissions_wrong_code_returns_403() { let repo = MockRepo::with_admin_code("real-secret"); let app = router(repo); let req = Request::builder() .method(Method::POST) .uri("/api/admin/lock") .header("X-Admin-Code", "wrong-code") .body(Body::empty()) .unwrap(); let (status, _body) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } /// `POST /api/admin/unlock` with no `X-Admin-Code` header returns `403`. #[tokio::test] async fn test_unlock_submissions_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/unlock") .body(Body::empty()) .unwrap(); let (status, _body) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } /// Locking when already locked is idempotent — returns `200` with /// `{ "submissions_locked": true }`. #[tokio::test] async fn test_lock_submissions_idempotent() { let repo = MockRepo::with_admin_code("admin-secret"); // Lock once via the trait directly. repo.set_submissions_locked(true) .await .expect("initial lock should not fail"); let app = router(repo); let req = Request::builder() .method(Method::POST) .uri("/api/admin/lock") .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["submissions_locked"], true); } // ── POST /api/admin/reset-auth-code handler tests ───────────────────────── /// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no /// `new_code` in the body returns `200` with a non-empty `auth_code`. /// The MockRepo returns `"new-mock-code"` when `new_code` is `None`. #[tokio::test] async fn test_reset_auth_code_correct_code_no_new_code_returns_200() { let repo = MockRepo::with_admin_code("current-secret"); let app = router(repo); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) .uri("/api/admin/reset-auth-code") .header("X-Admin-Code", "current-secret") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::OK); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert!( v["auth_code"].is_string(), "response must contain auth_code string" ); assert!( !v["auth_code"].as_str().unwrap().is_empty(), "auth_code must be non-empty" ); } /// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and an /// explicit `new_code` returns `200` and `auth_code` equals the supplied value. #[tokio::test] async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200() { let repo = MockRepo::with_admin_code("current-secret"); let app = router(repo); let body = serde_json::json!({ "new_code": "brand-new-passphrase" }); let req = Request::builder() .method(Method::POST) .uri("/api/admin/reset-auth-code") .header("X-Admin-Code", "current-secret") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::OK); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert_eq!( v["auth_code"], "brand-new-passphrase", "auth_code must equal the supplied new_code" ); } /// `POST /api/admin/reset-auth-code` with a wrong `X-Admin-Code` returns `403`. #[tokio::test] async fn test_reset_auth_code_wrong_code_returns_403() { let repo = MockRepo::with_admin_code("real-secret"); let app = router(repo); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) .uri("/api/admin/reset-auth-code") .header("X-Admin-Code", "wrong-secret") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } /// `POST /api/admin/reset-auth-code` with no `X-Admin-Code` header returns `403`. #[tokio::test] async fn test_reset_auth_code_missing_header_returns_403() { let repo = MockRepo::with_admin_code("real-secret"); let app = router(repo); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) .uri("/api/admin/reset-auth-code") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::FORBIDDEN); } // ── POST /api/quotes/:id/report handler tests ────────────────────────────── /// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`. #[tokio::test] async fn test_report_success() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let body = serde_json::json!({ "reason": "inappropriate content" }); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/abc-123/report") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::CREATED); } /// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`. #[tokio::test] async fn test_report_quote_not_found() { let app = router(MockRepo::empty()); let body = serde_json::json!({}); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/unknown/report") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NOT_FOUND); } /// `POST /api/quotes/:id/report` with a reason longer than 256 characters /// returns `400 Bad Request`. #[tokio::test] async fn test_report_reason_too_long() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let long_reason = "x".repeat(257); let body = serde_json::json!({ "reason": long_reason }); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/abc-123/report") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::BAD_REQUEST); } /// After a successful reset, subsequent calls with the old code return `403` /// and with the new code return `200`. #[tokio::test] async fn test_reset_auth_code_old_code_rejected_after_reset() { let repo = MockRepo::with_admin_code("old-secret"); // First reset: change from "old-secret" to "new-secret". let first_body = serde_json::json!({ "new_code": "new-secret" }); let first_req = Request::builder() .method(Method::POST) .uri("/api/admin/reset-auth-code") .header("X-Admin-Code", "old-secret") .header("Content-Type", "application/json") .body(Body::from(first_body.to_string())) .unwrap(); let app = router(Arc::clone(&repo) as Repo); let (status, _) = send(app, first_req).await; assert_eq!(status, StatusCode::OK, "first reset must succeed"); // Second call with old code must now be forbidden. let second_body = serde_json::json!({}); let second_req = Request::builder() .method(Method::POST) .uri("/api/admin/reset-auth-code") .header("X-Admin-Code", "old-secret") .header("Content-Type", "application/json") .body(Body::from(second_body.to_string())) .unwrap(); let app2 = router(Arc::clone(&repo) as Repo); let (status2, _) = send(app2, second_req).await; assert_eq!( status2, StatusCode::FORBIDDEN, "old code must be rejected after reset" ); // Third call with the new code must succeed. let third_body = serde_json::json!({}); let third_req = Request::builder() .method(Method::POST) .uri("/api/admin/reset-auth-code") .header("X-Admin-Code", "new-secret") .header("Content-Type", "application/json") .body(Body::from(third_body.to_string())) .unwrap(); let app3 = router(repo as Repo); let (status3, resp_body3) = send(app3, third_req).await; assert_eq!( status3, StatusCode::OK, "new code must be accepted after reset" ); let v: serde_json::Value = serde_json::from_str(&resp_body3).unwrap(); assert!( v["auth_code"].is_string(), "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) ───────────────── // // These tests spin up the full Axum router backed by a temporary file-based // SQLite database. Each test gets its own database via `NamedTempFile` so // there is no cross-test interference. // // Tickets covered: // 789d0f GET /api/ returns OpenAPI JSON // aa0eab GET /api/quotes/random // f9f448 GET /api/quotes/:id // 4a4c26 PUT /api/quotes (create) // 93f1b6 GET /api/quotes (list + filters + pagination) // fae330 POST /api/quotes/:id (update) // 8c87db DELETE /api/quotes/:id // 893eba Tag operations // e8f5cf Router ordering (/random not matched as :id) #[cfg(test)] mod integration_tests { use super::*; use axum::http::Request; use axum::{body::Body, http::Method}; use serde_json::json; use tempfile::NamedTempFile; use tower::util::ServiceExt; use crate::db::connection; // ── Harness ─────────────────────────────────────────────────────────────── /// Create an Axum router backed by a real, migrated NativeRepository /// stored in a temporary file. Returns both the router and the temp file /// handle (which must be kept alive for the duration of the test). async fn test_router() -> (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"); let repo: Arc = Arc::new(repo); (router(repo), f) } // ── Body helpers ────────────────────────────────────────────────────────── /// Collect the full response body as raw bytes. async fn body_bytes(resp: axum::response::Response) -> Vec { axum::body::to_bytes(resp.into_body(), usize::MAX) .await .expect("failed to read response body") .to_vec() } /// Collect the full response body and parse it as a JSON value. async fn body_json(resp: axum::response::Response) -> serde_json::Value { let bytes = body_bytes(resp).await; serde_json::from_slice(&bytes).expect("response is not valid JSON") } // ── Quote creation helper ───────────────────────────────────────────────── /// Create a quote via PUT /api/quotes and return `(quote_json, auth_code)`. async fn create_quote_raw( app: Router, text: &str, author: &str, tags: &[&str], ) -> (Router, serde_json::Value, String) { let payload = json!({ "text": text, "author": author, "tags": tags, }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!( resp.status(), StatusCode::CREATED, "create_quote_raw: unexpected status" ); let v = body_json(resp).await; let auth_code = v["auth_code"].as_str().unwrap().to_owned(); let quote = v["quote"].clone(); (app, quote, auth_code) } // ── Ticket 789d0f: GET /api/ returns OpenAPI JSON ───────────────────────── /// GET /api/ must respond 200 with a JSON body containing the keys /// `openapi`, `info`, and `paths` required by the OpenAPI 3.x spec. #[tokio::test] async fn integration_openapi_spec_is_valid_json() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/") .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!(v.get("openapi").is_some(), "missing 'openapi' key"); assert!(v.get("info").is_some(), "missing 'info' key"); assert!(v.get("paths").is_some(), "missing 'paths' key"); } // ── Ticket aa0eab: GET /api/quotes/random ───────────────────────────────── /// Random endpoint returns 404 when the database contains no quotes. #[tokio::test] async fn integration_random_empty_db_returns_404() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// Random endpoint returns 200 with a quote when the database has data. #[tokio::test] async fn integration_random_with_data_returns_200() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Cogito ergo sum", "Descartes", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .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!(v.get("id").is_some(), "random quote must have id"); assert!(v.get("text").is_some(), "random quote must have text"); assert!(v.get("author").is_some(), "random quote must have author"); } // ── Ticket f9f448: GET /api/quotes/:id ──────────────────────────────────── /// GET /api/quotes/:id returns 404 for an ID that does not exist. #[tokio::test] async fn integration_get_quote_not_found() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/does-not-exist-at-all") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// GET /api/quotes/:id returns 200 with the full quote schema. #[tokio::test] async fn integration_get_quote_returns_correct_schema() { let (app, _f) = test_router().await; let (app, created, _auth) = create_quote_raw(app, "To be or not to be", "Shakespeare", &["classic"]).await; let id = created["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // All required fields must be present assert_eq!(v["id"], id); assert_eq!(v["text"], "To be or not to be"); assert_eq!(v["author"], "Shakespeare"); assert!(v.get("source").is_some(), "source field must be present"); assert!(v.get("date").is_some(), "date field must be present"); assert!(v.get("tags").is_some(), "tags field must be present"); assert!(v.get("created_at").is_some(), "created_at must be present"); assert!(v.get("updated_at").is_some(), "updated_at must be present"); assert_eq!(v["tags"], json!(["classic"])); } // ── Ticket 4a4c26: PUT /api/quotes ──────────────────────────────────────── /// Create a quote without providing auth_code; the server auto-generates /// a 4-word passphrase. #[tokio::test] async fn integration_create_quote_auto_auth_code() { let (app, _f) = test_router().await; let payload = json!({ "text": "Hello", "author": "World" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; let auth = v["auth_code"].as_str().expect("auth_code must be a string"); // Auto-generated codes have the pattern word-word-word-word let parts: Vec<&str> = auth.split('-').collect(); assert_eq!(parts.len(), 4, "auto auth_code must be 4 words: {auth}"); assert!(v["quote"]["id"].is_string(), "quote.id must be present"); } /// Create a quote with a custom auth_code; it must be echoed back. #[tokio::test] async fn integration_create_quote_custom_auth_code() { let (app, _f) = test_router().await; let payload = json!({ "text": "Custom auth", "author": "Tester", "auth_code": "my-custom-passphrase-code" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; assert_eq!(v["auth_code"], "my-custom-passphrase-code"); } /// PUT /api/quotes with missing required fields returns 422. #[tokio::test] async fn integration_create_quote_missing_required_fields() { let (app, _f) = test_router().await; // Missing both `text` and `author` let payload = json!({ "source": "somewhere" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } // ── Ticket 93f1b6: GET /api/quotes ──────────────────────────────────────── /// Page 1 returns at most 10 quotes even when more exist. #[tokio::test] async fn integration_list_quotes_pagination_page1() { let (app, _f) = test_router().await; // Insert 12 quotes let mut current_app = app; for i in 0..12 { let (next_app, _, _) = create_quote_raw(current_app, &format!("Quote {i}"), "Paginator", &[]).await; current_app = next_app; } let req = Request::builder() .method(Method::GET) .uri("/api/quotes?page=1") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(current_app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["quotes"].as_array().unwrap().len(), 10); assert_eq!(v["total_count"], 12); assert_eq!(v["total_pages"], 2); } /// A page beyond the last page returns an empty list (not an error). #[tokio::test] async fn integration_list_quotes_page_beyond_results() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Only one", "Solo", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?page=99") .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["quotes"].as_array().unwrap().len(), 0); } /// `?author=` filter is case-insensitive. #[tokio::test] async fn integration_list_quotes_author_filter() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Upper Alice", "Alice", &[]).await; let (app, _, _) = create_quote_raw(app, "Lower alice", "alice", &[]).await; let (app, _, _) = create_quote_raw(app, "By Bob", "Bob", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?author=alice") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // Both "Alice" and "alice" should be returned assert_eq!(v["total_count"], 2); } /// `?tag=` filter returns only quotes that have the specified tag. #[tokio::test] async fn integration_list_quotes_tag_filter() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Tagged quote", "A", &["rust"]).await; let (app, _, _) = create_quote_raw(app, "Untagged quote", "B", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?tag=rust") .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); assert_eq!(v["quotes"][0]["text"], "Tagged quote"); } /// List on an empty database returns an empty quotes array. #[tokio::test] async fn integration_list_quotes_empty_db() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes") .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["quotes"].as_array().unwrap().len(), 0); assert_eq!(v["total_count"], 0); } // ── Ticket fae330: POST /api/quotes/:id ─────────────────────────────────── /// Update succeeds when auth code is correct; updated fields are reflected. #[tokio::test] async fn integration_update_quote_success() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Original text", "Original Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let payload = json!({ "text": "Updated text" }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["text"], "Updated text"); // Author should remain unchanged assert_eq!(v["author"], "Original Author"); } /// Update returns 403 when the wrong auth code is provided. #[tokio::test] async fn integration_update_quote_wrong_auth() { let (app, _f) = test_router().await; let (app, quote, _auth) = create_quote_raw(app, "Protected", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let payload = json!({ "text": "Hacked" }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", "definitely-wrong-code") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } /// Update returns 404 for an ID that does not exist. #[tokio::test] async fn integration_update_quote_not_found() { let (app, _f) = test_router().await; let payload = json!({ "text": "Ghost update" }); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/no-such-id-anywhere") .header("Content-Type", "application/json") .header("X-Auth-Code", "any-code") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// Partial update: only the provided fields change; omitted optional fields /// (tags in this case) remain unchanged. #[tokio::test] async fn integration_update_quote_partial_only_text_changes() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Original", "AuthorName", &["keep-this-tag"]).await; let id = quote["id"].as_str().unwrap().to_owned(); // Only update text; omit tags so they should remain let payload = json!({ "text": "New text", "author": "AuthorName" }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["text"], "New text"); // Tags not provided → tags remain unchanged assert_eq!(v["tags"], json!(["keep-this-tag"])); } /// Setting source to null in the update payload clears the field. #[tokio::test] async fn integration_update_quote_null_source_clears_it() { let (app, _f) = test_router().await; // Create a quote with a source let payload = json!({ "text": "Sourced quote", "author": "Writer", "source": "Some Book" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; let id = v["quote"]["id"].as_str().unwrap().to_owned(); let auth = v["auth_code"].as_str().unwrap().to_owned(); // Now update with source: null to clear it let update = json!({ "source": null }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(update.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert!( v["source"].is_null(), "source should be null after clearing" ); } // ── Ticket 8c87db: DELETE /api/quotes/:id ───────────────────────────────── /// Delete returns 204 No Content when auth code matches. #[tokio::test] async fn integration_delete_quote_success() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Delete me", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/quotes/{id}")) .header("X-Auth-Code", &auth) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } /// Delete returns 403 when auth code is wrong. #[tokio::test] async fn integration_delete_quote_wrong_auth() { let (app, _f) = test_router().await; let (app, quote, _auth) = create_quote_raw(app, "Protected", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/quotes/{id}")) .header("X-Auth-Code", "totally-wrong-code-here") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } /// Delete returns 404 for a non-existent ID. #[tokio::test] async fn integration_delete_quote_not_found() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/ghost-id-not-here") .header("X-Auth-Code", "any") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// After a successful delete, GET /api/quotes/:id returns 404. #[tokio::test] async fn integration_delete_then_get_returns_404() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Ephemeral", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); // Delete let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/quotes/{id}")) .header("X-Auth-Code", &auth) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Now GET should 404 let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // ── Ticket 893eba: Tag operations ───────────────────────────────────────── /// Tags provided on create appear in the GET response. #[tokio::test] async fn integration_tags_on_create_appear_in_get() { let (app, _f) = test_router().await; let (app, quote, _auth) = create_quote_raw(app, "Tagged", "Tagger", &["alpha", "beta", "gamma"]).await; let id = quote["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; let mut tags: Vec<&str> = v["tags"] .as_array() .unwrap() .iter() .map(|t| t.as_str().unwrap()) .collect(); tags.sort_unstable(); assert_eq!(tags, vec!["alpha", "beta", "gamma"]); } /// List quotes filtered by tag returns only quotes with that tag. #[tokio::test] async fn integration_tags_list_filter_by_tag() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Has tag", "A", &["special"]).await; let (app, _, _) = create_quote_raw(app, "No tag", "B", &[]).await; let (app, _, _) = create_quote_raw(app, "Other tag", "C", &["other"]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?tag=special") .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); assert_eq!(v["quotes"][0]["text"], "Has tag"); } /// Updating tags replaces the entire previous tag set. #[tokio::test] async fn integration_tags_update_replaces_all_previous_tags() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Retag me", "Author", &["old1", "old2"]).await; let id = quote["id"].as_str().unwrap().to_owned(); let payload = json!({ "tags": ["new1", "new2", "new3"] }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); // Fetch the quote and verify only new tags are present let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); let v = body_json(resp).await; let mut tags: Vec<&str> = v["tags"] .as_array() .unwrap() .iter() .map(|t| t.as_str().unwrap()) .collect(); tags.sort_unstable(); assert_eq!(tags, vec!["new1", "new2", "new3"]); } // ── Ticket e8f5cf: Router ordering ──────────────────────────────────────── /// GET /api/quotes/random must be dispatched to the random handler, not /// the get-by-id handler. Verified by populating the DB and confirming a /// 200 response (the random handler returns 200; get-by-id for the literal /// string "random" would return 404 since no quote has that ID). #[tokio::test] async fn integration_router_random_not_matched_as_id() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Some quote", "Some Author", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); // If router order were wrong, this would be 404 (no quote with id="random"). // Correct routing gives 200 because the random handler picks a real quote. assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // The random handler returns the full Quote, not a CreateResponse assert!(v.get("id").is_some(), "should be a Quote, not an error"); } // ── Date range filter integration tests ─────────────────────────────────── /// Create a quote with a specific date via PUT /api/quotes. async fn create_quote_with_date( app: Router, text: &str, date: Option<&str>, ) -> (Router, serde_json::Value, String) { let mut payload = json!({ "text": text, "author": "DateAuthor", "tags": [], }); if let Some(d) = date { payload["date"] = json!(d); } let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; let auth_code = v["auth_code"].as_str().unwrap().to_owned(); let quote = v["quote"].clone(); (app, quote, auth_code) } /// `?date_after_year=` filters out quotes dated before that year. #[tokio::test] async fn integration_date_filter_after_year() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await; let (app, _, _) = create_quote_with_date(app, "No date quote", None).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_after_year=2000") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // Only the 2020 quote qualifies; 1990 is before 2000, no-date excluded assert_eq!(v["total_count"], 1); assert_eq!(v["quotes"][0]["text"], "New quote"); } /// `?date_before_year=` filters out quotes dated after that year. #[tokio::test] async fn integration_date_filter_before_year() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await; let (app, _, _) = create_quote_with_date(app, "No date quote", None).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_before_year=2000") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // Only the 1990 quote qualifies; 2020 is after 2000-12-31, no-date excluded assert_eq!(v["total_count"], 1); assert_eq!(v["quotes"][0]["text"], "Old quote"); } /// `?date_after_year=&date_before_year=` combined bounds narrow the window. #[tokio::test] async fn integration_date_filter_range() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; let (app, _, _) = create_quote_with_date(app, "Mid quote", Some("2000-06-15")).await; let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-12-31")).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_after_year=1995&date_before_year=2010") .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); assert_eq!(v["quotes"][0]["text"], "Mid quote"); } /// `?date_after_month=` without a year returns 400 Bad Request. #[tokio::test] async fn integration_date_filter_month_without_year_returns_400() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_after_month=6") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } /// `?date_before_day=` without year+month returns 400 Bad Request. #[tokio::test] async fn integration_date_filter_day_without_year_month_returns_400() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_before_day=15") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .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"); } }