//! HTTP request handlers for the `quotesdb` API. //! //! Each handler maps to one route in the API specification. The [`router`] //! function assembles the Axum [`Router`] with all routes in the required //! order — in particular, `GET /api/quotes/random` is registered **before** //! `GET /api/quotes/:id` to prevent "random" being captured as an id. //! //! All handlers share a [`crate::db::QuoteRepository`] via Axum's state //! mechanism, wrapped in an [`Arc`] to allow cheap cloning across tasks. use std::sync::Arc; use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Json, Response}, routing::{delete, get, post, put}, Router, }; use serde::{Deserialize, Serialize}; use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; use crate::db::{DeleteResult, QuoteRepository}; // ── Shared application state ────────────────────────────────────────────────── /// Type alias for the shared repository handle. /// /// `Send + Sync` are required by Axum's native router so the state can be /// shared across Tokio tasks. `NativeRepository` satisfies both bounds. type Repo = Arc; // ── Error response helpers ───────────────────────────────────────────────────── /// JSON envelope for all API error responses. /// /// Serialised as `{"error": "..."}` with the appropriate HTTP status code. #[derive(Debug, Serialize)] struct ErrorBody { error: String, } /// Build a JSON error response with the given status code and message. fn error_response(status: StatusCode, msg: impl Into) -> Response { (status, Json(ErrorBody { error: msg.into() })).into_response() } /// Map a [`crate::db::DbError`] to an appropriate HTTP error response. fn db_error_response(err: crate::db::DbError) -> Response { use crate::db::DbError; match err { DbError::NotFound => error_response(StatusCode::NOT_FOUND, "not found"), DbError::Forbidden => error_response(StatusCode::FORBIDDEN, "forbidden"), DbError::Internal(msg) => error_response(StatusCode::INTERNAL_SERVER_ERROR, msg), } } // ── Response types ──────────────────────────────────────────────────────────── /// Response body returned by the create (PUT) endpoint. /// /// Includes the full [`Quote`] plus the `auth_code` string (only time it is /// sent to the client). #[derive(Debug, Serialize)] struct CreateResponse { /// The created quote (without auth_code in the embedded struct). quote: Quote, /// The auth code for future update/delete operations. Store it. auth_code: String, } // ── Query parameter structs ──────────────────────────────────────────────────── /// Query parameters for `GET /api/quotes`. #[derive(Debug, Deserialize)] struct ListParams { /// 1-based page number. Defaults to 1. #[serde(default = "default_page")] page: u32, /// Filter by author name (case-insensitive). author: Option, /// Filter by tag. tag: Option, /// Only include quotes dated on or after this year. date_after_year: Option, /// Narrows after-bound to this month (1–12). Requires `date_after_year`. date_after_month: Option, /// Narrows after-bound to this day (1–31). Requires `date_after_year` and `date_after_month`. date_after_day: Option, /// Only include quotes dated on or before this year. date_before_year: Option, /// Narrows before-bound to this month (1–12). Requires `date_before_year`. date_before_month: Option, /// Narrows before-bound to this day (1–31). Requires `date_before_year` and `date_before_month`. date_before_day: Option, } fn default_page() -> u32 { 1 } /// Build an ISO date prefix string from optional year/month/day components. /// /// Returns `None` if no year is given. For before-bounds, missing month /// defaults to `12` and missing day defaults to `31` so the bound is /// inclusive of the entire specified year/month. /// /// # Examples /// /// ```ignore /// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string())); /// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string())); /// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string())); /// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string())); /// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None); /// ``` fn build_date_bound( year: Option, month: Option, day: Option, is_before: bool, ) -> Option { match (year, month, day) { (None, _, _) => None, (Some(y), None, _) => { if is_before { Some(format!("{y:04}-12-31")) } else { Some(format!("{y:04}")) } } (Some(y), Some(m), None) => { if is_before { Some(format!("{y:04}-{m:02}-31")) } else { Some(format!("{y:04}-{m:02}")) } } (Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")), } } // ── Handlers ────────────────────────────────────────────────────────────────── /// `GET /api/` — return the OpenAPI specification as JSON. /// /// The spec is embedded at compile time from `api/openapi.yaml` (converted to /// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw /// spec string. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn openapi_handler() -> Response { const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json")); ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], OPENAPI_JSON, ) .into_response() } /// `GET /api/quotes` — list quotes with optional filtering and pagination. /// /// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and /// month/day variants) query parameters. Defaults to page 1 and no filters. /// Returns [`crate::db::ListResult`] serialised as JSON. /// /// Returns `400 Bad Request` when date component ordering is violated (e.g. /// `date_after_month` provided without `date_after_year`). #[cfg_attr(target_arch = "wasm32", worker::send)] async fn list_handler(State(repo): State, Query(params): Query) -> Response { // Validate: month requires year, day requires year+month if params.date_after_month.is_some() && params.date_after_year.is_none() { return error_response( StatusCode::BAD_REQUEST, "date_after_month requires date_after_year", ); } if params.date_after_day.is_some() && (params.date_after_year.is_none() || params.date_after_month.is_none()) { return error_response( StatusCode::BAD_REQUEST, "date_after_day requires date_after_year and date_after_month", ); } if params.date_before_month.is_some() && params.date_before_year.is_none() { return error_response( StatusCode::BAD_REQUEST, "date_before_month requires date_before_year", ); } if params.date_before_day.is_some() && (params.date_before_year.is_none() || params.date_before_month.is_none()) { return error_response( StatusCode::BAD_REQUEST, "date_before_day requires date_before_year and date_before_month", ); } let date_after = build_date_bound( params.date_after_year, params.date_after_month, params.date_after_day, false, ); let date_before = build_date_bound( params.date_before_year, params.date_before_month, params.date_before_day, true, ); match repo .list_quotes( params.page, params.author.as_deref(), params.tag.as_deref(), date_after.as_deref(), date_before.as_deref(), ) .await { Ok(result) => (StatusCode::OK, Json(result)).into_response(), Err(e) => db_error_response(e), } } /// `GET /api/quotes/random` — return a random quote. /// /// Returns `404` when the database is empty. /// /// **Registration order:** this route must be registered before /// `GET /api/quotes/:id` in the router to avoid "random" being matched as an /// id parameter. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn random_handler(State(repo): State) -> Response { match repo.get_random_quote().await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "no quotes in database"), Err(e) => db_error_response(e), } } /// `GET /api/quotes/:id` — retrieve a single quote by NanoID. /// /// Returns `404` when no quote has the given id. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn get_quote_handler(State(repo): State, Path(id): Path) -> Response { match repo.get_quote(&id).await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "quote not found"), Err(e) => db_error_response(e), } } /// Verify a Cloudflare Turnstile token against the siteverify API. /// /// Returns `true` if the token is valid, `false` otherwise. /// Failures are treated conservatively as invalid (returns `false`). #[cfg(not(target_arch = "wasm32"))] async fn verify_turnstile(token: &str, secret: &str) -> bool { #[derive(serde::Deserialize)] struct TurnstileResponse { success: bool, } let params = [("secret", secret), ("response", token)]; let Ok(client) = reqwest::Client::builder().build() else { return false; }; let Ok(resp) = client .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") .form(¶ms) .send() .await else { return false; }; let Ok(body) = resp.json::().await else { return false; }; body.success } /// `PUT /api/quotes` — create a new quote. /// /// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created` /// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only /// time it is returned — the client must store it. /// /// When the `TURNSTILE_SECRET_KEY` environment variable is set, a valid /// Cloudflare Turnstile token must be provided in the `cf_turnstile_token` /// field. This check is skipped on wasm32 targets (Workers runtime). #[cfg_attr(target_arch = "wasm32", worker::send)] async fn create_handler(State(repo): State, Json(input): Json) -> Response { // Verify Cloudflare Turnstile token (native builds only; skipped on wasm32). #[cfg(not(target_arch = "wasm32"))] { if let Ok(secret) = std::env::var("TURNSTILE_SECRET_KEY") { let token = match input.cf_turnstile_token.as_deref() { Some(t) if !t.is_empty() => t.to_owned(), _ => { return error_response(StatusCode::BAD_REQUEST, "CAPTCHA token required") .into_response() } }; let verified = verify_turnstile(&token, &secret).await; if !verified { return error_response(StatusCode::FORBIDDEN, "CAPTCHA verification failed") .into_response(); } } } match repo.create_quote(input).await { Ok((quote, auth_code)) => ( StatusCode::CREATED, Json(CreateResponse { quote, auth_code }), ) .into_response(), Err(e) => db_error_response(e), } } /// Extract the `X-Auth-Code` header value from the request headers. /// /// Returns `None` if the header is absent or cannot be decoded as UTF-8. fn extract_auth_code(headers: &HeaderMap) -> Option { headers .get("X-Auth-Code") .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned()) } /// `POST /api/quotes/:id` — update an existing quote. /// /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// `404` if the quote does not exist, or `200` with the updated quote. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn update_handler( State(repo): State, Path(id): Path, headers: HeaderMap, Json(input): Json, ) -> Response { let Some(auth_code) = extract_auth_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required"); }; match repo.update_quote(&id, input, &auth_code).await { Ok(quote) => (StatusCode::OK, Json(quote)).into_response(), Err(e) => db_error_response(e), } } /// `DELETE /api/quotes/:id` — delete a quote. /// /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// `404` if not found, or `204 No Content` on success. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn delete_handler( State(repo): State, Path(id): Path, headers: HeaderMap, ) -> Response { let Some(auth_code) = extract_auth_code(&headers) else { return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required"); }; match repo.delete_quote(&id, &auth_code).await { Ok(DeleteResult::Deleted) => StatusCode::NO_CONTENT.into_response(), Ok(DeleteResult::NotFound) => error_response(StatusCode::NOT_FOUND, "quote not found"), Ok(DeleteResult::Forbidden) => error_response(StatusCode::FORBIDDEN, "forbidden"), Err(e) => db_error_response(e), } } // ── Router ──────────────────────────────────────────────────────────────────── /// Build the Axum [`Router`] with all API routes wired to their handlers. /// /// Route registration order is important: `GET /api/quotes/random` must /// appear before `GET /api/quotes/:id` so Axum's static segment wins over /// the dynamic `:id` capture. /// /// The repository must implement `Send + Sync` so it can be shared across /// Tokio tasks by Axum's state mechanism. [`NativeRepository`] satisfies /// both bounds via `tokio_rusqlite::Connection`. /// /// [`NativeRepository`]: crate::db::NativeRepository pub fn router(repo: Arc) -> Router { Router::new() // Meta .route("/api/", get(openapi_handler)) // 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>, } 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 { let quotes = self.quotes.lock().unwrap(); let all: Vec = quotes.iter().map(|(q, _)| q.clone()).collect(); Ok(ListResult { quotes: all.clone(), page, total_pages: 1, total_count: all.len() as u32, }) } async fn get_quote(&self, id: &str) -> Result, DbError> { let quotes = self.quotes.lock().unwrap(); Ok(quotes .iter() .find(|(q, _)| q.id == id) .map(|(q, _)| q.clone())) } async fn get_random_quote(&self) -> Result, DbError> { let quotes = self.quotes.lock().unwrap(); Ok(quotes.first().map(|(q, _)| q.clone())) } async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> { let auth = input .auth_code .clone() .unwrap_or_else(|| "test-auth".to_owned()); let quote = Quote { id: "test-id".to_owned(), text: input.text, author: input.author, source: input.source, date: input.date, tags: input.tags, created_at: "2024-01-01T00:00:00".to_owned(), updated_at: "2024-01-01T00:00:00".to_owned(), }; self.quotes .lock() .unwrap() .push((quote.clone(), auth.clone())); Ok((quote, auth)) } async fn update_quote( &self, id: &str, input: UpdateQuoteInput, auth_code: &str, ) -> Result { let mut quotes = self.quotes.lock().unwrap(); let entry = quotes.iter_mut().find(|(q, _)| q.id == id); match entry { None => Err(DbError::NotFound), Some((q, stored_auth)) => { if stored_auth.as_str() != auth_code { return Err(DbError::Forbidden); } if let Some(t) = input.text { q.text = t; } if let Some(a) = input.author { q.author = a; } q.source = input.source; q.date = input.date; if let Some(tags) = input.tags { q.tags = tags; } Ok(q.clone()) } } } async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { let mut quotes = self.quotes.lock().unwrap(); let pos = quotes.iter().position(|(q, _)| q.id == id); match pos { None => Ok(DeleteResult::NotFound), Some(i) => { let (_, stored) = "es[i]; if stored.as_str() != auth_code { return Ok(DeleteResult::Forbidden); } quotes.remove(i); Ok(DeleteResult::Deleted) } } } async fn get_admin_auth_code(&self) -> Result, DbError> { Ok(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 { Err(DbError::Forbidden) } async fn get_submissions_locked(&self) -> Result { 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) -> (StatusCode, String) { let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); let status = resp.status(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); (status, String::from_utf8_lossy(&body).to_string()) } #[tokio::test] async fn test_openapi_endpoint() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::GET) .uri("/api/") .body(Body::empty()) .unwrap(); let (status, body) = send(app, req).await; assert_eq!(status, StatusCode::OK); // Should be valid JSON let _: serde_json::Value = serde_json::from_str(&body).unwrap(); } #[tokio::test] async fn test_list_quotes() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let req = Request::builder() .method(Method::GET) .uri("/api/quotes") .body(Body::empty()) .unwrap(); let (status, body) = send(app, req).await; assert_eq!(status, StatusCode::OK); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(v["total_count"], 1); } #[tokio::test] async fn test_random_quote_not_found() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_random_quote_found() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::OK); } #[tokio::test] async fn test_get_quote_not_found() { let app = router(MockRepo::empty()); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/nonexistent") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_get_quote_found() { let app = router(MockRepo::with_quote(sample_quote(), "auth")); let req = Request::builder() .method(Method::GET) .uri("/api/quotes/abc-123") .body(Body::empty()) .unwrap(); let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::OK); } #[tokio::test] async fn test_create_quote() { let app = router(MockRepo::empty()); let body = serde_json::json!({ "text": "New quote", "author": "Author", "tags": [] }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(body.to_string())) .unwrap(); let (status, resp_body) = send(app, req).await; assert_eq!(status, StatusCode::CREATED); let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); assert!(v["auth_code"].is_string()); assert_eq!(v["quote"]["text"], "New quote"); } #[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 = Arc::new(repo); (router(repo), f) } // ── Body helpers ────────────────────────────────────────────────────────── /// Collect the full response body as raw bytes. async fn body_bytes(resp: axum::response::Response) -> Vec { axum::body::to_bytes(resp.into_body(), usize::MAX) .await .expect("failed to read response body") .to_vec() } /// Collect the full response body and parse it as a JSON value. async fn body_json(resp: axum::response::Response) -> serde_json::Value { let bytes = body_bytes(resp).await; serde_json::from_slice(&bytes).expect("response is not valid JSON") } // ── Quote creation helper ───────────────────────────────────────────────── /// Create a quote via PUT /api/quotes and return `(quote_json, auth_code)`. async fn create_quote_raw( app: Router, text: &str, author: &str, tags: &[&str], ) -> (Router, serde_json::Value, String) { let payload = json!({ "text": text, "author": author, "tags": tags, }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!( resp.status(), StatusCode::CREATED, "create_quote_raw: unexpected status" ); let v = body_json(resp).await; let auth_code = v["auth_code"].as_str().unwrap().to_owned(); let quote = v["quote"].clone(); (app, quote, auth_code) } // ── Ticket 789d0f: GET /api/ returns OpenAPI JSON ───────────────────────── /// GET /api/ must respond 200 with a JSON body containing the keys /// `openapi`, `info`, and `paths` required by the OpenAPI 3.x spec. #[tokio::test] async fn integration_openapi_spec_is_valid_json() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert!(v.get("openapi").is_some(), "missing 'openapi' key"); assert!(v.get("info").is_some(), "missing 'info' key"); assert!(v.get("paths").is_some(), "missing 'paths' key"); } // ── Ticket aa0eab: GET /api/quotes/random ───────────────────────────────── /// Random endpoint returns 404 when the database contains no quotes. #[tokio::test] async fn integration_random_empty_db_returns_404() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// Random endpoint returns 200 with a quote when the database has data. #[tokio::test] async fn integration_random_with_data_returns_200() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Cogito ergo sum", "Descartes", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert!(v.get("id").is_some(), "random quote must have id"); assert!(v.get("text").is_some(), "random quote must have text"); assert!(v.get("author").is_some(), "random quote must have author"); } // ── Ticket f9f448: GET /api/quotes/:id ──────────────────────────────────── /// GET /api/quotes/:id returns 404 for an ID that does not exist. #[tokio::test] async fn integration_get_quote_not_found() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/does-not-exist-at-all") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// GET /api/quotes/:id returns 200 with the full quote schema. #[tokio::test] async fn integration_get_quote_returns_correct_schema() { let (app, _f) = test_router().await; let (app, created, _auth) = create_quote_raw(app, "To be or not to be", "Shakespeare", &["classic"]).await; let id = created["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // All required fields must be present assert_eq!(v["id"], id); assert_eq!(v["text"], "To be or not to be"); assert_eq!(v["author"], "Shakespeare"); assert!(v.get("source").is_some(), "source field must be present"); assert!(v.get("date").is_some(), "date field must be present"); assert!(v.get("tags").is_some(), "tags field must be present"); assert!(v.get("created_at").is_some(), "created_at must be present"); assert!(v.get("updated_at").is_some(), "updated_at must be present"); assert_eq!(v["tags"], json!(["classic"])); } // ── Ticket 4a4c26: PUT /api/quotes ──────────────────────────────────────── /// Create a quote without providing auth_code; the server auto-generates /// a 4-word passphrase. #[tokio::test] async fn integration_create_quote_auto_auth_code() { let (app, _f) = test_router().await; let payload = json!({ "text": "Hello", "author": "World" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; let auth = v["auth_code"].as_str().expect("auth_code must be a string"); // Auto-generated codes have the pattern word-word-word-word let parts: Vec<&str> = auth.split('-').collect(); assert_eq!(parts.len(), 4, "auto auth_code must be 4 words: {auth}"); assert!(v["quote"]["id"].is_string(), "quote.id must be present"); } /// Create a quote with a custom auth_code; it must be echoed back. #[tokio::test] async fn integration_create_quote_custom_auth_code() { let (app, _f) = test_router().await; let payload = json!({ "text": "Custom auth", "author": "Tester", "auth_code": "my-custom-passphrase-code" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; assert_eq!(v["auth_code"], "my-custom-passphrase-code"); } /// PUT /api/quotes with missing required fields returns 422. #[tokio::test] async fn integration_create_quote_missing_required_fields() { let (app, _f) = test_router().await; // Missing both `text` and `author` let payload = json!({ "source": "somewhere" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } // ── Ticket 93f1b6: GET /api/quotes ──────────────────────────────────────── /// Page 1 returns at most 10 quotes even when more exist. #[tokio::test] async fn integration_list_quotes_pagination_page1() { let (app, _f) = test_router().await; // Insert 12 quotes let mut current_app = app; for i in 0..12 { let (next_app, _, _) = create_quote_raw(current_app, &format!("Quote {i}"), "Paginator", &[]).await; current_app = next_app; } let req = Request::builder() .method(Method::GET) .uri("/api/quotes?page=1") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(current_app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["quotes"].as_array().unwrap().len(), 10); assert_eq!(v["total_count"], 12); assert_eq!(v["total_pages"], 2); } /// A page beyond the last page returns an empty list (not an error). #[tokio::test] async fn integration_list_quotes_page_beyond_results() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Only one", "Solo", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?page=99") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["quotes"].as_array().unwrap().len(), 0); } /// `?author=` filter is case-insensitive. #[tokio::test] async fn integration_list_quotes_author_filter() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Upper Alice", "Alice", &[]).await; let (app, _, _) = create_quote_raw(app, "Lower alice", "alice", &[]).await; let (app, _, _) = create_quote_raw(app, "By Bob", "Bob", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?author=alice") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // Both "Alice" and "alice" should be returned assert_eq!(v["total_count"], 2); } /// `?tag=` filter returns only quotes that have the specified tag. #[tokio::test] async fn integration_list_quotes_tag_filter() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Tagged quote", "A", &["rust"]).await; let (app, _, _) = create_quote_raw(app, "Untagged quote", "B", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?tag=rust") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["total_count"], 1); assert_eq!(v["quotes"][0]["text"], "Tagged quote"); } /// List on an empty database returns an empty quotes array. #[tokio::test] async fn integration_list_quotes_empty_db() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["quotes"].as_array().unwrap().len(), 0); assert_eq!(v["total_count"], 0); } // ── Ticket fae330: POST /api/quotes/:id ─────────────────────────────────── /// Update succeeds when auth code is correct; updated fields are reflected. #[tokio::test] async fn integration_update_quote_success() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Original text", "Original Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let payload = json!({ "text": "Updated text" }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["text"], "Updated text"); // Author should remain unchanged assert_eq!(v["author"], "Original Author"); } /// Update returns 403 when the wrong auth code is provided. #[tokio::test] async fn integration_update_quote_wrong_auth() { let (app, _f) = test_router().await; let (app, quote, _auth) = create_quote_raw(app, "Protected", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let payload = json!({ "text": "Hacked" }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", "definitely-wrong-code") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } /// Update returns 404 for an ID that does not exist. #[tokio::test] async fn integration_update_quote_not_found() { let (app, _f) = test_router().await; let payload = json!({ "text": "Ghost update" }); let req = Request::builder() .method(Method::POST) .uri("/api/quotes/no-such-id-anywhere") .header("Content-Type", "application/json") .header("X-Auth-Code", "any-code") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// Partial update: only the provided fields change; omitted optional fields /// (tags in this case) remain unchanged. #[tokio::test] async fn integration_update_quote_partial_only_text_changes() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Original", "AuthorName", &["keep-this-tag"]).await; let id = quote["id"].as_str().unwrap().to_owned(); // Only update text; omit tags so they should remain let payload = json!({ "text": "New text", "author": "AuthorName" }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["text"], "New text"); // Tags not provided → tags remain unchanged assert_eq!(v["tags"], json!(["keep-this-tag"])); } /// Setting source to null in the update payload clears the field. #[tokio::test] async fn integration_update_quote_null_source_clears_it() { let (app, _f) = test_router().await; // Create a quote with a source let payload = json!({ "text": "Sourced quote", "author": "Writer", "source": "Some Book" }); let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; let id = v["quote"]["id"].as_str().unwrap().to_owned(); let auth = v["auth_code"].as_str().unwrap().to_owned(); // Now update with source: null to clear it let update = json!({ "source": null }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(update.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert!( v["source"].is_null(), "source should be null after clearing" ); } // ── Ticket 8c87db: DELETE /api/quotes/:id ───────────────────────────────── /// Delete returns 204 No Content when auth code matches. #[tokio::test] async fn integration_delete_quote_success() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Delete me", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/quotes/{id}")) .header("X-Auth-Code", &auth) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } /// Delete returns 403 when auth code is wrong. #[tokio::test] async fn integration_delete_quote_wrong_auth() { let (app, _f) = test_router().await; let (app, quote, _auth) = create_quote_raw(app, "Protected", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/quotes/{id}")) .header("X-Auth-Code", "totally-wrong-code-here") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } /// Delete returns 404 for a non-existent ID. #[tokio::test] async fn integration_delete_quote_not_found() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::DELETE) .uri("/api/quotes/ghost-id-not-here") .header("X-Auth-Code", "any") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// After a successful delete, GET /api/quotes/:id returns 404. #[tokio::test] async fn integration_delete_then_get_returns_404() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Ephemeral", "Author", &[]).await; let id = quote["id"].as_str().unwrap().to_owned(); // Delete let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/quotes/{id}")) .header("X-Auth-Code", &auth) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Now GET should 404 let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // ── Ticket 893eba: Tag operations ───────────────────────────────────────── /// Tags provided on create appear in the GET response. #[tokio::test] async fn integration_tags_on_create_appear_in_get() { let (app, _f) = test_router().await; let (app, quote, _auth) = create_quote_raw(app, "Tagged", "Tagger", &["alpha", "beta", "gamma"]).await; let id = quote["id"].as_str().unwrap().to_owned(); let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; let mut tags: Vec<&str> = v["tags"] .as_array() .unwrap() .iter() .map(|t| t.as_str().unwrap()) .collect(); tags.sort_unstable(); assert_eq!(tags, vec!["alpha", "beta", "gamma"]); } /// List quotes filtered by tag returns only quotes with that tag. #[tokio::test] async fn integration_tags_list_filter_by_tag() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Has tag", "A", &["special"]).await; let (app, _, _) = create_quote_raw(app, "No tag", "B", &[]).await; let (app, _, _) = create_quote_raw(app, "Other tag", "C", &["other"]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?tag=special") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["total_count"], 1); assert_eq!(v["quotes"][0]["text"], "Has tag"); } /// Updating tags replaces the entire previous tag set. #[tokio::test] async fn integration_tags_update_replaces_all_previous_tags() { let (app, _f) = test_router().await; let (app, quote, auth) = create_quote_raw(app, "Retag me", "Author", &["old1", "old2"]).await; let id = quote["id"].as_str().unwrap().to_owned(); let payload = json!({ "tags": ["new1", "new2", "new3"] }); let req = Request::builder() .method(Method::POST) .uri(format!("/api/quotes/{id}")) .header("Content-Type", "application/json") .header("X-Auth-Code", &auth) .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); // Fetch the quote and verify only new tags are present let req = Request::builder() .method(Method::GET) .uri(format!("/api/quotes/{id}")) .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); let v = body_json(resp).await; let mut tags: Vec<&str> = v["tags"] .as_array() .unwrap() .iter() .map(|t| t.as_str().unwrap()) .collect(); tags.sort_unstable(); assert_eq!(tags, vec!["new1", "new2", "new3"]); } // ── Ticket e8f5cf: Router ordering ──────────────────────────────────────── /// GET /api/quotes/random must be dispatched to the random handler, not /// the get-by-id handler. Verified by populating the DB and confirming a /// 200 response (the random handler returns 200; get-by-id for the literal /// string "random" would return 404 since no quote has that ID). #[tokio::test] async fn integration_router_random_not_matched_as_id() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_raw(app, "Some quote", "Some Author", &[]).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes/random") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); // If router order were wrong, this would be 404 (no quote with id="random"). // Correct routing gives 200 because the random handler picks a real quote. assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // The random handler returns the full Quote, not a CreateResponse assert!(v.get("id").is_some(), "should be a Quote, not an error"); } // ── Date range filter integration tests ─────────────────────────────────── /// Create a quote with a specific date via PUT /api/quotes. async fn create_quote_with_date( app: Router, text: &str, date: Option<&str>, ) -> (Router, serde_json::Value, String) { let mut payload = json!({ "text": text, "author": "DateAuthor", "tags": [], }); if let Some(d) = date { payload["date"] = json!(d); } let req = Request::builder() .method(Method::PUT) .uri("/api/quotes") .header("Content-Type", "application/json") .body(Body::from(payload.to_string())) .unwrap(); let resp = ServiceExt::>::oneshot(app.clone(), req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let v = body_json(resp).await; let auth_code = v["auth_code"].as_str().unwrap().to_owned(); let quote = v["quote"].clone(); (app, quote, auth_code) } /// `?date_after_year=` filters out quotes dated before that year. #[tokio::test] async fn integration_date_filter_after_year() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await; let (app, _, _) = create_quote_with_date(app, "No date quote", None).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_after_year=2000") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // Only the 2020 quote qualifies; 1990 is before 2000, no-date excluded assert_eq!(v["total_count"], 1); assert_eq!(v["quotes"][0]["text"], "New quote"); } /// `?date_before_year=` filters out quotes dated after that year. #[tokio::test] async fn integration_date_filter_before_year() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await; let (app, _, _) = create_quote_with_date(app, "No date quote", None).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_before_year=2000") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; // Only the 1990 quote qualifies; 2020 is after 2000-12-31, no-date excluded assert_eq!(v["total_count"], 1); assert_eq!(v["quotes"][0]["text"], "Old quote"); } /// `?date_after_year=&date_before_year=` combined bounds narrow the window. #[tokio::test] async fn integration_date_filter_range() { let (app, _f) = test_router().await; let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; let (app, _, _) = create_quote_with_date(app, "Mid quote", Some("2000-06-15")).await; let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-12-31")).await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_after_year=1995&date_before_year=2010") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let v = body_json(resp).await; assert_eq!(v["total_count"], 1); assert_eq!(v["quotes"][0]["text"], "Mid quote"); } /// `?date_after_month=` without a year returns 400 Bad Request. #[tokio::test] async fn integration_date_filter_month_without_year_returns_400() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_after_month=6") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } /// `?date_before_day=` without year+month returns 400 Bad Request. #[tokio::test] async fn integration_date_filter_day_without_year_month_returns_400() { let (app, _f) = test_router().await; let req = Request::builder() .method(Method::GET) .uri("/api/quotes?date_before_day=15") .body(Body::empty()) .unwrap(); let resp = ServiceExt::>::oneshot(app, req) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } }