You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

3228 lines
124 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! 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 crate::{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<dyn QuoteRepository + Send + Sync>;
/// Shared application state threaded through all Axum handlers.
///
/// `admin_secret` is `Some` when an `ADMIN_AUTH_CODE` Cloudflare Worker secret
/// is configured. In that case it takes precedence over the DB-stored code and
/// the `reset-auth-code` endpoint is disabled (returning 409).
#[derive(Clone)]
pub struct AppState {
repo: Repo,
/// Optional admin secret injected from the Worker environment.
/// When `Some`, this value is the sole source of truth for admin auth.
admin_secret: Option<String>,
}
// ── 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<String>) -> 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<String>,
/// Filter by tag.
tag: Option<String>,
/// Only include quotes dated on or after this year.
date_after_year: Option<u16>,
/// Narrows after-bound to this month (112). Requires `date_after_year`.
date_after_month: Option<u8>,
/// Narrows after-bound to this day (131). Requires `date_after_year` and `date_after_month`.
date_after_day: Option<u8>,
/// Only include quotes dated on or before this year.
date_before_year: Option<u16>,
/// Narrows before-bound to this month (112). Requires `date_before_year`.
date_before_month: Option<u8>,
/// Narrows before-bound to this day (131). Requires `date_before_year` and `date_before_month`.
date_before_day: Option<u8>,
}
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<u16>,
month: Option<u8>,
day: Option<u8>,
is_before: bool,
) -> Option<String> {
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(state): State<AppState>) -> Response {
match state.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(state): State<AppState>, Query(params): Query<ListParams>) -> Response {
let repo = &state.repo;
// 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(state): State<AppState>) -> Response {
match state.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(state): State<AppState>, Path(id): Path<String>) -> Response {
match state.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(&params)
.send()
.await
else {
return false;
};
let Ok(body) = resp.json::<TurnstileResponse>().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(state): State<AppState>,
Json(input): Json<CreateQuoteInput>,
) -> Response {
// Pre-flight: reject new submissions when locked.
match state.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 state.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<String> {
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<String> {
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 configured authority.
///
/// When `state.admin_secret` is `Some`, the secret is compared directly and
/// the database is never queried. When it is `None`, the stored admin code is
/// fetched via [`QuoteRepository::get_admin_auth_code`] and compared 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(state: &AppState, code: &str) -> bool {
if let Some(secret) = &state.admin_secret {
return secret == code;
}
match state.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(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Json(input): Json<UpdateQuoteInput>,
) -> Response {
let Some(auth_code) = extract_auth_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required");
};
match state.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(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
let Some(auth_code) = extract_auth_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required");
};
match state.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(state): State<AppState>, 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(&state, &code).await {
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
}
match state.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(state): State<AppState>, 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(&state, &code).await {
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
}
match state.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<String>,
}
/// 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(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<ResetAuthCodeRequest>,
) -> Response {
if state.admin_secret.is_some() {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "auth code is managed via ADMIN_AUTH_CODE secret — update it with wrangler"
})),
)
.into_response();
}
let admin_code = match extract_admin_code(&headers) {
Some(c) => c,
None => return StatusCode::FORBIDDEN.into_response(),
};
match state
.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<String>,
}
/// `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(state): State<AppState>,
Path(id): Path<String>,
body: Option<Json<ReportInput>>,
) -> 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 state.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(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<AdminReportsParams>,
) -> Response {
let Some(code) = extract_admin_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
};
if !verify_admin_code(&state, &code).await {
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
}
match state.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(state): State<AppState>,
Path(quote_id): Path<String>,
headers: HeaderMap,
) -> Response {
let Some(code) = extract_admin_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
};
if !verify_admin_code(&state, &code).await {
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
}
match state.repo.get_reports_for_quote(&quote_id).await {
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
Err(crate::db::DbError::NotFound) => {
error_response(StatusCode::NOT_FOUND, "quote not found")
}
Err(e) => db_error_response(e),
}
}
/// `DELETE /api/admin/reports/:quote_id/quote` — delete a quote as admin.
///
/// Deletes the quote unconditionally (no per-quote auth code required).
/// Tags and reports are removed automatically via `ON DELETE CASCADE`.
/// Returns `204 No Content` on success.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
/// exist.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn admin_delete_quote_handler(
State(state): State<AppState>,
Path(quote_id): Path<String>,
headers: HeaderMap,
) -> Response {
let Some(code) = extract_admin_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
};
if !verify_admin_code(&state, &code).await {
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
}
match state.repo.admin_delete_quote(&quote_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(crate::db::DbError::NotFound) => {
error_response(StatusCode::NOT_FOUND, "quote not found")
}
Err(e) => db_error_response(e),
}
}
/// `POST /api/admin/reports/:quote_id/hide` — hide a quote.
///
/// Sets `hidden = 1` on the quote so it is excluded from public listing.
/// Returns `200 OK` with `{"hidden": true}` on success.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
/// exist.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn hide_quote_handler(
State(state): State<AppState>,
Path(quote_id): Path<String>,
headers: HeaderMap,
) -> Response {
let Some(code) = extract_admin_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
};
if !verify_admin_code(&state, &code).await {
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
}
match state.repo.hide_quote(&quote_id).await {
Ok(()) => Json(serde_json::json!({ "hidden": true })).into_response(),
Err(crate::db::DbError::NotFound) => {
error_response(StatusCode::NOT_FOUND, "quote not found")
}
Err(e) => db_error_response(e),
}
}
/// `DELETE /api/admin/reports/:quote_id/reports` — clear all reports for a quote.
///
/// Removes all report rows for the given quote without deleting the quote
/// itself. Returns `204 No Content` on success.
///
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
/// exist.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn clear_reports_handler(
State(state): State<AppState>,
Path(quote_id): Path<String>,
headers: HeaderMap,
) -> Response {
let Some(code) = extract_admin_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
};
if !verify_admin_code(&state, &code).await {
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
}
match state.repo.clear_reports(&quote_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(crate::db::DbError::NotFound) => {
error_response(StatusCode::NOT_FOUND, "quote not found")
}
Err(e) => db_error_response(e),
}
}
// ── Router ────────────────────────────────────────────────────────────────────
/// 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<dyn QuoteRepository + Send + Sync>,
admin_secret: Option<String>,
) -> 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(AppState { repo, admin_secret })
}
#[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 crate::{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<Vec<(Quote, String)>>,
/// Stored admin super auth code (`None` until seeded).
admin_auth_code: std::sync::Mutex<Option<String>>,
/// Whether new quote submissions are currently locked.
submissions_locked: std::sync::Mutex<bool>,
}
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<Self> {
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<ListResult, DbError> {
let quotes = self.quotes.lock().unwrap();
let all: Vec<Quote> = 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<Option<Quote>, 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<Option<Quote>, 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<Quote, DbError> {
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<DeleteResult, DbError> {
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) = &quotes[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<Option<String>, 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<String, DbError> {
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<bool, DbError> {
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<crate::db::ReportListResult, DbError> {
Ok(crate::db::ReportListResult {
reports: vec![],
page,
total_pages: 0,
total_count: 0,
})
}
async fn get_reports_for_quote(
&self,
quote_id: &str,
) -> Result<crate::db::QuoteReports, DbError> {
let quotes = self.quotes.lock().unwrap();
let maybe = quotes.iter().find(|(q, _)| q.id == quote_id);
match maybe {
None => Err(DbError::NotFound),
Some((q, _)) => Ok(crate::db::QuoteReports {
quote: q.clone(),
reports: vec![],
}),
}
}
async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> {
let mut quotes = self.quotes.lock().unwrap();
let pos = quotes.iter().position(|(q, _)| q.id == quote_id);
match pos {
None => Err(DbError::NotFound),
Some(i) => {
quotes.remove(i);
Ok(())
}
}
}
async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> {
let mut quotes = self.quotes.lock().unwrap();
match quotes.iter_mut().find(|(q, _)| q.id == quote_id) {
None => Err(DbError::NotFound),
Some((q, _)) => {
q.hidden = true;
Ok(())
}
}
}
async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> {
let quotes = self.quotes.lock().unwrap();
if quotes.iter().any(|(q, _)| q.id == quote_id) {
Ok(())
} else {
Err(DbError::NotFound)
}
}
}
fn sample_quote() -> Quote {
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<Body>) -> (StatusCode, String) {
let resp = ServiceExt::<Request<Body>>::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(), None);
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"), None);
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(), None);
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"), None);
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(), None);
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"), None);
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(), None);
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), None);
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), None);
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, None);
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"), None);
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"), None);
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"), None);
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"), None);
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"), None);
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(), None);
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(), None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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"), None);
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(), None);
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"), None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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(crate::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, None);
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, None);
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, None);
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(crate::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, None);
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, None);
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, None);
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(crate::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, None);
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, None);
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, None);
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(crate::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, None);
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, None);
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, None);
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);
}
// ── ADMIN_AUTH_CODE secret override tests ──────────────────────────────────
/// When `admin_secret` is `Some`, it takes precedence over the DB-stored code.
#[tokio::test]
async fn test_admin_secret_overrides_db_code() {
// DB has "db-code" but the secret is "secret-code".
let repo = MockRepo::with_admin_code("db-code");
let app = router(repo, Some("secret-code".to_owned()));
let req = Request::builder()
.method(Method::POST)
.uri("/api/admin/lock")
.header("X-Admin-Code", "secret-code")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
}
/// When `admin_secret` is `Some`, `reset-auth-code` returns `409 Conflict`.
#[tokio::test]
async fn test_reset_auth_code_blocked_when_secret_active() {
let repo = MockRepo::with_admin_code("db-code");
let app = router(repo, Some("secret-code".to_owned()));
let req = Request::builder()
.method(Method::POST)
.uri("/api/admin/reset-auth-code")
.header("Content-Type", "application/json")
.header("X-Admin-Code", "secret-code")
.body(Body::from(r#"{"new_code": "anything"}"#))
.unwrap();
let (status, body) = send(app, req).await;
assert_eq!(status, StatusCode::CONFLICT);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(v["error"].as_str().unwrap().contains("ADMIN_AUTH_CODE"));
}
}
// ── 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<dyn QuoteRepository + Send + Sync> = Arc::new(repo);
(router(repo, None), f)
}
// ── Body helpers ──────────────────────────────────────────────────────────
/// Collect the full response body as raw bytes.
async fn body_bytes(resp: axum::response::Response) -> Vec<u8> {
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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["total_count"], 1);
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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["total_count"], 1);
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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::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::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["total_count"], 1);
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::<Request<Body>>::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::<Request<Body>>::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<dyn QuoteRepository + Send + Sync> = Arc::new(repo);
(router(repo, None), f)
}
/// Submit a report for a quote via `POST /api/quotes/:id/report`.
async fn report_quote(app: Router, quote_id: &str) -> Router {
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/quotes/{quote_id}/report"))
.header("Content-Type", "application/json")
.body(Body::from(r#"{"reason":"spam"}"#))
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app.clone(), req)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"report_quote: unexpected status"
);
app
}
/// `GET /api/admin/reports` returns `200` with an empty list when there
/// are no reported quotes.
#[tokio::test]
async fn integration_list_reports_empty_returns_200() {
let (app, _f) = test_router_with_admin("admin-secret").await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/admin/reports")
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["total_count"], 0);
assert_eq!(v["reports"].as_array().unwrap().len(), 0);
}
/// `GET /api/admin/reports` returns a summary entry after a report has
/// been submitted for a quote.
#[tokio::test]
async fn integration_list_reports_with_report_returns_entry() {
let (app, _f) = test_router_with_admin("admin-secret").await;
let (app, quote, _auth) = create_quote_raw(app, "Reported quote", "Author", &[]).await;
let quote_id = quote["id"].as_str().unwrap().to_owned();
let app = report_quote(app, &quote_id).await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/admin/reports")
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["total_count"], 1);
let entry = &v["reports"][0];
assert_eq!(entry["quote_id"], quote_id.as_str());
assert_eq!(entry["report_count"], 1);
}
/// `GET /api/admin/reports/:quote_id` returns `200` with the full quote
/// and all report rows.
#[tokio::test]
async fn integration_get_quote_reports_returns_full_detail() {
let (app, _f) = test_router_with_admin("admin-secret").await;
let (app, quote, _auth) = create_quote_raw(app, "Flagged", "Author", &[]).await;
let quote_id = quote["id"].as_str().unwrap().to_owned();
let app = report_quote(app, &quote_id).await;
let req = Request::builder()
.method(Method::GET)
.uri(format!("/api/admin/reports/{quote_id}"))
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["quote"]["id"], quote_id.as_str());
assert_eq!(v["reports"].as_array().unwrap().len(), 1);
assert_eq!(v["reports"][0]["reason"], "spam");
}
/// `DELETE /api/admin/reports/:quote_id/quote` deletes the quote; a
/// subsequent `GET /api/quotes/:id` returns `404`.
#[tokio::test]
async fn integration_admin_delete_quote_removes_quote() {
let (app, _f) = test_router_with_admin("admin-secret").await;
let (app, quote, _auth) = create_quote_raw(app, "To delete", "Author", &[]).await;
let quote_id = quote["id"].as_str().unwrap().to_owned();
let app = report_quote(app, &quote_id).await;
// Delete via admin endpoint.
let req = Request::builder()
.method(Method::DELETE)
.uri(format!("/api/admin/reports/{quote_id}/quote"))
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app.clone(), req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Confirm the quote is gone.
let req2 = Request::builder()
.method(Method::GET)
.uri(format!("/api/quotes/{quote_id}"))
.body(Body::empty())
.unwrap();
let resp2 = ServiceExt::<Request<Body>>::oneshot(app, req2)
.await
.unwrap();
assert_eq!(resp2.status(), StatusCode::NOT_FOUND);
}
/// `POST /api/admin/reports/:quote_id/hide` sets `hidden = true`; the
/// `hidden` field on the quote is `true` when fetched via GET afterward.
#[tokio::test]
async fn integration_hide_quote_sets_hidden_flag() {
let (app, _f) = test_router_with_admin("admin-secret").await;
let (app, quote, _auth) = create_quote_raw(app, "Hide me", "Author", &[]).await;
let quote_id = quote["id"].as_str().unwrap().to_owned();
let app = report_quote(app, &quote_id).await;
// Hide the quote.
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/admin/reports/{quote_id}/hide"))
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app.clone(), req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["hidden"], true);
// Verify via GET.
let req2 = Request::builder()
.method(Method::GET)
.uri(format!("/api/quotes/{quote_id}"))
.body(Body::empty())
.unwrap();
let resp2 = ServiceExt::<Request<Body>>::oneshot(app, req2)
.await
.unwrap();
assert_eq!(resp2.status(), StatusCode::OK);
let v2 = body_json(resp2).await;
assert_eq!(v2["hidden"], true);
}
/// `DELETE /api/admin/reports/:quote_id/reports` clears reports; the list
/// is empty afterward.
#[tokio::test]
async fn integration_clear_reports_empties_report_list() {
let (app, _f) = test_router_with_admin("admin-secret").await;
let (app, quote, _auth) = create_quote_raw(app, "Spammy", "Author", &[]).await;
let quote_id = quote["id"].as_str().unwrap().to_owned();
let app = report_quote(app, &quote_id).await;
// Confirm there is one report before clearing.
let check_req = Request::builder()
.method(Method::GET)
.uri("/api/admin/reports")
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let check_resp = ServiceExt::<Request<Body>>::oneshot(app.clone(), check_req)
.await
.unwrap();
let v = body_json(check_resp).await;
assert_eq!(
v["total_count"], 1,
"should have one report before clearing"
);
// Clear reports.
let req = Request::builder()
.method(Method::DELETE)
.uri(format!("/api/admin/reports/{quote_id}/reports"))
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app.clone(), req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Confirm no reports remain.
let check2_req = Request::builder()
.method(Method::GET)
.uri("/api/admin/reports")
.header("X-Admin-Code", "admin-secret")
.body(Body::empty())
.unwrap();
let check2_resp = ServiceExt::<Request<Body>>::oneshot(app, check2_req)
.await
.unwrap();
let v2 = body_json(check2_resp).await;
assert_eq!(v2["total_count"], 0, "all reports should be cleared");
}
}