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

1634 lines
62 KiB
Rust

This file contains ambiguous Unicode characters!

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

//! HTTP request handlers for the `quotesdb` API.
//!
//! Each handler maps to one route in the API specification. The [`router`]
//! function assembles the Axum [`Router`] with all routes in the required
//! order — in particular, `GET /api/quotes/random` is registered **before**
//! `GET /api/quotes/:id` to prevent "random" being captured as an id.
//!
//! All handlers share a [`crate::db::QuoteRepository`] via Axum's state
//! mechanism, wrapped in an [`Arc`] to allow cheap cloning across tasks.
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
routing::{delete, get, post, put},
Router,
};
use serde::{Deserialize, Serialize};
use 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 (112). Requires `date_after_year`.
date_after_month: Option<u8>,
/// Narrows after-bound to this day (131). Requires `date_after_year` and `date_after_month`.
date_after_day: Option<u8>,
/// Only include quotes dated on or before this year.
date_before_year: Option<u16>,
/// Narrows before-bound to this month (112). Requires `date_before_year`.
date_before_month: Option<u8>,
/// Narrows before-bound to this day (131). Requires `date_before_year` and `date_before_month`.
date_before_day: Option<u8>,
}
fn default_page() -> u32 {
1
}
/// Build an ISO date prefix string from optional year/month/day components.
///
/// Returns `None` if no year is given. For before-bounds, missing month
/// defaults to `12` and missing day defaults to `31` so the bound is
/// inclusive of the entire specified year/month.
///
/// # Examples
///
/// ```ignore
/// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string()));
/// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string()));
/// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string()));
/// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string()));
/// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None);
/// ```
fn build_date_bound(
year: Option<u16>,
month: Option<u8>,
day: Option<u8>,
is_before: bool,
) -> Option<String> {
match (year, month, day) {
(None, _, _) => None,
(Some(y), None, _) => {
if is_before {
Some(format!("{y:04}-12-31"))
} else {
Some(format!("{y:04}"))
}
}
(Some(y), Some(m), None) => {
if is_before {
Some(format!("{y:04}-{m:02}-31"))
} else {
Some(format!("{y:04}-{m:02}"))
}
}
(Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")),
}
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// `GET /api/` — return the OpenAPI specification as JSON.
///
/// The spec is embedded at compile time from `api/openapi.yaml` (converted to
/// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw
/// spec string.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn openapi_handler() -> Response {
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
OPENAPI_JSON,
)
.into_response()
}
/// `GET /api/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(&params)
.send()
.await
else {
return false;
};
let Ok(body) = resp.json::<TurnstileResponse>().await else {
return false;
};
body.success
}
/// `PUT /api/quotes` — create a new quote.
///
/// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created`
/// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only
/// time it is returned — the client must store it.
///
/// 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 {
// 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())
}
/// `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),
}
}
// ── 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))
// IMPORTANT: /random must be registered before /{id} so the static
// segment wins over the dynamic capture.
.route("/api/quotes/random", get(random_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.
struct MockRepo {
quotes: std::sync::Mutex<Vec<(Quote, String)>>,
}
impl MockRepo {
fn empty() -> Repo {
Arc::new(Self {
quotes: std::sync::Mutex::new(vec![]),
})
}
fn with_quote(quote: Quote, auth: &str) -> Repo {
Arc::new(Self {
quotes: std::sync::Mutex::new(vec![(quote, auth.to_owned())]),
})
}
}
#[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,
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;
}
Ok(q.clone())
}
}
}
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
let mut quotes = self.quotes.lock().unwrap();
let pos = quotes.iter().position(|(q, _)| q.id == id);
match pos {
None => Ok(DeleteResult::NotFound),
Some(i) => {
let (_, stored) = &quotes[i];
if stored.as_str() != auth_code {
return Ok(DeleteResult::Forbidden);
}
quotes.remove(i);
Ok(DeleteResult::Deleted)
}
}
}
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
Ok(None)
}
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
Ok(())
}
async fn update_admin_auth_code(
&self,
_current: &str,
_new_code: Option<&str>,
) -> Result<String, DbError> {
Err(DbError::Forbidden)
}
async fn get_submissions_locked(&self) -> Result<bool, DbError> {
Ok(false)
}
async fn set_submissions_locked(&self, _locked: bool) -> Result<(), DbError> {
Ok(())
}
async fn seed_submissions_locked(&self) -> Result<(), DbError> {
Ok(())
}
}
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![],
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");
}
#[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);
}
}
// ── 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);
}
}