|
|
//! HTTP request handlers for the `quotesdb` API.
|
|
|
//!
|
|
|
//! Each handler maps to one route in the API specification. The [`router`]
|
|
|
//! function assembles the Axum [`Router`] with all routes in the required
|
|
|
//! order — in particular, `GET /api/quotes/random` is registered **before**
|
|
|
//! `GET /api/quotes/:id` to prevent "random" being captured as an id.
|
|
|
//!
|
|
|
//! All handlers share a [`crate::db::QuoteRepository`] via Axum's state
|
|
|
//! mechanism, wrapped in an [`Arc`] to allow cheap cloning across tasks.
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
use axum::{
|
|
|
extract::{Path, Query, State},
|
|
|
http::{HeaderMap, StatusCode},
|
|
|
response::{IntoResponse, Json, Response},
|
|
|
routing::{delete, get, post, put},
|
|
|
Router,
|
|
|
};
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
|
|
|
|
|
|
use crate::db::{DeleteResult, QuoteRepository};
|
|
|
|
|
|
// ── Shared application state ──────────────────────────────────────────────────
|
|
|
|
|
|
/// Type alias for the shared repository handle.
|
|
|
///
|
|
|
/// `Send + Sync` are required by Axum's native router so the state can be
|
|
|
/// shared across Tokio tasks. `NativeRepository` satisfies both bounds.
|
|
|
type Repo = Arc<dyn QuoteRepository + Send + Sync>;
|
|
|
|
|
|
// ── Error response helpers ─────────────────────────────────────────────────────
|
|
|
|
|
|
/// JSON envelope for all API error responses.
|
|
|
///
|
|
|
/// Serialised as `{"error": "..."}` with the appropriate HTTP status code.
|
|
|
#[derive(Debug, Serialize)]
|
|
|
struct ErrorBody {
|
|
|
error: String,
|
|
|
}
|
|
|
|
|
|
/// Build a JSON error response with the given status code and message.
|
|
|
fn error_response(status: StatusCode, msg: impl Into<String>) -> Response {
|
|
|
(status, Json(ErrorBody { error: msg.into() })).into_response()
|
|
|
}
|
|
|
|
|
|
/// Map a [`crate::db::DbError`] to an appropriate HTTP error response.
|
|
|
fn db_error_response(err: crate::db::DbError) -> Response {
|
|
|
use crate::db::DbError;
|
|
|
match err {
|
|
|
DbError::NotFound => error_response(StatusCode::NOT_FOUND, "not found"),
|
|
|
DbError::Forbidden => error_response(StatusCode::FORBIDDEN, "forbidden"),
|
|
|
DbError::Internal(msg) => error_response(StatusCode::INTERNAL_SERVER_ERROR, msg),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Response types ────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Response body returned by the create (PUT) endpoint.
|
|
|
///
|
|
|
/// Includes the full [`Quote`] plus the `auth_code` string (only time it is
|
|
|
/// sent to the client).
|
|
|
#[derive(Debug, Serialize)]
|
|
|
struct CreateResponse {
|
|
|
/// The created quote (without auth_code in the embedded struct).
|
|
|
quote: Quote,
|
|
|
/// The auth code for future update/delete operations. Store it.
|
|
|
auth_code: String,
|
|
|
}
|
|
|
|
|
|
// ── Query parameter structs ────────────────────────────────────────────────────
|
|
|
|
|
|
/// Query parameters for `GET /api/quotes`.
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
struct ListParams {
|
|
|
/// 1-based page number. Defaults to 1.
|
|
|
#[serde(default = "default_page")]
|
|
|
page: u32,
|
|
|
/// Filter by author name (case-insensitive).
|
|
|
author: Option<String>,
|
|
|
/// Filter by tag.
|
|
|
tag: Option<String>,
|
|
|
/// Only include quotes dated on or after this year.
|
|
|
date_after_year: Option<u16>,
|
|
|
/// Narrows after-bound to this month (1–12). Requires `date_after_year`.
|
|
|
date_after_month: Option<u8>,
|
|
|
/// Narrows after-bound to this day (1–31). Requires `date_after_year` and `date_after_month`.
|
|
|
date_after_day: Option<u8>,
|
|
|
/// Only include quotes dated on or before this year.
|
|
|
date_before_year: Option<u16>,
|
|
|
/// Narrows before-bound to this month (1–12). Requires `date_before_year`.
|
|
|
date_before_month: Option<u8>,
|
|
|
/// Narrows before-bound to this day (1–31). Requires `date_before_year` and `date_before_month`.
|
|
|
date_before_day: Option<u8>,
|
|
|
}
|
|
|
|
|
|
fn default_page() -> u32 {
|
|
|
1
|
|
|
}
|
|
|
|
|
|
/// Build an ISO date prefix string from optional year/month/day components.
|
|
|
///
|
|
|
/// Returns `None` if no year is given. For before-bounds, missing month
|
|
|
/// defaults to `12` and missing day defaults to `31` so the bound is
|
|
|
/// inclusive of the entire specified year/month.
|
|
|
///
|
|
|
/// # Examples
|
|
|
///
|
|
|
/// ```ignore
|
|
|
/// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string()));
|
|
|
/// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string()));
|
|
|
/// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string()));
|
|
|
/// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string()));
|
|
|
/// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None);
|
|
|
/// ```
|
|
|
fn build_date_bound(
|
|
|
year: Option<u16>,
|
|
|
month: Option<u8>,
|
|
|
day: Option<u8>,
|
|
|
is_before: bool,
|
|
|
) -> Option<String> {
|
|
|
match (year, month, day) {
|
|
|
(None, _, _) => None,
|
|
|
(Some(y), None, _) => {
|
|
|
if is_before {
|
|
|
Some(format!("{y:04}-12-31"))
|
|
|
} else {
|
|
|
Some(format!("{y:04}"))
|
|
|
}
|
|
|
}
|
|
|
(Some(y), Some(m), None) => {
|
|
|
if is_before {
|
|
|
Some(format!("{y:04}-{m:02}-31"))
|
|
|
} else {
|
|
|
Some(format!("{y:04}-{m:02}"))
|
|
|
}
|
|
|
}
|
|
|
(Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// `GET /api/` — return the OpenAPI specification as JSON.
|
|
|
///
|
|
|
/// The spec is embedded at compile time from `api/openapi.yaml` (converted to
|
|
|
/// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw
|
|
|
/// spec string.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn openapi_handler() -> Response {
|
|
|
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
|
|
|
(
|
|
|
StatusCode::OK,
|
|
|
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
|
|
OPENAPI_JSON,
|
|
|
)
|
|
|
.into_response()
|
|
|
}
|
|
|
|
|
|
/// `GET /api/quotes` — list quotes with optional filtering and pagination.
|
|
|
///
|
|
|
/// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and
|
|
|
/// month/day variants) query parameters. Defaults to page 1 and no filters.
|
|
|
/// Returns [`crate::db::ListResult`] serialised as JSON.
|
|
|
///
|
|
|
/// Returns `400 Bad Request` when date component ordering is violated (e.g.
|
|
|
/// `date_after_month` provided without `date_after_year`).
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>) -> Response {
|
|
|
// Validate: month requires year, day requires year+month
|
|
|
if params.date_after_month.is_some() && params.date_after_year.is_none() {
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_after_month requires date_after_year",
|
|
|
);
|
|
|
}
|
|
|
if params.date_after_day.is_some()
|
|
|
&& (params.date_after_year.is_none() || params.date_after_month.is_none())
|
|
|
{
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_after_day requires date_after_year and date_after_month",
|
|
|
);
|
|
|
}
|
|
|
if params.date_before_month.is_some() && params.date_before_year.is_none() {
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_before_month requires date_before_year",
|
|
|
);
|
|
|
}
|
|
|
if params.date_before_day.is_some()
|
|
|
&& (params.date_before_year.is_none() || params.date_before_month.is_none())
|
|
|
{
|
|
|
return error_response(
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
"date_before_day requires date_before_year and date_before_month",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
let date_after = build_date_bound(
|
|
|
params.date_after_year,
|
|
|
params.date_after_month,
|
|
|
params.date_after_day,
|
|
|
false,
|
|
|
);
|
|
|
let date_before = build_date_bound(
|
|
|
params.date_before_year,
|
|
|
params.date_before_month,
|
|
|
params.date_before_day,
|
|
|
true,
|
|
|
);
|
|
|
|
|
|
match repo
|
|
|
.list_quotes(
|
|
|
params.page,
|
|
|
params.author.as_deref(),
|
|
|
params.tag.as_deref(),
|
|
|
date_after.as_deref(),
|
|
|
date_before.as_deref(),
|
|
|
)
|
|
|
.await
|
|
|
{
|
|
|
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `GET /api/quotes/random` — return a random quote.
|
|
|
///
|
|
|
/// Returns `404` when the database is empty.
|
|
|
///
|
|
|
/// **Registration order:** this route must be registered before
|
|
|
/// `GET /api/quotes/:id` in the router to avoid "random" being matched as an
|
|
|
/// id parameter.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn random_handler(State(repo): State<Repo>) -> Response {
|
|
|
match repo.get_random_quote().await {
|
|
|
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "no quotes in database"),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `GET /api/quotes/:id` — retrieve a single quote by NanoID.
|
|
|
///
|
|
|
/// Returns `404` when no quote has the given id.
|
|
|
#[cfg_attr(target_arch = "wasm32", worker::send)]
|
|
|
async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) -> Response {
|
|
|
match repo.get_quote(&id).await {
|
|
|
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
|
|
|
Ok(None) => error_response(StatusCode::NOT_FOUND, "quote not found"),
|
|
|
Err(e) => db_error_response(e),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Verify a Cloudflare Turnstile token against the siteverify API.
|
|
|
///
|
|
|
/// Returns `true` if the token is valid, `false` otherwise.
|
|
|
/// Failures are treated conservatively as invalid (returns `false`).
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
async fn verify_turnstile(token: &str, secret: &str) -> bool {
|
|
|
#[derive(serde::Deserialize)]
|
|
|
struct TurnstileResponse {
|
|
|
success: bool,
|
|
|
}
|
|
|
|
|
|
let params = [("secret", secret), ("response", token)];
|
|
|
let Ok(client) = reqwest::Client::builder().build() else {
|
|
|
return false;
|
|
|
};
|
|
|
let Ok(resp) = client
|
|
|
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
|
|
.form(¶ms)
|
|
|
.send()
|
|
|
.await
|
|
|
else {
|
|
|
return false;
|
|
|
};
|
|
|
let Ok(body) = resp.json::<TurnstileResponse>().await else {
|
|
|
return false;
|
|
|
};
|
|
|
body.success
|
|
|
}
|
|
|
|
|
|
/// `PUT /api/quotes` — create a new quote.
|
|
|
///
|
|
|
/// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created`
|
|
|
/// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only
|
|
|
/// time it is returned — the client must store it.
|
|
|
///
|
|
|
/// 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) = "es[i];
|
|
|
if stored.as_str() != auth_code {
|
|
|
return Ok(DeleteResult::Forbidden);
|
|
|
}
|
|
|
quotes.remove(i);
|
|
|
Ok(DeleteResult::Deleted)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
|
|
|
Ok(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);
|
|
|
}
|
|
|
}
|