|
|
//! HTTP request handlers for the `quotesdb` API.
|
|
|
//!
|
|
|
//! Each handler maps to one route in the API specification. The [`router`]
|
|
|
//! function assembles the Axum [`Router`] with all routes in the required
|
|
|
//! order — in particular, `GET /api/quotes/random` is registered **before**
|
|
|
//! `GET /api/quotes/:id` to prevent "random" being captured as an id.
|
|
|
//!
|
|
|
//! All handlers share a [`crate::db::QuoteRepository`] via Axum's state
|
|
|
//! mechanism, wrapped in an [`Arc`] to allow cheap cloning across tasks.
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
use axum::{
|
|
|
extract::{Path, Query, State},
|
|
|
http::{HeaderMap, StatusCode},
|
|
|
response::{IntoResponse, Json, Response},
|
|
|
routing::{delete, get, post, put},
|
|
|
Router,
|
|
|
};
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
|
|
|
|
|
|
use crate::db::{DeleteResult, QuoteRepository};
|
|
|
|
|
|
// ── Shared application state ──────────────────────────────────────────────────
|
|
|
|
|
|
/// Type alias for the shared repository handle.
|
|
|
///
|
|
|
/// `Send + Sync` are required by Axum's native router so the state can be
|
|
|
/// shared across Tokio tasks. `NativeRepository` satisfies both bounds.
|
|
|
type Repo = Arc<dyn QuoteRepository + Send + Sync>;
|
|
|
|
|
|
// ── 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 (1–12). Requires `date_after_year`.
|
|
|
date_after_month: Option<u8>,
|
|
|
/// Narrows after-bound to this day (1–31). 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 (1–12). Requires `date_before_year`.
|
|
|
date_before_month: Option<u8>,
|
|
|
/// Narrows before-bound to this day (1–31). 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(repo): State<Repo>) -> Response {
|
|
|
match repo.get_submissions_locked().await {
|
|
|
Ok(locked) => Json(serde_json::json!({ "submissions_locked": locked })).into_response(),
|
|
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `GET /api/quotes` — list quotes with optional filtering and pagination.
|
|
|
///
|
|
|
/// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and
|
|
|
/// month/day variants) query parameters. Defaults to page 1 and no filters.
|
|
|
/// Returns [`crate::db::ListResult`] serialised as JSON.
|
|
|
///
|
|
|
/// Returns `400 Bad Request` when date component ordering is violated (e.g.
|
|
|
/// `date_after_month` provided without `date_after_year`).
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>) -> Response {
|
|
|
// Validate: month requires year, day requires year+month
|
|
|
if params.date_after_month.is_some() && params.date_after_year.is_none() {
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_after_month requires date_after_year",
|
|
|
);
|
|
|
}
|
|
|
if params.date_after_day.is_some()
|
|
|
&& (params.date_after_year.is_none() || params.date_after_month.is_none())
|
|
|
{
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_after_day requires date_after_year and date_after_month",
|
|
|
);
|
|
|
}
|
|
|
if params.date_before_month.is_some() && params.date_before_year.is_none() {
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_before_month requires date_before_year",
|
|
|
);
|
|
|
}
|
|
|
if params.date_before_day.is_some()
|
|
|
&& (params.date_before_year.is_none() || params.date_before_month.is_none())
|
|
|
{
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_before_day requires date_before_year and date_before_month",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
let date_after = build_date_bound(
|
|
|
params.date_after_year,
|
|
|
params.date_after_month,
|
|
|
params.date_after_day,
|
|
|
false,
|
|
|
);
|
|
|
let date_before = build_date_bound(
|
|
|
params.date_before_year,
|
|
|
params.date_before_month,
|
|
|
params.date_before_day,
|
|
|
true,
|
|
|
);
|
|
|
|
|
|
match repo
|
|
|
.list_quotes(
|
|
|
params.page,
|
|
|
params.author.as_deref(),
|
|
|
params.tag.as_deref(),
|
|
|
date_after.as_deref(),
|
|
|
date_before.as_deref(),
|
|
|
)
|
|
|
.await
|
|
|
{
|
|
|
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `GET /api/quotes/random` — return a random quote.
|
|
|
///
|
|
|
/// Returns `404` when the database is empty.
|
|
|
///
|
|
|
/// **Registration order:** this route must be registered before
|
|
|
/// `GET /api/quotes/:id` in the router to avoid "random" being matched as an
|
|
|
/// id parameter.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn random_handler(State(repo): State<Repo>) -> Response {
|
|
|
match repo.get_random_quote().await {
|
|
|
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "no quotes in database"),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `GET /api/quotes/:id` — retrieve a single quote by NanoID.
|
|
|
///
|
|
|
/// Returns `404` when no quote has the given id.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) -> Response {
|
|
|
match repo.get_quote(&id).await {
|
|
|
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "quote not found"),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Verify a Cloudflare Turnstile token against the siteverify API.
|
|
|
///
|
|
|
/// Returns `true` if the token is valid, `false` otherwise.
|
|
|
/// Failures are treated conservatively as invalid (returns `false`).
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
async fn verify_turnstile(token: &str, secret: &str) -> bool {
|
|
|
#[derive(serde::Deserialize)]
|
|
|
struct TurnstileResponse {
|
|
|
success: bool,
|
|
|
}
|
|
|
|
|
|
let params = [("secret", secret), ("response", token)];
|
|
|
let Ok(client) = reqwest::Client::builder().build() else {
|
|
|
return false;
|
|
|
};
|
|
|
let Ok(resp) = client
|
|
|
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
|
|
.form(¶ms)
|
|
|
.send()
|
|
|
.await
|
|
|
else {
|
|
|
return false;
|
|
|
};
|
|
|
let Ok(body) = resp.json::<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(repo): State<Repo>, Json(input): Json<CreateQuoteInput>) -> Response {
|
|
|
// Pre-flight: reject new submissions when locked.
|
|
|
match repo.get_submissions_locked().await {
|
|
|
Ok(true) => {
|
|
|
return (
|
|
|
StatusCode::LOCKED,
|
|
|
Json(serde_json::json!({ "error": "submissions are closed" })),
|
|
|
)
|
|
|
.into_response();
|
|
|
}
|
|
|
Ok(false) => {}
|
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
|
}
|
|
|
|
|
|
// Verify Cloudflare Turnstile token (native builds only; skipped on wasm32).
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
{
|
|
|
if let Ok(secret) = std::env::var("TURNSTILE_SECRET_KEY") {
|
|
|
let token = match input.cf_turnstile_token.as_deref() {
|
|
|
Some(t) if !t.is_empty() => t.to_owned(),
|
|
|
_ => {
|
|
|
return error_response(StatusCode::BAD_REQUEST, "CAPTCHA token required")
|
|
|
.into_response()
|
|
|
}
|
|
|
};
|
|
|
let verified = verify_turnstile(&token, &secret).await;
|
|
|
if !verified {
|
|
|
return error_response(StatusCode::FORBIDDEN, "CAPTCHA verification failed")
|
|
|
.into_response();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
match repo.create_quote(input).await {
|
|
|
Ok((quote, auth_code)) => (
|
|
|
StatusCode::CREATED,
|
|
|
Json(CreateResponse { quote, auth_code }),
|
|
|
)
|
|
|
.into_response(),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Extract the `X-Auth-Code` header value from the request headers.
|
|
|
///
|
|
|
/// Returns `None` if the header is absent or cannot be decoded as UTF-8.
|
|
|
fn extract_auth_code(headers: &HeaderMap) -> Option<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 one stored in the repository.
|
|
|
///
|
|
|
/// Fetches the current admin code via [`QuoteRepository::get_admin_auth_code`]
|
|
|
/// and compares it with the supplied code using standard string equality.
|
|
|
/// Returns `true` if the codes match, `false` if the code is wrong, missing,
|
|
|
/// or the database query fails.
|
|
|
async fn verify_admin_code(repo: &Repo, code: &str) -> bool {
|
|
|
match repo.get_admin_auth_code().await {
|
|
|
Ok(Some(stored)) => stored == code,
|
|
|
_ => false,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `POST /api/quotes/:id` — update an existing quote.
|
|
|
///
|
|
|
/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong,
|
|
|
/// `404` if the quote does not exist, or `200` with the updated quote.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn update_handler(
|
|
|
State(repo): State<Repo>,
|
|
|
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 repo.update_quote(&id, input, &auth_code).await {
|
|
|
Ok(quote) => (StatusCode::OK, Json(quote)).into_response(),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `DELETE /api/quotes/:id` — delete a quote.
|
|
|
///
|
|
|
/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong,
|
|
|
/// `404` if not found, or `204 No Content` on success.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn delete_handler(
|
|
|
State(repo): State<Repo>,
|
|
|
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 repo.delete_quote(&id, &auth_code).await {
|
|
|
Ok(DeleteResult::Deleted) => StatusCode::NO_CONTENT.into_response(),
|
|
|
Ok(DeleteResult::NotFound) => error_response(StatusCode::NOT_FOUND, "quote not found"),
|
|
|
Ok(DeleteResult::Forbidden) => error_response(StatusCode::FORBIDDEN, "forbidden"),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/lock` — lock new quote submissions.
|
|
|
///
|
|
|
/// Requires the `X-Admin-Code` header. Sets `submissions_locked = true` in
|
|
|
/// the repository and returns the updated lock state as JSON:
|
|
|
///
|
|
|
/// ```json
|
|
|
/// { "submissions_locked": true }
|
|
|
/// ```
|
|
|
///
|
|
|
/// Returns `403 Forbidden` if the header is missing or the code is incorrect.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn lock_submissions(State(repo): State<Repo>, headers: HeaderMap) -> Response {
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
};
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
}
|
|
|
match repo.set_submissions_locked(true).await {
|
|
|
Ok(()) => Json(serde_json::json!({ "submissions_locked": true })).into_response(),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/unlock` — unlock new quote submissions.
|
|
|
///
|
|
|
/// Requires the `X-Admin-Code` header. Sets `submissions_locked = false` in
|
|
|
/// the repository and returns the updated lock state as JSON:
|
|
|
///
|
|
|
/// ```json
|
|
|
/// { "submissions_locked": false }
|
|
|
/// ```
|
|
|
///
|
|
|
/// Returns `403 Forbidden` if the header is missing or the code is incorrect.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn unlock_submissions(State(repo): State<Repo>, headers: HeaderMap) -> Response {
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
};
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
}
|
|
|
match repo.set_submissions_locked(false).await {
|
|
|
Ok(()) => Json(serde_json::json!({ "submissions_locked": false })).into_response(),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Request body for `POST /api/admin/reset-auth-code`.
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
struct ResetAuthCodeRequest {
|
|
|
/// New admin auth code. If omitted, the server generates a fresh 4-word passphrase.
|
|
|
new_code: Option<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(repo): State<Repo>,
|
|
|
headers: HeaderMap,
|
|
|
Json(payload): Json<ResetAuthCodeRequest>,
|
|
|
) -> Response {
|
|
|
let admin_code = match extract_admin_code(&headers) {
|
|
|
Some(c) => c,
|
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
|
};
|
|
|
match repo
|
|
|
.update_admin_auth_code(&admin_code, payload.new_code.as_deref())
|
|
|
.await
|
|
|
{
|
|
|
Ok(new_code) => Json(ResetAuthCodeResponse {
|
|
|
auth_code: new_code,
|
|
|
})
|
|
|
.into_response(),
|
|
|
Err(crate::db::DbError::Forbidden) => StatusCode::FORBIDDEN.into_response(),
|
|
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Request body for `POST /api/quotes/:id/report`.
|
|
|
///
|
|
|
/// All fields are optional — a report can be submitted without a reason.
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
struct ReportInput {
|
|
|
/// Optional human-readable reason for the report. At most 256 characters.
|
|
|
reason: Option<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(repo): State<Repo>,
|
|
|
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 repo.create_report(&id, reason.as_deref()).await {
|
|
|
Ok(()) => StatusCode::CREATED.into_response(),
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
}
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Query parameters for `GET /api/admin/reports`.
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
struct AdminReportsParams {
|
|
|
/// 1-based page number. Defaults to 1.
|
|
|
#[serde(default = "default_page")]
|
|
|
page: u32,
|
|
|
}
|
|
|
|
|
|
/// `GET /api/admin/reports` — paginated list of reported quotes.
|
|
|
///
|
|
|
/// Returns a [`ReportListResult`] with 10 entries per page. Each entry
|
|
|
/// contains the quote ID, truncated text, author, total report count, and
|
|
|
/// the timestamp of the most recent report.
|
|
|
///
|
|
|
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
|
|
|
/// is absent or the code is incorrect.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn list_reports_handler(
|
|
|
State(repo): State<Repo>,
|
|
|
headers: HeaderMap,
|
|
|
Query(params): Query<AdminReportsParams>,
|
|
|
) -> Response {
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
};
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
}
|
|
|
match repo.list_reports(params.page).await {
|
|
|
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `GET /api/admin/reports/:quote_id` — full quote and all reports for it.
|
|
|
///
|
|
|
/// Returns a JSON object with `quote` and `reports` fields. Reports are
|
|
|
/// ordered oldest first.
|
|
|
///
|
|
|
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
|
|
|
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
|
|
|
/// exist.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn get_quote_reports_handler(
|
|
|
State(repo): State<Repo>,
|
|
|
Path(quote_id): Path<String>,
|
|
|
headers: HeaderMap,
|
|
|
) -> Response {
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
};
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
}
|
|
|
match repo.get_reports_for_quote("e_id).await {
|
|
|
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
}
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/quote` — delete a quote as admin.
|
|
|
///
|
|
|
/// Deletes the quote unconditionally (no per-quote auth code required).
|
|
|
/// Tags and reports are removed automatically via `ON DELETE CASCADE`.
|
|
|
/// Returns `204 No Content` on success.
|
|
|
///
|
|
|
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
|
|
|
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
|
|
|
/// exist.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn admin_delete_quote_handler(
|
|
|
State(repo): State<Repo>,
|
|
|
Path(quote_id): Path<String>,
|
|
|
headers: HeaderMap,
|
|
|
) -> Response {
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
};
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
}
|
|
|
match repo.admin_delete_quote("e_id).await {
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
}
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/reports/:quote_id/hide` — hide a quote.
|
|
|
///
|
|
|
/// Sets `hidden = 1` on the quote so it is excluded from public listing.
|
|
|
/// Returns `200 OK` with `{"hidden": true}` on success.
|
|
|
///
|
|
|
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
|
|
|
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
|
|
|
/// exist.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn hide_quote_handler(
|
|
|
State(repo): State<Repo>,
|
|
|
Path(quote_id): Path<String>,
|
|
|
headers: HeaderMap,
|
|
|
) -> Response {
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
};
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
}
|
|
|
match repo.hide_quote("e_id).await {
|
|
|
Ok(()) => Json(serde_json::json!({ "hidden": true })).into_response(),
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
}
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/reports` — clear all reports for a quote.
|
|
|
///
|
|
|
/// Removes all report rows for the given quote without deleting the quote
|
|
|
/// itself. Returns `204 No Content` on success.
|
|
|
///
|
|
|
/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header
|
|
|
/// is absent or the code is incorrect, `404 Not Found` if the quote does not
|
|
|
/// exist.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn clear_reports_handler(
|
|
|
State(repo): State<Repo>,
|
|
|
Path(quote_id): Path<String>,
|
|
|
headers: HeaderMap,
|
|
|
) -> Response {
|
|
|
let Some(code) = extract_admin_code(&headers) else {
|
|
|
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
|
|
|
};
|
|
|
if !verify_admin_code(&repo, &code).await {
|
|
|
return error_response(StatusCode::FORBIDDEN, "invalid admin code");
|
|
|
}
|
|
|
match repo.clear_reports("e_id).await {
|
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
|
Err(crate::db::DbError::NotFound) => {
|
|
|
error_response(StatusCode::NOT_FOUND, "quote not found")
|
|
|
}
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Router ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Build the Axum [`Router`] with all API routes wired to their handlers.
|
|
|
///
|
|
|
/// Route registration order is important: `GET /api/quotes/random` must
|
|
|
/// appear before `GET /api/quotes/:id` so Axum's static segment wins over
|
|
|
/// the dynamic `:id` capture.
|
|
|
///
|
|
|
/// The repository must implement `Send + Sync` so it can be shared across
|
|
|
/// Tokio tasks by Axum's state mechanism. [`NativeRepository`] satisfies
|
|
|
/// both bounds via `tokio_rusqlite::Connection`.
|
|
|
///
|
|
|
/// [`NativeRepository`]: crate::db::NativeRepository
|
|
|
pub fn router(repo: Arc<dyn QuoteRepository + Send + Sync>) -> Router {
|
|
|
Router::new()
|
|
|
// Meta
|
|
|
.route("/api/", get(openapi_handler))
|
|
|
// Public status — exposes whether submissions are currently locked.
|
|
|
.route("/api/status", get(get_status))
|
|
|
// Admin endpoints — toggle the global submissions lock and reset auth code.
|
|
|
.route("/api/admin/lock", post(lock_submissions))
|
|
|
.route("/api/admin/unlock", post(unlock_submissions))
|
|
|
.route("/api/admin/reset-auth-code", post(reset_auth_code))
|
|
|
// Admin moderation endpoints — report management.
|
|
|
.route("/api/admin/reports", get(list_reports_handler))
|
|
|
.route(
|
|
|
"/api/admin/reports/{quote_id}",
|
|
|
get(get_quote_reports_handler),
|
|
|
)
|
|
|
.route(
|
|
|
"/api/admin/reports/{quote_id}/quote",
|
|
|
delete(admin_delete_quote_handler),
|
|
|
)
|
|
|
.route(
|
|
|
"/api/admin/reports/{quote_id}/hide",
|
|
|
post(hide_quote_handler),
|
|
|
)
|
|
|
.route(
|
|
|
"/api/admin/reports/{quote_id}/reports",
|
|
|
delete(clear_reports_handler),
|
|
|
)
|
|
|
// IMPORTANT: /random and /{id}/report must be registered before /{id}
|
|
|
// so static segments win over the dynamic capture.
|
|
|
.route("/api/quotes/random", get(random_handler))
|
|
|
.route("/api/quotes/{id}/report", post(report_handler))
|
|
|
.route("/api/quotes/{id}", get(get_quote_handler))
|
|
|
.route("/api/quotes", get(list_handler))
|
|
|
.route("/api/quotes", put(create_handler))
|
|
|
.route("/api/quotes/{id}", post(update_handler))
|
|
|
.route("/api/quotes/{id}", delete(delete_handler))
|
|
|
.with_state(repo)
|
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
|
use super::*;
|
|
|
use axum::{
|
|
|
body::Body,
|
|
|
http::{Method, Request},
|
|
|
};
|
|
|
use tower::util::ServiceExt; // for `oneshot`
|
|
|
|
|
|
use crate::db::{DbError, DeleteResult, ListResult};
|
|
|
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
|
|
|
|
|
|
// ── Mock repository for handler tests ─────────────────────────────────────
|
|
|
|
|
|
/// A simple mock [`QuoteRepository`] for unit-testing handlers.
|
|
|
///
|
|
|
/// Tracks in-memory state for quotes, the admin auth code, and the
|
|
|
/// submissions-locked flag so all trait methods can be exercised without a
|
|
|
/// real database.
|
|
|
struct MockRepo {
|
|
|
quotes: std::sync::Mutex<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) = "es[i];
|
|
|
if stored.as_str() != auth_code {
|
|
|
return Ok(DeleteResult::Forbidden);
|
|
|
}
|
|
|
quotes.remove(i);
|
|
|
Ok(DeleteResult::Deleted)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async fn get_admin_auth_code(&self) -> Result<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());
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
// Should be valid JSON
|
|
|
let _: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_list_quotes() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/quotes")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["total_count"], 1);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_random_quote_not_found() {
|
|
|
let app = router(MockRepo::empty());
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/quotes/random")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_random_quote_found() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/quotes/random")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_get_quote_not_found() {
|
|
|
let app = router(MockRepo::empty());
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/quotes/nonexistent")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_get_quote_found() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_create_quote() {
|
|
|
let app = router(MockRepo::empty());
|
|
|
let body = serde_json::json!({
|
|
|
"text": "New quote",
|
|
|
"author": "Author",
|
|
|
"tags": []
|
|
|
});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::PUT)
|
|
|
.uri("/api/quotes")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, resp_body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
|
|
|
assert!(v["auth_code"].is_string());
|
|
|
assert_eq!(v["quote"]["text"], "New quote");
|
|
|
}
|
|
|
|
|
|
/// `PUT /api/quotes` while `submissions_locked = true` returns `423 Locked`
|
|
|
/// with `{"error": "submissions are closed"}`.
|
|
|
#[tokio::test]
|
|
|
async fn test_create_quote_locked_returns_423() {
|
|
|
let app = router(MockRepo::with_submissions_locked(true));
|
|
|
let body = serde_json::json!({
|
|
|
"text": "Locked quote",
|
|
|
"author": "Author",
|
|
|
"tags": []
|
|
|
});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::PUT)
|
|
|
.uri("/api/quotes")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, resp_body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::LOCKED);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
|
|
|
assert_eq!(v["error"], "submissions are closed");
|
|
|
}
|
|
|
|
|
|
/// `PUT /api/quotes` while `submissions_locked = false` returns `201 Created`
|
|
|
/// (existing behaviour is unchanged).
|
|
|
#[tokio::test]
|
|
|
async fn test_create_quote_unlocked_returns_201() {
|
|
|
let app = router(MockRepo::with_submissions_locked(false));
|
|
|
let body = serde_json::json!({
|
|
|
"text": "Unlocked quote",
|
|
|
"author": "Author",
|
|
|
"tags": []
|
|
|
});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::PUT)
|
|
|
.uri("/api/quotes")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, resp_body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
|
|
|
assert!(v["auth_code"].is_string());
|
|
|
}
|
|
|
|
|
|
/// After unlocking (`submissions_locked = false` after being `true`),
|
|
|
/// `PUT /api/quotes` succeeds again with `201 Created`.
|
|
|
#[tokio::test]
|
|
|
async fn test_create_quote_after_unlock_returns_201() {
|
|
|
// Build a repo that starts locked.
|
|
|
let repo = MockRepo::with_submissions_locked(true);
|
|
|
// Unlock it.
|
|
|
repo.set_submissions_locked(false)
|
|
|
.await
|
|
|
.expect("set_submissions_locked should not fail");
|
|
|
let app = router(repo);
|
|
|
let body = serde_json::json!({
|
|
|
"text": "Re-enabled quote",
|
|
|
"author": "Author",
|
|
|
"tags": []
|
|
|
});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::PUT)
|
|
|
.uri("/api/quotes")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, resp_body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
|
|
|
assert!(v["auth_code"].is_string());
|
|
|
assert_eq!(v["quote"]["text"], "Re-enabled quote");
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_update_quote_missing_auth() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
|
|
|
let body = serde_json::json!({"text": "Updated"});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_update_quote_wrong_auth() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
|
|
|
let body = serde_json::json!({"text": "Updated"});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.header("X-Auth-Code", "wrong")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_update_quote_success() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
|
|
|
let body = serde_json::json!({"text": "Updated text"});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.header("X-Auth-Code", "correct")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, resp_body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
|
|
|
assert_eq!(v["text"], "Updated text");
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_delete_quote_missing_auth() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_delete_quote_success() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri("/api/quotes/abc-123")
|
|
|
.header("X-Auth-Code", "correct")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
|
async fn test_delete_quote_not_found() {
|
|
|
let app = router(MockRepo::empty());
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri("/api/quotes/nonexistent")
|
|
|
.header("X-Auth-Code", "any")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
// ── Admin DB method tests ──────────────────────────────────────────────────
|
|
|
|
|
|
/// `get_submissions_locked` returns `false` when the repo has just been
|
|
|
/// created (no `set_submissions_locked` call has been made yet).
|
|
|
#[tokio::test]
|
|
|
async fn test_get_submissions_locked_default_false() {
|
|
|
let repo = MockRepo::empty();
|
|
|
let locked = repo
|
|
|
.get_submissions_locked()
|
|
|
.await
|
|
|
.expect("get_submissions_locked should not fail");
|
|
|
assert!(!locked, "submissions should be unlocked by default");
|
|
|
}
|
|
|
|
|
|
/// After calling `set_submissions_locked(true)`, `get_submissions_locked`
|
|
|
/// must return `true`.
|
|
|
#[tokio::test]
|
|
|
async fn test_set_and_get_submissions_locked() {
|
|
|
let repo = MockRepo::empty();
|
|
|
repo.set_submissions_locked(true)
|
|
|
.await
|
|
|
.expect("set_submissions_locked should not fail");
|
|
|
let locked = repo
|
|
|
.get_submissions_locked()
|
|
|
.await
|
|
|
.expect("get_submissions_locked should not fail");
|
|
|
assert!(
|
|
|
locked,
|
|
|
"submissions should be locked after set_submissions_locked(true)"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `update_admin_auth_code` with the correct current code succeeds and
|
|
|
/// returns the new code.
|
|
|
#[tokio::test]
|
|
|
async fn test_update_admin_auth_code_correct_current_succeeds() {
|
|
|
let repo = MockRepo::with_admin_code("old-code");
|
|
|
let new_code = repo
|
|
|
.update_admin_auth_code("old-code", Some("brand-new-code"))
|
|
|
.await
|
|
|
.expect("update_admin_auth_code should succeed when current matches");
|
|
|
assert_eq!(new_code, "brand-new-code");
|
|
|
// The stored code should now be the new one.
|
|
|
let stored = repo
|
|
|
.get_admin_auth_code()
|
|
|
.await
|
|
|
.expect("get_admin_auth_code should not fail");
|
|
|
assert_eq!(stored.as_deref(), Some("brand-new-code"));
|
|
|
}
|
|
|
|
|
|
/// `update_admin_auth_code` with the wrong current code returns
|
|
|
/// `Err(DbError::Forbidden)`.
|
|
|
#[tokio::test]
|
|
|
async fn test_update_admin_auth_code_wrong_current_forbidden() {
|
|
|
let repo = MockRepo::with_admin_code("real-code");
|
|
|
let result = repo
|
|
|
.update_admin_auth_code("wrong-code", Some("irrelevant"))
|
|
|
.await;
|
|
|
assert!(
|
|
|
matches!(result, Err(DbError::Forbidden)),
|
|
|
"expected Forbidden, got {result:?}",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── GET /api/status handler tests ─────────────────────────────────────────
|
|
|
|
|
|
/// `GET /api/status` returns `200` with `{"submissions_locked": false}` when
|
|
|
/// the repo's submissions lock is unset (the default `false` state).
|
|
|
#[tokio::test]
|
|
|
async fn test_get_status_unlocked() {
|
|
|
let app = router(MockRepo::empty());
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/status")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["submissions_locked"], false);
|
|
|
}
|
|
|
|
|
|
/// `GET /api/status` returns `200` with `{"submissions_locked": true}` after
|
|
|
/// the lock has been enabled via `set_submissions_locked(true)`.
|
|
|
#[tokio::test]
|
|
|
async fn test_get_status_locked() {
|
|
|
let repo = MockRepo::empty();
|
|
|
repo.set_submissions_locked(true)
|
|
|
.await
|
|
|
.expect("set_submissions_locked should not fail");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/status")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["submissions_locked"], true);
|
|
|
}
|
|
|
|
|
|
/// `get_submissions_locked` returns `false` for a freshly created repo
|
|
|
/// (graceful default — no DB row or explicit seed needed).
|
|
|
#[tokio::test]
|
|
|
async fn test_get_submissions_locked_default_is_false() {
|
|
|
let repo = MockRepo::empty();
|
|
|
let locked = repo
|
|
|
.get_submissions_locked()
|
|
|
.await
|
|
|
.expect("get_submissions_locked should not fail");
|
|
|
assert!(!locked, "submissions should default to unlocked");
|
|
|
}
|
|
|
|
|
|
// ── POST /api/admin/lock handler tests ────────────────────────────────────
|
|
|
|
|
|
/// `POST /api/admin/lock` with the correct admin code returns `200` and
|
|
|
/// `{ "submissions_locked": true }`.
|
|
|
#[tokio::test]
|
|
|
async fn test_lock_submissions_correct_code_returns_200() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/lock")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["submissions_locked"], true);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/unlock` with the correct admin code returns `200` and
|
|
|
/// `{ "submissions_locked": false }`.
|
|
|
#[tokio::test]
|
|
|
async fn test_unlock_submissions_correct_code_returns_200() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
// Start in locked state.
|
|
|
repo.set_submissions_locked(true)
|
|
|
.await
|
|
|
.expect("set_submissions_locked should not fail");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/unlock")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["submissions_locked"], false);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/lock` with a wrong admin code returns `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_lock_submissions_wrong_code_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/lock")
|
|
|
.header("X-Admin-Code", "wrong-code")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/unlock` with no `X-Admin-Code` header returns `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_unlock_submissions_missing_header_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/unlock")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
/// Locking when already locked is idempotent — returns `200` with
|
|
|
/// `{ "submissions_locked": true }`.
|
|
|
#[tokio::test]
|
|
|
async fn test_lock_submissions_idempotent() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
// Lock once via the trait directly.
|
|
|
repo.set_submissions_locked(true)
|
|
|
.await
|
|
|
.expect("initial lock should not fail");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/lock")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["submissions_locked"], true);
|
|
|
}
|
|
|
|
|
|
// ── POST /api/admin/reset-auth-code handler tests ─────────────────────────
|
|
|
|
|
|
/// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no
|
|
|
/// `new_code` in the body returns `200` with a non-empty `auth_code`.
|
|
|
/// The MockRepo returns `"new-mock-code"` when `new_code` is `None`.
|
|
|
#[tokio::test]
|
|
|
async fn test_reset_auth_code_correct_code_no_new_code_returns_200() {
|
|
|
let repo = MockRepo::with_admin_code("current-secret");
|
|
|
let app = router(repo);
|
|
|
let body = serde_json::json!({});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reset-auth-code")
|
|
|
.header("X-Admin-Code", "current-secret")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, resp_body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
|
|
|
assert!(
|
|
|
v["auth_code"].is_string(),
|
|
|
"response must contain auth_code string"
|
|
|
);
|
|
|
assert!(
|
|
|
!v["auth_code"].as_str().unwrap().is_empty(),
|
|
|
"auth_code must be non-empty"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and an
|
|
|
/// explicit `new_code` returns `200` and `auth_code` equals the supplied value.
|
|
|
#[tokio::test]
|
|
|
async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200() {
|
|
|
let repo = MockRepo::with_admin_code("current-secret");
|
|
|
let app = router(repo);
|
|
|
let body = serde_json::json!({ "new_code": "brand-new-passphrase" });
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reset-auth-code")
|
|
|
.header("X-Admin-Code", "current-secret")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, resp_body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
|
|
|
assert_eq!(
|
|
|
v["auth_code"], "brand-new-passphrase",
|
|
|
"auth_code must equal the supplied new_code"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/reset-auth-code` with a wrong `X-Admin-Code` returns `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_reset_auth_code_wrong_code_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
let app = router(repo);
|
|
|
let body = serde_json::json!({});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reset-auth-code")
|
|
|
.header("X-Admin-Code", "wrong-secret")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/reset-auth-code` with no `X-Admin-Code` header returns `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_reset_auth_code_missing_header_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
let app = router(repo);
|
|
|
let body = serde_json::json!({});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reset-auth-code")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
// ── POST /api/quotes/:id/report handler tests ──────────────────────────────
|
|
|
|
|
|
/// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`.
|
|
|
#[tokio::test]
|
|
|
async fn test_report_success() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
|
|
|
let body = serde_json::json!({ "reason": "inappropriate content" });
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/quotes/abc-123/report")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`.
|
|
|
#[tokio::test]
|
|
|
async fn test_report_quote_not_found() {
|
|
|
let app = router(MockRepo::empty());
|
|
|
let body = serde_json::json!({});
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/quotes/unknown/report")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/quotes/:id/report` with a reason longer than 256 characters
|
|
|
/// returns `400 Bad Request`.
|
|
|
#[tokio::test]
|
|
|
async fn test_report_reason_too_long() {
|
|
|
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
|
|
|
let long_reason = "x".repeat(257);
|
|
|
let body = serde_json::json!({ "reason": long_reason });
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/quotes/abc-123/report")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(body.to_string()))
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
|
}
|
|
|
|
|
|
/// After a successful reset, subsequent calls with the old code return `403`
|
|
|
/// and with the new code return `200`.
|
|
|
#[tokio::test]
|
|
|
async fn test_reset_auth_code_old_code_rejected_after_reset() {
|
|
|
let repo = MockRepo::with_admin_code("old-secret");
|
|
|
|
|
|
// First reset: change from "old-secret" to "new-secret".
|
|
|
let first_body = serde_json::json!({ "new_code": "new-secret" });
|
|
|
let first_req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reset-auth-code")
|
|
|
.header("X-Admin-Code", "old-secret")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(first_body.to_string()))
|
|
|
.unwrap();
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
let (status, _) = send(app, first_req).await;
|
|
|
assert_eq!(status, StatusCode::OK, "first reset must succeed");
|
|
|
|
|
|
// Second call with old code must now be forbidden.
|
|
|
let second_body = serde_json::json!({});
|
|
|
let second_req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reset-auth-code")
|
|
|
.header("X-Admin-Code", "old-secret")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(second_body.to_string()))
|
|
|
.unwrap();
|
|
|
let app2 = router(Arc::clone(&repo) as Repo);
|
|
|
let (status2, _) = send(app2, second_req).await;
|
|
|
assert_eq!(
|
|
|
status2,
|
|
|
StatusCode::FORBIDDEN,
|
|
|
"old code must be rejected after reset"
|
|
|
);
|
|
|
|
|
|
// Third call with the new code must succeed.
|
|
|
let third_body = serde_json::json!({});
|
|
|
let third_req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reset-auth-code")
|
|
|
.header("X-Admin-Code", "new-secret")
|
|
|
.header("Content-Type", "application/json")
|
|
|
.body(Body::from(third_body.to_string()))
|
|
|
.unwrap();
|
|
|
let app3 = router(repo as Repo);
|
|
|
let (status3, resp_body3) = send(app3, third_req).await;
|
|
|
assert_eq!(
|
|
|
status3,
|
|
|
StatusCode::OK,
|
|
|
"new code must be accepted after reset"
|
|
|
);
|
|
|
let v: serde_json::Value = serde_json::from_str(&resp_body3).unwrap();
|
|
|
assert!(
|
|
|
v["auth_code"].is_string(),
|
|
|
"response must include auth_code after second reset"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── GET /api/admin/reports handler tests ──────────────────────────────────
|
|
|
|
|
|
/// `GET /api/admin/reports` with a valid admin code returns `200` and a
|
|
|
/// [`ReportListResult`] JSON body (empty list since MockRepo returns no rows).
|
|
|
#[tokio::test]
|
|
|
async fn test_list_reports_correct_code_returns_200() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/admin/reports")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["total_count"], 0);
|
|
|
assert!(v["reports"].is_array());
|
|
|
}
|
|
|
|
|
|
/// `GET /api/admin/reports` with no `X-Admin-Code` header returns `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_list_reports_missing_header_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/admin/reports")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
/// `GET /api/admin/reports` with a wrong admin code returns `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_list_reports_wrong_code_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("real-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/admin/reports")
|
|
|
.header("X-Admin-Code", "wrong-code")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
// ── GET /api/admin/reports/:quote_id handler tests ────────────────────────
|
|
|
|
|
|
/// `GET /api/admin/reports/:quote_id` with a valid code and existing quote
|
|
|
/// returns `200` with the quote and an empty reports list.
|
|
|
#[tokio::test]
|
|
|
async fn test_get_quote_reports_found_returns_200() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
// Seed a quote into the mock.
|
|
|
repo.create_quote(quotesdb::CreateQuoteInput {
|
|
|
text: "Test".to_owned(),
|
|
|
author: "Author".to_owned(),
|
|
|
source: None,
|
|
|
date: None,
|
|
|
tags: vec![],
|
|
|
auth_code: Some("auth".to_owned()),
|
|
|
cf_turnstile_token: None,
|
|
|
})
|
|
|
.await
|
|
|
.unwrap();
|
|
|
// Retrieve the quote id.
|
|
|
let quotes = repo.quotes.lock().unwrap();
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
drop(quotes);
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri(format!("/api/admin/reports/{quote_id}"))
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["quote"]["id"], quote_id.as_str());
|
|
|
assert!(v["reports"].is_array());
|
|
|
}
|
|
|
|
|
|
/// `GET /api/admin/reports/:quote_id` for a nonexistent quote returns `404`.
|
|
|
#[tokio::test]
|
|
|
async fn test_get_quote_reports_not_found_returns_404() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/admin/reports/nonexistent")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
/// `GET /api/admin/reports/:quote_id` with no admin code returns `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_get_quote_reports_missing_header_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/admin/reports/any-id")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
// ── DELETE /api/admin/reports/:quote_id/quote handler tests ───────────────
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/quote` with a valid code deletes
|
|
|
/// the quote and returns `204 No Content`.
|
|
|
#[tokio::test]
|
|
|
async fn test_admin_delete_quote_returns_204() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
repo.create_quote(quotesdb::CreateQuoteInput {
|
|
|
text: "Delete me".to_owned(),
|
|
|
author: "Author".to_owned(),
|
|
|
source: None,
|
|
|
date: None,
|
|
|
tags: vec![],
|
|
|
auth_code: Some("auth".to_owned()),
|
|
|
cf_turnstile_token: None,
|
|
|
})
|
|
|
.await
|
|
|
.unwrap();
|
|
|
let quotes = repo.quotes.lock().unwrap();
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
drop(quotes);
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/quote"))
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
|
|
|
|
|
// Verify the quote is gone.
|
|
|
let quotes = repo.quotes.lock().unwrap();
|
|
|
assert!(quotes.is_empty(), "quote should have been deleted");
|
|
|
}
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/quote` for a nonexistent quote
|
|
|
/// returns `404`.
|
|
|
#[tokio::test]
|
|
|
async fn test_admin_delete_quote_not_found_returns_404() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri("/api/admin/reports/nonexistent/quote")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/quote` with no admin code returns
|
|
|
/// `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_admin_delete_quote_missing_header_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri("/api/admin/reports/any-id/quote")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
// ── POST /api/admin/reports/:quote_id/hide handler tests ──────────────────
|
|
|
|
|
|
/// `POST /api/admin/reports/:quote_id/hide` with a valid code sets the
|
|
|
/// quote hidden and returns `200` with `{"hidden": true}`.
|
|
|
#[tokio::test]
|
|
|
async fn test_hide_quote_returns_200() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
repo.create_quote(quotesdb::CreateQuoteInput {
|
|
|
text: "Hide me".to_owned(),
|
|
|
author: "Author".to_owned(),
|
|
|
source: None,
|
|
|
date: None,
|
|
|
tags: vec![],
|
|
|
auth_code: Some("auth".to_owned()),
|
|
|
cf_turnstile_token: None,
|
|
|
})
|
|
|
.await
|
|
|
.unwrap();
|
|
|
let quotes = repo.quotes.lock().unwrap();
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
drop(quotes);
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/hide"))
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, body) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
|
assert_eq!(v["hidden"], true);
|
|
|
|
|
|
// Verify the quote is now hidden in the mock.
|
|
|
let quotes = repo.quotes.lock().unwrap();
|
|
|
assert!(quotes[0].0.hidden, "quote should be marked hidden");
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/reports/:quote_id/hide` for a nonexistent quote
|
|
|
/// returns `404`.
|
|
|
#[tokio::test]
|
|
|
async fn test_hide_quote_not_found_returns_404() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reports/nonexistent/hide")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
/// `POST /api/admin/reports/:quote_id/hide` with no admin code returns
|
|
|
/// `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_hide_quote_missing_header_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri("/api/admin/reports/any-id/hide")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
|
|
|
// ── DELETE /api/admin/reports/:quote_id/reports handler tests ─────────────
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/reports` with a valid code clears
|
|
|
/// all reports and returns `204 No Content`.
|
|
|
#[tokio::test]
|
|
|
async fn test_clear_reports_returns_204() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
repo.create_quote(quotesdb::CreateQuoteInput {
|
|
|
text: "Reported".to_owned(),
|
|
|
author: "Author".to_owned(),
|
|
|
source: None,
|
|
|
date: None,
|
|
|
tags: vec![],
|
|
|
auth_code: Some("auth".to_owned()),
|
|
|
cf_turnstile_token: None,
|
|
|
})
|
|
|
.await
|
|
|
.unwrap();
|
|
|
let quotes = repo.quotes.lock().unwrap();
|
|
|
let quote_id = quotes[0].0.id.clone();
|
|
|
drop(quotes);
|
|
|
|
|
|
let app = router(Arc::clone(&repo) as Repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/reports"))
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
|
|
}
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/reports` for a nonexistent quote
|
|
|
/// returns `404`.
|
|
|
#[tokio::test]
|
|
|
async fn test_clear_reports_not_found_returns_404() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri("/api/admin/reports/nonexistent/reports")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
}
|
|
|
|
|
|
/// `DELETE /api/admin/reports/:quote_id/reports` with no admin code returns
|
|
|
/// `403`.
|
|
|
#[tokio::test]
|
|
|
async fn test_clear_reports_missing_header_returns_403() {
|
|
|
let repo = MockRepo::with_admin_code("admin-secret");
|
|
|
let app = router(repo);
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri("/api/admin/reports/any-id/reports")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let (status, _) = send(app, req).await;
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Integration tests (real NativeRepository + real SQLite) ─────────────────
|
|
|
//
|
|
|
// These tests spin up the full Axum router backed by a temporary file-based
|
|
|
// SQLite database. Each test gets its own database via `NamedTempFile` so
|
|
|
// there is no cross-test interference.
|
|
|
//
|
|
|
// Tickets covered:
|
|
|
// 789d0f GET /api/ returns OpenAPI JSON
|
|
|
// aa0eab GET /api/quotes/random
|
|
|
// f9f448 GET /api/quotes/:id
|
|
|
// 4a4c26 PUT /api/quotes (create)
|
|
|
// 93f1b6 GET /api/quotes (list + filters + pagination)
|
|
|
// fae330 POST /api/quotes/:id (update)
|
|
|
// 8c87db DELETE /api/quotes/:id
|
|
|
// 893eba Tag operations
|
|
|
// e8f5cf Router ordering (/random not matched as :id)
|
|
|
#[cfg(test)]
|
|
|
mod integration_tests {
|
|
|
use super::*;
|
|
|
use axum::http::Request;
|
|
|
use axum::{body::Body, http::Method};
|
|
|
use serde_json::json;
|
|
|
use tempfile::NamedTempFile;
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
use crate::db::connection;
|
|
|
|
|
|
// ── Harness ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Create an Axum router backed by a real, migrated NativeRepository
|
|
|
/// stored in a temporary file. Returns both the router and the temp file
|
|
|
/// handle (which must be kept alive for the duration of the test).
|
|
|
async fn test_router() -> (Router, NamedTempFile) {
|
|
|
let f = NamedTempFile::new().expect("failed to create temp db file");
|
|
|
let repo = connection::open(f.path().to_str().expect("non-utf8 temp path"))
|
|
|
.await
|
|
|
.expect("failed to open test database");
|
|
|
repo.run_migrations().await.expect("migrations failed");
|
|
|
let repo: Arc<dyn QuoteRepository + Send + Sync> = Arc::new(repo);
|
|
|
(router(repo), f)
|
|
|
}
|
|
|
|
|
|
// ── Body helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Collect the full response body as raw bytes.
|
|
|
async fn body_bytes(resp: axum::response::Response) -> Vec<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), 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, "e_id).await;
|
|
|
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/admin/reports")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let resp = ServiceExt::<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, "e_id).await;
|
|
|
|
|
|
let req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri(format!("/api/admin/reports/{quote_id}"))
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let resp = ServiceExt::<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, "e_id).await;
|
|
|
|
|
|
// Delete via admin endpoint.
|
|
|
let req = Request::builder()
|
|
|
.method(Method::DELETE)
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/quote"))
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let resp = ServiceExt::<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, "e_id).await;
|
|
|
|
|
|
// Hide the quote.
|
|
|
let req = Request::builder()
|
|
|
.method(Method::POST)
|
|
|
.uri(format!("/api/admin/reports/{quote_id}/hide"))
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let resp = ServiceExt::<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, "e_id).await;
|
|
|
|
|
|
// Confirm there is one report before clearing.
|
|
|
let check_req = Request::builder()
|
|
|
.method(Method::GET)
|
|
|
.uri("/api/admin/reports")
|
|
|
.header("X-Admin-Code", "admin-secret")
|
|
|
.body(Body::empty())
|
|
|
.unwrap();
|
|
|
let check_resp = ServiceExt::<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");
|
|
|
}
|
|
|
}
|