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.

1316 lines
50 KiB
Rust

//! 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>,
}
fn default_page() -> u32 {
1
}
// ── 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` query parameters. Defaults to page 1 and
/// no filters. Returns [`crate::db::ListResult`] serialised as JSON.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>) -> Response {
match repo
.list_quotes(params.page, params.author.as_deref(), params.tag.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),
}
}
/// `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.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn create_handler(State(repo): State<Repo>, Json(input): Json<CreateQuoteInput>) -> 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>,
) -> 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)
}
}
}
}
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");
}
}