From b00f24ae8574c7cc660da9d6b040b80b33c6bbf7 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 9 Mar 2026 20:36:34 -0700 Subject: [PATCH] fix(quotesdb): fix D1 exec() newline truncation in migrations, add justfile and migrate command D1's exec() treats newlines as statement separators, causing multiline CREATE TABLE statements to be truncated after the first line and return "incomplete input: SQLITE_ERROR" on every request. Fix: switch run_migrations() in D1Repository to use prepare(sql).run() instead of exec(sql), which treats the full string as a single statement. Also moves db and handlers modules from src/bin/api/ to src/ (library modules), adds justfile with build/deploy/migrate recipes, adds migrations/schema.sql for direct wrangler d1 execute usage, and adds wrangler.toml for worker deployment configuration. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/justfile | 30 + quotesdb/migrations/schema.sql | 40 + quotesdb/src/db/connection.rs | 47 + quotesdb/src/db/d1.rs | 947 ++++++++++ quotesdb/src/db/migrations.rs | 73 + quotesdb/src/db/mod.rs | 275 +++ quotesdb/src/db/native.rs | 1524 +++++++++++++++ quotesdb/src/handlers/mod.rs | 3156 ++++++++++++++++++++++++++++++++ quotesdb/wrangler.toml | 23 + 9 files changed, 6115 insertions(+) create mode 100644 quotesdb/justfile create mode 100644 quotesdb/migrations/schema.sql create mode 100644 quotesdb/src/db/connection.rs create mode 100644 quotesdb/src/db/d1.rs create mode 100644 quotesdb/src/db/migrations.rs create mode 100644 quotesdb/src/db/mod.rs create mode 100644 quotesdb/src/db/native.rs create mode 100644 quotesdb/src/handlers/mod.rs create mode 100644 quotesdb/wrangler.toml diff --git a/quotesdb/justfile b/quotesdb/justfile new file mode 100644 index 0000000..03d2611 --- /dev/null +++ b/quotesdb/justfile @@ -0,0 +1,30 @@ +build: build-ui build-api + +migrate: + # Apply schema migrations to the remote D1 database. + # The ALTER TABLE at the end will error if hidden already exists — that's expected. + wrangler d1 execute quotesdb --remote --file migrations/schema.sql + +build-api: + # worker-build runs wasm-bindgen to produce ES-module-compatible output in build/worker/. + # Install once with: cargo install worker-build + # --features workers-api enables D1 bindings and route handlers for the Workers runtime. + worker-build --release -- --features workers-api + +build-ui: + trunk build --release + +deploy: deploy-ui deploy-api + +deploy-ui: + # --commit-dirty=true silences the git-dirty warning (expected during local deploys). + wrangler pages deploy dist --project-name quotesdb-ui --branch quotesdb --commit-dirty=true + +deploy-api: + wrangler deploy --config wrangler.toml + +run-api: + cargo run --features workers-api + +run-ui: + trunk serve diff --git a/quotesdb/migrations/schema.sql b/quotesdb/migrations/schema.sql new file mode 100644 index 0000000..51e9486 --- /dev/null +++ b/quotesdb/migrations/schema.sql @@ -0,0 +1,40 @@ +-- quotesdb D1 schema migrations +-- Run with: wrangler d1 execute quotesdb --remote --file migrations/schema.sql +-- All CREATE statements are idempotent (IF NOT EXISTS). +-- The ALTER TABLE is not idempotent; errors are expected on re-runs (column already exists). + +CREATE TABLE IF NOT EXISTS quotes ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + author TEXT NOT NULL, + source TEXT, + date TEXT, + auth_code TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS quote_tags ( + quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (quote_id, tag) +); + +CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id); + +CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS admin_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS reports ( + id TEXT PRIMARY KEY, + quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, + reason TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Add hidden column to existing databases (ignore error if column already exists). +ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0; diff --git a/quotesdb/src/db/connection.rs b/quotesdb/src/db/connection.rs new file mode 100644 index 0000000..14cae3f --- /dev/null +++ b/quotesdb/src/db/connection.rs @@ -0,0 +1,47 @@ +//! Database connection setup for the native API server. +//! +//! Provides [`open`] which opens a `tokio-rusqlite` connection, configures +//! SQLite pragmas for WAL mode and foreign key enforcement, and wraps the +//! result in a [`NativeRepository`]. + +use super::{DbError, NativeRepository}; +use tokio_rusqlite::Connection; + +/// Open a SQLite database at `path` and return a configured [`NativeRepository`]. +/// +/// This function: +/// 1. Opens the file-backed SQLite connection via `tokio_rusqlite::Connection::open`. +/// 2. Enables Write-Ahead Logging (`PRAGMA journal_mode=WAL`) for better +/// concurrent read performance. +/// 3. Enables foreign key enforcement (`PRAGMA foreign_keys=ON`) so that +/// `ON DELETE CASCADE` works on the `quote_tags` table. +/// +/// Returns `Err(DbError::Internal(...))` if the file cannot be opened or if +/// the pragma commands fail. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// use quotesdb::db::QuoteRepository as _; +/// let repo = quotesdb::db::connection::open("quotesdb.sqlite").await?; +/// repo.run_migrations().await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn open(path: &str) -> Result { + let conn = Connection::open(path) + .await + .map_err(|e| DbError::Internal(format!("failed to open database: {e}")))?; + + // Configure SQLite pragmas on the connection thread + conn.call(|c| { + // WAL mode improves concurrent reader throughput + c.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(format!("pragma configuration failed: {e}")))?; + + Ok(NativeRepository::new(conn)) +} diff --git a/quotesdb/src/db/d1.rs b/quotesdb/src/db/d1.rs new file mode 100644 index 0000000..0f4c6a0 --- /dev/null +++ b/quotesdb/src/db/d1.rs @@ -0,0 +1,947 @@ +//! Cloudflare D1 repository implementation (wasm32 only). +//! +//! [`D1Repository`] wraps a [`worker::d1::Database`] and provides full +//! implementations of all [`super::QuoteRepository`] methods using the +//! Cloudflare D1 API from workers-rs 0.5. +//! +//! This module is only compiled for `wasm32-unknown-unknown` targets. + +use super::{DbError, DeleteResult, ListResult, QuoteRepository}; +use crate::{generate_auth_code, generate_id, CreateQuoteInput, Quote, UpdateQuoteInput}; +use wasm_bindgen::JsValue; + +// ── Helper structs ──────────────────────────────────────────────────────────── + +/// Row shape for quote SELECT queries. +#[derive(Debug, serde::Deserialize)] +struct QuoteRow { + id: String, + text: String, + author: String, + source: Option, + date: Option, + /// Stored as an integer (0 = visible, 1 = hidden); converted to bool on deserialization. + hidden: i64, + created_at: String, + updated_at: String, +} + +impl QuoteRow { + /// Convert this row into a [`Quote`] by attaching a pre-fetched tags list. + fn into_quote(self, tags: Vec) -> Quote { + Quote { + id: self.id, + text: self.text, + author: self.author, + source: self.source, + date: self.date, + hidden: self.hidden != 0, + created_at: self.created_at, + updated_at: self.updated_at, + tags, + } + } +} + +/// Row shape for auth_code lookups. +#[derive(Debug, serde::Deserialize)] +struct AuthRow { + auth_code: String, +} + +/// Row shape for tag lookups. +#[derive(Debug, serde::Deserialize)] +struct TagRow { + tag: String, +} + +/// Row shape for COUNT(*) queries. +#[derive(Debug, serde::Deserialize)] +struct CountRow { + count: u32, +} + +// ── Repository struct ───────────────────────────────────────────────────────── + +/// Cloudflare D1-backed repository (wasm32 only). +/// +/// Wraps a [`worker::d1::D1Database`] handle provided by the Workers runtime. +/// All methods use the D1 prepared-statement API to execute SQL queries. +pub struct D1Repository { + /// The Cloudflare D1 database handle. + pub db: worker::d1::D1Database, +} + +// SAFETY: wasm32-unknown-unknown is single-threaded; JS values are never sent +// across threads. Required to satisfy Arc. +unsafe impl Send for D1Repository {} +unsafe impl Sync for D1Repository {} + +impl D1Repository { + /// Create a new [`D1Repository`] wrapping the given D1 database handle. + pub fn new(db: worker::d1::D1Database) -> Self { + Self { db } + } + + /// Fetch all tags for a quote, sorted alphabetically. + /// + /// Returns a sorted `Vec` of tag values, or an empty vec if none exist. + async fn fetch_tags(&self, id: &str) -> Result, DbError> { + let rows = self + .db + .prepare("SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + Ok(rows.into_iter().map(|r| r.tag).collect()) + } +} + +// ── QuoteRepository impl ────────────────────────────────────────────────────── + +#[async_trait::async_trait(?Send)] +impl QuoteRepository for D1Repository { + /// Run all DDL migration statements from [`super::migrations`]. + /// + /// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`, + /// and the `hidden` column ALTER. Safe to call multiple times — CREATE + /// statements use `IF NOT EXISTS`. The ALTER TABLE error (column already + /// exists) is intentionally ignored for idempotency. + /// + /// Uses `prepare(sql).run()` instead of `exec(sql)` because D1's `exec()` + /// treats newlines as statement separators, which truncates multiline DDL + /// statements. `prepare().run()` treats the entire string as one statement. + async fn run_migrations(&self) -> Result<(), DbError> { + use super::migrations::*; + for sql in &[ + CREATE_QUOTES, + CREATE_QUOTE_TAGS, + CREATE_TAG_INDEX, + CREATE_AUTHOR_INDEX, + CREATE_ADMIN_CONFIG, + CREATE_REPORTS, + ] { + self.db + .prepare(*sql) + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + } + // ALTER TABLE does not support IF NOT EXISTS — ignore the error when + // the column already exists (idempotent on re-runs). + let _ = self.db.prepare(ALTER_QUOTES_ADD_HIDDEN).run().await; // ignore "column exists" error + Ok(()) + } + + /// List quotes with optional author/tag/date filters and 1-based pagination. + /// + /// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering. + /// `date_after` and `date_before` are ISO date prefix strings compared via + /// `>=` / `<=` against the stored `date` column; rows where `date IS NULL` + /// are excluded when either bound is set. + /// Tags for each returned quote are fetched in a second query per quote to + /// avoid duplicate rows from a JOIN. + async fn list_quotes( + &self, + page: u32, + author: Option<&str>, + tag: Option<&str>, + date_after: Option<&str>, + date_before: Option<&str>, + ) -> Result { + const PAGE_SIZE: u32 = 10; + let page = page.max(1); + + // ── Build WHERE clause with positional params ────────────────────── + // Always exclude hidden quotes from listing endpoints. + let mut conditions: Vec = vec!["q.hidden = 0".to_owned()]; + let mut binds: Vec = Vec::new(); + let mut param_idx: u32 = 1; + + if let Some(a) = author { + conditions.push(format!("q.author = ?{param_idx} COLLATE NOCASE")); + binds.push(JsValue::from_str(a)); + param_idx += 1; + } + if let Some(t) = tag { + conditions.push(format!( + "q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?{param_idx})" + )); + binds.push(JsValue::from_str(t)); + param_idx += 1; + } + // Exclude NULL dates when any date bound is active + if date_after.is_some() || date_before.is_some() { + conditions.push("q.date IS NOT NULL".to_owned()); + } + if let Some(da) = date_after { + conditions.push(format!("q.date >= ?{param_idx}")); + binds.push(JsValue::from_str(da)); + param_idx += 1; + } + if let Some(db) = date_before { + conditions.push(format!("q.date <= ?{param_idx}")); + binds.push(JsValue::from_str(db)); + param_idx += 1; + } + + let where_clause = format!("WHERE {}", conditions.join(" AND ")); + + // ── Count total matching rows ────────────────────────────────────── + let count_sql = format!("SELECT COUNT(*) as count FROM quotes q {where_clause}"); + let count_row = self + .db + .prepare(&count_sql) + .bind(&binds) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .ok_or_else(|| DbError::Internal("count query returned no row".to_string()))?; + + let total_count = count_row.count; + let total_pages = ((total_count + PAGE_SIZE - 1) / PAGE_SIZE).max(1); + let offset = (page - 1) * PAGE_SIZE; + + // ── Fetch the page of quotes ─────────────────────────────────────── + let list_sql = format!( + "SELECT q.id, q.text, q.author, q.source, q.date, \ + q.hidden, q.created_at, q.updated_at \ + FROM quotes q {where_clause} \ + ORDER BY q.created_at DESC \ + LIMIT ?{param_idx} OFFSET ?{}", + param_idx + 1 + ); + + let mut list_binds = binds.clone(); + list_binds.push(JsValue::from_f64(PAGE_SIZE as f64)); + list_binds.push(JsValue::from_f64(offset as f64)); + + let rows = self + .db + .prepare(&list_sql) + .bind(&list_binds) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + + // Second pass: fetch tags for each quote + let mut quotes: Vec = Vec::with_capacity(rows.len()); + for row in rows { + let tags = self.fetch_tags(&row.id).await?; + quotes.push(row.into_quote(tags)); + } + + Ok(ListResult { + quotes, + page, + total_pages, + total_count, + }) + } + + /// Retrieve a single quote by its primary key. + /// + /// Returns `Ok(None)` when no row matches `id`. + async fn get_quote(&self, id: &str) -> Result, DbError> { + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match row { + None => Ok(None), + Some(r) => { + let tags = self.fetch_tags(&r.id).await?; + Ok(Some(r.into_quote(tags))) + } + } + } + + /// Return one quote chosen at random. + /// + /// Returns `Ok(None)` when the `quotes` table is empty. + async fn get_random_quote(&self) -> Result, DbError> { + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1", + ) + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match row { + None => Ok(None), + Some(r) => { + let tags = self.fetch_tags(&r.id).await?; + Ok(Some(r.into_quote(tags))) + } + } + } + + /// Insert a new quote row and its associated tags. + /// + /// If `input.auth_code` is `None`, a 4-word passphrase is generated. + /// Returns the persisted [`Quote`] (without `auth_code`) and the raw + /// auth-code string so the caller can include it in the creation response. + async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> { + let id = generate_id(); + let auth_code = input.auth_code.unwrap_or_else(generate_auth_code); + + // Insert the quote row + self.db + .prepare( + "INSERT INTO quotes (id, text, author, source, date, auth_code) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind(&[ + JsValue::from_str(&id), + JsValue::from_str(&input.text), + JsValue::from_str(&input.author), + input + .source + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + input + .date + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + JsValue::from_str(&auth_code), + ]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + // Batch insert tags + if !input.tags.is_empty() { + let tag_stmts: Vec = input + .tags + .iter() + .map(|tag| { + self.db + .prepare("INSERT OR IGNORE INTO quote_tags (quote_id, tag) VALUES (?1, ?2)") + .bind(&[JsValue::from_str(&id), JsValue::from_str(tag)]) + .map_err(|e| DbError::Internal(e.to_string())) + }) + .collect::, _>>()?; + self.db + .batch(tag_stmts) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + } + + // Read back the row to get server-generated timestamps + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(&id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .ok_or_else(|| DbError::Internal("inserted row not found on read-back".to_string()))?; + + let tags = self.fetch_tags(&id).await?; + Ok((row.into_quote(tags), auth_code)) + } + + /// Update non-`None` fields on an existing quote. + /// + /// Verifies `auth_code` before making any changes. If `input.tags` is + /// `Some`, the entire tag set is replaced. Updates `updated_at` to the + /// current UTC time. + async fn update_quote( + &self, + id: &str, + input: UpdateQuoteInput, + auth_code: &str, + ) -> Result { + // Phase 1: fetch stored auth_code + let auth_row = self + .db + .prepare("SELECT auth_code FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match auth_row { + None => return Err(DbError::NotFound), + Some(ref r) if r.auth_code == auth_code => {} // exact match, proceed + Some(_) => { + // Check admin code fallback + let admin = self.get_admin_auth_code().await?; + if admin.as_deref() != Some(auth_code) { + return Err(DbError::Forbidden); + } + } + } + + // Phase 2: build dynamic SET clause with positional params + let mut sets: Vec = Vec::new(); + let mut binds: Vec = Vec::new(); + let mut param_idx: u32 = 1; + + if let Some(ref text) = input.text { + sets.push(format!("text = ?{param_idx}")); + binds.push(JsValue::from_str(text)); + param_idx += 1; + } + if let Some(ref author) = input.author { + sets.push(format!("author = ?{param_idx}")); + binds.push(JsValue::from_str(author)); + param_idx += 1; + } + // source and date always updated (None clears the field) + sets.push(format!("source = ?{param_idx}")); + binds.push( + input + .source + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + ); + param_idx += 1; + + sets.push(format!("date = ?{param_idx}")); + binds.push( + input + .date + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + ); + param_idx += 1; + + // hidden is only updated when explicitly provided + if let Some(h) = input.hidden { + sets.push(format!("hidden = ?{param_idx}")); + binds.push(JsValue::from_f64(if h { 1.0 } else { 0.0 })); + param_idx += 1; + } + + sets.push("updated_at = datetime('now')".to_string()); + + let update_sql = format!( + "UPDATE quotes SET {} WHERE id = ?{param_idx}", + sets.join(", ") + ); + binds.push(JsValue::from_str(id)); + + self.db + .prepare(&update_sql) + .bind(&binds) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + // Phase 3: replace tags if provided + if let Some(ref tags) = input.tags { + self.db + .prepare("DELETE FROM quote_tags WHERE quote_id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + if !tags.is_empty() { + let tag_stmts: Vec = tags + .iter() + .map(|tag| { + self.db + .prepare( + "INSERT OR IGNORE INTO quote_tags (quote_id, tag) \ + VALUES (?1, ?2)", + ) + .bind(&[JsValue::from_str(id), JsValue::from_str(tag)]) + .map_err(|e| DbError::Internal(e.to_string())) + }) + .collect::, _>>()?; + self.db + .batch(tag_stmts) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + } + } + + // Phase 4: read back the updated quote + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .ok_or(DbError::NotFound)?; + + let fetched_tags = self.fetch_tags(&row.id).await?; + Ok(row.into_quote(fetched_tags)) + } + + /// Delete a quote by ID after verifying the auth code. + /// + /// Returns [`DeleteResult::NotFound`] if no quote has that ID, + /// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the + /// admin super auth code matches, or [`DeleteResult::Deleted`] on success. + /// Tags are removed automatically by the `ON DELETE CASCADE` constraint on + /// `quote_tags`. + async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { + // Fetch stored auth_code + let auth_row = self + .db + .prepare("SELECT auth_code FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match auth_row { + None => return Ok(DeleteResult::NotFound), + Some(ref r) if r.auth_code == auth_code => { + // Per-quote auth matches — fall through to delete + } + Some(_) => { + // Check admin code as fallback + let admin = self.get_admin_auth_code().await?; + if admin.as_deref() != Some(auth_code) { + return Ok(DeleteResult::Forbidden); + } + } + } + + self.db + .prepare("DELETE FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + Ok(DeleteResult::Deleted) + } + + /// Retrieve the admin super auth code from `admin_config`. + /// + /// Returns `Ok(None)` if the admin code has not been seeded yet. + async fn get_admin_auth_code(&self) -> Result, DbError> { + #[derive(serde::Deserialize)] + struct ValueRow { + value: String, + } + + self.db + .prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'") + .first::(None) + .await + .map(|opt| opt.map(|r| r.value)) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Insert the admin auth code if not already present. + /// + /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. + async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> { + self.db + .prepare( + "INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)", + ) + .bind(&[JsValue::from_str(code)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Replace the admin auth code if `current` matches the stored value. + /// + /// Generates a fresh 4-word passphrase when `new_code` is `None`. + /// + /// The check and update are performed atomically via a single + /// `UPDATE … WHERE key = 'admin_auth_code' AND value = ?current` statement. + /// The result metadata's `changes` count is inspected: if zero rows were + /// affected the stored code either does not exist or did not match `current`, + /// and `Err(DbError::Forbidden)` is returned. If one row was affected the new + /// code is returned. + async fn update_admin_auth_code( + &self, + current: &str, + new_code: Option<&str>, + ) -> Result { + let replacement = new_code + .map(|s| s.to_owned()) + .unwrap_or_else(generate_auth_code); + + let result = self + .db + .prepare( + "UPDATE admin_config \ + SET value = ?1 \ + WHERE key = 'admin_auth_code' AND value = ?2", + ) + .bind(&[JsValue::from_str(&replacement), JsValue::from_str(current)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let changes = result + .meta() + .map_err(|e| DbError::Internal(e.to_string()))? + .and_then(|m| m.changes) + .unwrap_or(0); + + if changes == 0 { + return Err(DbError::Forbidden); + } + + Ok(replacement) + } + + /// Return whether submissions are currently locked. + /// + /// Reads the `submissions_locked` key from `admin_config`. + /// Returns `false` if the key has not been seeded yet. + async fn get_submissions_locked(&self) -> Result { + #[derive(serde::Deserialize)] + struct ValueRow { + value: String, + } + + let row = self + .db + .prepare("SELECT value FROM admin_config WHERE key = 'submissions_locked'") + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + Ok(row.map(|r| r.value == "1").unwrap_or(false)) + } + + /// Persist the submissions lock state. + /// + /// Upserts `"1"` (locked) or `"0"` (unlocked) into the `submissions_locked` + /// key in `admin_config`. + async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> { + let value = if locked { "1" } else { "0" }; + self.db + .prepare( + "INSERT INTO admin_config (key, value) VALUES ('submissions_locked', ?1) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ) + .bind(&[JsValue::from_str(value)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Seed the `submissions_locked` key as `"0"` if not already present. + /// + /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. + async fn seed_submissions_locked(&self) -> Result<(), DbError> { + self.db + .prepare( + "INSERT OR IGNORE INTO admin_config (key, value) \ + VALUES ('submissions_locked', '0')", + ) + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Create a moderation report for an existing quote. + /// + /// Checks that the quote exists via a COUNT query, then inserts a new row + /// into the `reports` table. Returns `Err(DbError::NotFound)` if no quote + /// with the given `quote_id` exists. + async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError> { + // Step 1: verify the quote exists. + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + // Step 2: insert the report row. + let id = generate_id(); + let reason_value = match reason { + Some(r) => JsValue::from_str(r), + None => JsValue::NULL, + }; + self.db + .prepare("INSERT INTO reports (id, quote_id, reason) VALUES (?1, ?2, ?3)") + .bind(&[ + JsValue::from_str(&id), + JsValue::from_str(quote_id), + reason_value, + ]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// List all quotes that have at least one report, paginated (10 per page). + /// + /// Returns a [`super::ReportListResult`] ordered by most recent report + /// descending. Page numbers are 1-based. + async fn list_reports(&self, page: u32) -> Result { + #[derive(serde::Deserialize)] + struct TotalRow { + total: u32, + } + + #[derive(serde::Deserialize)] + struct SummaryRow { + quote_id: String, + text: String, + author: String, + report_count: u32, + latest_report_at: String, + } + + let page = page.max(1); + let offset = (page - 1) * 10; + + // Count distinct quoted with at least one report. + let total_row = self + .db + .prepare("SELECT COUNT(DISTINCT quote_id) AS total FROM reports") + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + let total_count = total_row.map(|r| r.total).unwrap_or(0); + let total_pages = total_count.div_ceil(10); + + // Aggregate: one row per quote, sorted by most recent report. + let raw_rows = self + .db + .prepare( + "SELECT q.id AS quote_id, q.text, q.author, COUNT(r.id) AS report_count, \ + MAX(r.created_at) AS latest_report_at \ + FROM reports r \ + JOIN quotes q ON q.id = r.quote_id \ + GROUP BY r.quote_id \ + ORDER BY latest_report_at DESC \ + LIMIT 10 OFFSET ?1", + ) + .bind(&[JsValue::from_f64(offset as f64)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + + let summaries = raw_rows + .into_iter() + .map(|r| { + let truncated = if r.text.chars().count() > 80 { + r.text.chars().take(80).collect() + } else { + r.text + }; + super::ReportSummary { + quote_id: r.quote_id, + text: truncated, + author: r.author, + report_count: r.report_count, + latest_report_at: r.latest_report_at, + } + }) + .collect(); + + Ok(super::ReportListResult { + reports: summaries, + page, + total_pages, + total_count, + }) + } + + /// Return the full quote plus all individual report rows for a quote. + /// + /// Reports are ordered oldest first by `created_at`. + /// Returns `Err(DbError::NotFound)` if no quote with `quote_id` exists. + async fn get_reports_for_quote(&self, quote_id: &str) -> Result { + #[derive(serde::Deserialize)] + struct ReportRowRaw { + id: String, + reason: Option, + created_at: String, + } + + // Fetch the quote. + let maybe_row = self + .db + .prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let row = maybe_row.ok_or(DbError::NotFound)?; + let tags = self.fetch_tags(&row.id).await?; + let quote = row.into_quote(tags); + + // Fetch all reports. + let report_rows = self + .db + .prepare( + "SELECT id, reason, created_at FROM reports \ + WHERE quote_id = ?1 ORDER BY created_at ASC", + ) + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + + let reports = report_rows + .into_iter() + .map(|r| super::ReportRow { + id: r.id, + reason: r.reason, + created_at: r.created_at, + }) + .collect(); + + Ok(super::QuoteReports { quote, reports }) + } + + /// Delete a quote unconditionally (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + /// Tags and reports are removed via `ON DELETE CASCADE`. + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> { + // Verify the quote exists first. + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + self.db + .prepare("DELETE FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Set `hidden = 1` on a quote (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> { + // Verify the quote exists first. + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + self.db + .prepare("UPDATE quotes SET hidden = 1 WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Delete all reports for a quote without deleting the quote itself. + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> { + // Verify the quote exists first. + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + self.db + .prepare("DELETE FROM reports WHERE quote_id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } +} diff --git a/quotesdb/src/db/migrations.rs b/quotesdb/src/db/migrations.rs new file mode 100644 index 0000000..a76a56f --- /dev/null +++ b/quotesdb/src/db/migrations.rs @@ -0,0 +1,73 @@ +//! SQL migration strings for the `quotesdb` schema. +//! +//! These strings are run once on startup via [`super::QuoteRepository::run_migrations`]. +//! Both the `D1Repository` (WASM) and `NativeRepository` (native) execute these +//! in sequence. + +/// Creates the `quotes` table if it does not already exist. +/// +/// Stores one row per quote with all core fields. The `auth_code` is stored +/// plaintext for simple passphrase-based ownership verification. +pub const CREATE_QUOTES: &str = "\ +CREATE TABLE IF NOT EXISTS quotes ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + author TEXT NOT NULL, + source TEXT, + date TEXT, + auth_code TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +)"; + +/// Creates the `quote_tags` join table if it does not already exist. +/// +/// Uses `ON DELETE CASCADE` so tags are removed automatically when a quote +/// is deleted. The composite primary key prevents duplicate tags per quote. +pub const CREATE_QUOTE_TAGS: &str = "\ +CREATE TABLE IF NOT EXISTS quote_tags ( + quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (quote_id, tag) +)"; + +/// Creates an index on `quote_tags.quote_id` to speed up tag lookups. +pub const CREATE_TAG_INDEX: &str = "\ +CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id)"; + +/// Creates a case-insensitive index on `quotes.author` for filter queries. +pub const CREATE_AUTHOR_INDEX: &str = "\ +CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE)"; + +/// Creates the `admin_config` key/value table for global configuration. +/// +/// Stores a single row for the admin auth code under key `admin_auth_code`. +pub const CREATE_ADMIN_CONFIG: &str = "\ +CREATE TABLE IF NOT EXISTS admin_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +)"; + +/// Adds the `hidden` column to the `quotes` table. +/// +/// This is a schema migration for existing databases. The column defaults to +/// `0` (not hidden) so all pre-existing quotes remain publicly visible. +/// +/// SQLite does not support `ADD COLUMN IF NOT EXISTS`, so callers must +/// ignore the error when the column already exists (e.g., on repeated startup). +pub const ALTER_QUOTES_ADD_HIDDEN: &str = "\ +ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"; + +/// Creates the `reports` table if it does not already exist. +/// +/// Each row represents one user-submitted report against a quote. +/// `quote_id` references `quotes(id)` with `ON DELETE CASCADE` so reports +/// are removed automatically when the associated quote is deleted. +/// `reason` is optional and capped at 256 characters by application logic. +pub const CREATE_REPORTS: &str = "\ +CREATE TABLE IF NOT EXISTS reports ( + id TEXT PRIMARY KEY, + quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, + reason TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +)"; diff --git a/quotesdb/src/db/mod.rs b/quotesdb/src/db/mod.rs new file mode 100644 index 0000000..fb0340a --- /dev/null +++ b/quotesdb/src/db/mod.rs @@ -0,0 +1,275 @@ +//! Database abstraction layer for the `quotesdb` API. +//! +//! Provides the [`QuoteRepository`] async trait as a uniform interface over +//! two backend implementations: +//! +//! - [`NativeRepository`] — `rusqlite` + `tokio-rusqlite` for native/test targets. +//! - `D1Repository` — Cloudflare D1 via workers-rs for WASM/production targets. +//! +//! The correct implementation is selected at compile time via `cfg(target_arch)`. +//! No feature flags or runtime branching are needed. + +pub mod migrations; + +#[cfg(not(target_arch = "wasm32"))] +mod native; + +#[cfg(all(target_arch = "wasm32", feature = "workers-api"))] +pub mod d1; + +#[cfg(not(target_arch = "wasm32"))] +pub mod connection; + +#[cfg(not(target_arch = "wasm32"))] +pub use native::NativeRepository; + +use serde::{Deserialize, Serialize}; + +// ── Shared result types ─────────────────────────────────────────────────────── + +/// A paginated list of quotes. +/// +/// Returned by [`QuoteRepository::list_quotes`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListResult { + /// The quotes on this page. + pub quotes: Vec, + /// Current page number (1-based). + pub page: u32, + /// Total number of pages. + pub total_pages: u32, + /// Total number of quotes matching the filter. + pub total_count: u32, +} + +/// A single report row returned by admin report queries. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportRow { + /// Unique report ID (NanoID). + pub id: String, + /// Optional human-readable reason supplied by the reporter. + pub reason: Option, + /// ISO timestamp when the report was created. + pub created_at: String, +} + +/// Summary of a reported quote, returned in the paginated reports list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportSummary { + /// The ID of the reported quote. + pub quote_id: String, + /// Abbreviated quote text (first 80 chars). + pub text: String, + /// Author of the reported quote. + pub author: String, + /// Total number of reports against this quote. + pub report_count: u32, + /// ISO timestamp of the most recent report. + pub latest_report_at: String, +} + +/// A paginated list of reported quotes. +/// +/// Returned by [`QuoteRepository::list_reports`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportListResult { + /// Summaries of reported quotes on this page. + pub reports: Vec, + /// Current page number (1-based). + pub page: u32, + /// Total number of pages. + pub total_pages: u32, + /// Total number of quotes with at least one report. + pub total_count: u32, +} + +/// Full details for a reported quote: the quote itself plus all report rows. +/// +/// Returned by [`QuoteRepository::get_reports_for_quote`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuoteReports { + /// The full quote object. + pub quote: crate::Quote, + /// All reports submitted against this quote, ordered oldest first. + pub reports: Vec, +} + +/// Outcome of a delete operation. +#[derive(Debug, PartialEq)] +pub enum DeleteResult { + /// The quote was deleted successfully. + Deleted, + /// No quote with the given ID exists. + NotFound, + /// The auth code did not match — deletion refused. + Forbidden, +} + +/// Errors returned by [`QuoteRepository`] methods. +#[derive(Debug, thiserror::Error)] +pub enum DbError { + /// An internal database error occurred. + #[error("database error: {0}")] + Internal(String), + /// The requested resource does not exist. + #[error("not found")] + NotFound, + /// The operation is forbidden (wrong auth code). + #[error("forbidden")] + Forbidden, +} + +// ── Trait ───────────────────────────────────────────────────────────────────── + +/// Async repository interface for all quote CRUD operations. +/// +/// On native targets the trait uses `async_trait` (Send-capable futures), +/// which lets Axum share the repository across Tokio tasks. +/// On wasm32 the trait uses `async_trait(?Send)` because D1 database methods +/// internally use `JsFuture` (which is `!Send`). Handler futures are wrapped +/// with `#[worker::send]` at the call site to satisfy Axum's `Handler` bounds. +/// +/// Implementations must be backed by a persistent store (SQLite for native, +/// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc` +/// so it can be shared across Axum handler calls. +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +pub trait QuoteRepository { + /// Run `CREATE TABLE IF NOT EXISTS` migrations. + /// + /// Covers the `quotes`, `quote_tags`, index, and `admin_config` tables. + /// Must be called once on startup before any other operations. + async fn run_migrations(&self) -> Result<(), DbError>; + + /// List quotes with optional filtering and pagination. + /// + /// Page numbers are 1-based. Returns an empty `quotes` vec when `page` + /// is beyond the last page. `date_after` and `date_before` are ISO date + /// prefix strings (e.g. `"2020"`, `"2020-06"`, `"2020-06-15"`); the DB + /// layer uses `>=` / `<=` comparisons against the stored `date` column. + async fn list_quotes( + &self, + page: u32, + author: Option<&str>, + tag: Option<&str>, + date_after: Option<&str>, + date_before: Option<&str>, + ) -> Result; + + /// Retrieve a single quote by its ID. + /// + /// Returns `Ok(None)` when no quote with the given ID exists. + async fn get_quote(&self, id: &str) -> Result, DbError>; + + /// Return a single random quote. + /// + /// Returns `Ok(None)` when the database is empty. + async fn get_random_quote(&self) -> Result, DbError>; + + /// Create a new quote. + /// + /// If `input.auth_code` is `None`, a 4-word passphrase is auto-generated. + /// Returns the stored quote (without auth_code) and the auth_code string. + async fn create_quote( + &self, + input: crate::CreateQuoteInput, + ) -> Result<(crate::Quote, String), DbError>; + + /// Update an existing quote. + /// + /// The `auth_code` header value must match `quotes.auth_code`. + /// Returns `Err(DbError::NotFound)` if the ID does not exist. + /// Returns `Err(DbError::Forbidden)` if the auth code does not match. + async fn update_quote( + &self, + id: &str, + input: crate::UpdateQuoteInput, + auth_code: &str, + ) -> Result; + + /// Delete a quote by ID. + /// + /// The `auth_code` header value must match `quotes.auth_code` or the + /// admin super auth code stored in `admin_config`. + /// Tags are removed automatically via `ON DELETE CASCADE`. + async fn delete_quote(&self, id: &str, auth_code: &str) -> Result; + + /// Retrieve the admin super auth code from `admin_config`. + /// + /// Returns `Ok(None)` if the admin code has not been seeded yet. + async fn get_admin_auth_code(&self) -> Result, DbError>; + + /// Store the admin auth code in `admin_config` if not already present. + /// + /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. + async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>; + + /// Replace the admin auth code if `current` matches the stored value. + /// + /// If `new_code` is `None`, a fresh 4-word passphrase is auto-generated. + /// Returns the new auth code on success. + /// Returns `Err(DbError::Forbidden)` if `current` does not match. + async fn update_admin_auth_code( + &self, + current: &str, + new_code: Option<&str>, + ) -> Result; + + /// Return whether submissions are currently locked. + /// + /// Reads the `submissions_locked` key from `admin_config`. + /// Returns `false` if the key has not been seeded yet (defaults to open). + async fn get_submissions_locked(&self) -> Result; + + /// Persist the submissions lock state. + /// + /// Writes `"1"` (locked) or `"0"` (unlocked) to the `submissions_locked` + /// key in `admin_config`, upserting if necessary. + async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>; + + /// Seed the `submissions_locked` key as `"0"` if not already present. + /// + /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. + /// Call once on startup to ensure the key exists. + async fn seed_submissions_locked(&self) -> Result<(), DbError>; + + /// Create a moderation report for an existing quote. + /// + /// `reason` is optional and should be at most 256 chars (enforced at the + /// handler layer before this method is called). + /// + /// Returns `Err(DbError::NotFound)` if `quote_id` does not exist in the + /// `quotes` table. + async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError>; + + /// List all quotes that have at least one report, paginated (10 per page). + /// + /// Each entry includes: quote ID, truncated text, author, report count, + /// and the timestamp of the most recent report. Page numbers are 1-based. + async fn list_reports(&self, page: u32) -> Result; + + /// Return the full quote plus all individual report rows for a quote. + /// + /// Reports are ordered oldest first by `created_at`. + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn get_reports_for_quote(&self, quote_id: &str) -> Result; + + /// Delete a quote unconditionally (admin action). + /// + /// Does not verify per-quote auth code — caller must authenticate as admin + /// before invoking this method. Tags and reports are removed automatically + /// by the `ON DELETE CASCADE` constraints. + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError>; + + /// Set `hidden = 1` on a quote (admin action). + /// + /// Does not verify per-quote auth code — caller must authenticate as admin. + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError>; + + /// Delete all reports for a quote without deleting the quote itself. + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError>; +} diff --git a/quotesdb/src/db/native.rs b/quotesdb/src/db/native.rs new file mode 100644 index 0000000..f988476 --- /dev/null +++ b/quotesdb/src/db/native.rs @@ -0,0 +1,1524 @@ +//! Native SQLite repository implementation using `tokio-rusqlite`. +//! +//! [`NativeRepository`] wraps a [`tokio_rusqlite::Connection`] and implements +//! the [`super::QuoteRepository`] trait for all CRUD operations. It is used for +//! local development and testing; production uses `D1Repository` (wasm32). + +use super::{DbError, DeleteResult, ListResult, QuoteRepository}; +use crate::{generate_auth_code, generate_id, CreateQuoteInput, Quote, UpdateQuoteInput}; +use rusqlite::OptionalExtension; +use tokio_rusqlite::Connection; + +/// Native SQLite repository backed by `tokio-rusqlite`. +/// +/// Wraps a `tokio_rusqlite::Connection` and provides async implementations +/// of all [`QuoteRepository`] methods. Each method enters the rusqlite +/// thread pool via [`Connection::call`]. +pub struct NativeRepository { + conn: Connection, +} + +impl NativeRepository { + /// Create a new [`NativeRepository`] wrapping the given connection. + pub fn new(conn: Connection) -> Self { + Self { conn } + } +} + +/// Fetch the tags for a single quote ID. +/// +/// Returns a sorted `Vec` of tag values, or an empty vec if none exist. +fn fetch_tags_for_quote( + conn: &rusqlite::Connection, + quote_id: &str, +) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare("SELECT tag FROM quote_tags WHERE quote_id = ? ORDER BY tag")?; + let tags = stmt + .query_map([quote_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(tags) +} + +/// Map rusqlite columns (id, text, author, source, date, hidden, created_at, updated_at) +/// plus a pre-fetched tags vec into a [`Quote`]. +fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec) -> Result { + let hidden_int: i64 = row.get(5)?; + Ok(Quote { + id: row.get(0)?, + text: row.get(1)?, + author: row.get(2)?, + source: row.get(3)?, + date: row.get(4)?, + hidden: hidden_int != 0, + created_at: row.get(6)?, + updated_at: row.get(7)?, + tags, + }) +} + +#[async_trait::async_trait] +impl QuoteRepository for NativeRepository { + /// Run all DDL migration statements from [`super::migrations`]. + /// + /// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`, + /// and the `hidden` column ALTER. Safe to call multiple times — CREATE + /// statements use `IF NOT EXISTS`, and the ALTER TABLE error (column + /// already exists) is intentionally ignored. + async fn run_migrations(&self) -> Result<(), DbError> { + self.conn + .call(|conn| { + use super::migrations::*; + conn.execute_batch(&format!( + "{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \ + {CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG}; \ + {CREATE_REPORTS};" + ))?; + // ALTER TABLE does not support IF NOT EXISTS — ignore the error + // when the column already exists (idempotent on re-runs). + let _ = conn.execute(ALTER_QUOTES_ADD_HIDDEN, []); + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// List quotes with optional author/tag/date filters and 1-based pagination. + /// + /// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering. + /// `date_after` and `date_before` are ISO date prefix strings compared via + /// `>=` / `<=` against the stored `date` column; rows where `date IS NULL` + /// are excluded when either bound is set. + /// Tags for each returned quote are fetched in a second query per quote to + /// avoid duplicate rows from a JOIN. + async fn list_quotes( + &self, + page: u32, + author: Option<&str>, + tag: Option<&str>, + date_after: Option<&str>, + date_before: Option<&str>, + ) -> Result { + let page = page.max(1); + let author = author.map(|s| s.to_owned()); + let tag = tag.map(|s| s.to_owned()); + let date_after = date_after.map(|s| s.to_owned()); + let date_before = date_before.map(|s| s.to_owned()); + + self.conn + .call(move |conn| { + const PAGE_SIZE: i64 = 10; + + // ── Build WHERE clause ──────────────────────────────────── + // Always exclude hidden quotes from listing endpoints. + let mut conditions: Vec = vec!["q.hidden = 0".to_owned()]; + if author.is_some() { + conditions.push("q.author = ? COLLATE NOCASE".to_owned()); + } + if tag.is_some() { + conditions + .push("q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?)".to_owned()); + } + // Exclude NULL dates when any date bound is active + if date_after.is_some() || date_before.is_some() { + conditions.push("q.date IS NOT NULL".to_owned()); + } + if date_after.is_some() { + conditions.push("q.date >= ?".to_owned()); + } + if date_before.is_some() { + conditions.push("q.date <= ?".to_owned()); + } + let where_clause = format!("WHERE {}", conditions.join(" AND ")); + + // Collect bound params in order for both queries + let mut params: Vec> = Vec::new(); + if let Some(ref a) = author { + params.push(Box::new(a.clone())); + } + if let Some(ref t) = tag { + params.push(Box::new(t.clone())); + } + if let Some(ref da) = date_after { + params.push(Box::new(da.clone())); + } + if let Some(ref db) = date_before { + params.push(Box::new(db.clone())); + } + + // ── Count total matching rows ────────────────────────────── + let count_sql = format!("SELECT COUNT(*) FROM quotes q {where_clause}"); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|b| b.as_ref()).collect(); + let total_count: u32 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(param_refs.iter().copied()), + |row| row.get(0), + )?; + + let total_pages = + (((total_count as i64) + PAGE_SIZE - 1) / PAGE_SIZE).max(1) as u32; + let offset = ((page as i64) - 1) * PAGE_SIZE; + + // ── Fetch the page of quotes ────────────────────────────── + let list_sql = format!( + "SELECT q.id, q.text, q.author, q.source, q.date, \ + q.hidden, q.created_at, q.updated_at \ + FROM quotes q {where_clause} \ + ORDER BY q.created_at DESC \ + LIMIT ? OFFSET ?" + ); + + // Re-collect bound params (limit/offset appended at end) + let mut params2: Vec> = Vec::new(); + if let Some(ref a) = author { + params2.push(Box::new(a.clone())); + } + if let Some(ref t) = tag { + params2.push(Box::new(t.clone())); + } + if let Some(ref da) = date_after { + params2.push(Box::new(da.clone())); + } + if let Some(ref db) = date_before { + params2.push(Box::new(db.clone())); + } + params2.push(Box::new(PAGE_SIZE)); + params2.push(Box::new(offset)); + + let param_refs2: Vec<&dyn rusqlite::types::ToSql> = + params2.iter().map(|b| b.as_ref()).collect(); + + let mut stmt = conn.prepare(&list_sql)?; + let partial_quotes: Vec = stmt + .query_map( + rusqlite::params_from_iter(param_refs2.iter().copied()), + |row| row_to_quote(row, vec![]), + )? + .collect::, _>>()?; + + // Second pass: fetch tags for each quote + let quotes = partial_quotes + .into_iter() + .map(|mut q| { + q.tags = fetch_tags_for_quote(conn, &q.id)?; + Ok(q) + }) + .collect::, rusqlite::Error>>()?; + + Ok(ListResult { + quotes, + page, + total_pages, + total_count, + }) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Retrieve a single quote by its primary key. + /// + /// Returns `Ok(None)` when no row matches `id`. + async fn get_quote(&self, id: &str) -> Result, DbError> { + let id = id.to_owned(); + self.conn + .call(move |conn| { + let mut stmt = conn.prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?", + )?; + let mut rows = stmt.query([&id as &str])?; + match rows.next()? { + Some(row) => { + let tags = fetch_tags_for_quote(conn, &id)?; + Ok(Some(row_to_quote(row, tags)?)) + } + None => Ok(None), + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Return one quote chosen at random. + /// + /// Returns `Ok(None)` when the `quotes` table is empty. + async fn get_random_quote(&self) -> Result, DbError> { + self.conn + .call(|conn| { + let mut stmt = conn.prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1", + )?; + let mut rows = stmt.query([])?; + match rows.next()? { + Some(row) => { + let id: String = row.get(0)?; + let tags = fetch_tags_for_quote(conn, &id)?; + Ok(Some(row_to_quote(row, tags)?)) + } + None => Ok(None), + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Insert a new quote row and its associated tags. + /// + /// If `input.auth_code` is `None`, a 4-word passphrase is generated. + /// Returns the persisted [`Quote`] (without `auth_code`) and the raw + /// auth-code string so the caller can include it in the creation response. + async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> { + let id = generate_id(); + let auth_code = input.auth_code.clone().unwrap_or_else(generate_auth_code); + + let id2 = id.clone(); + let auth2 = auth_code.clone(); + + self.conn + .call(move |conn| { + conn.execute( + "INSERT INTO quotes (id, text, author, source, date, auth_code) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + id2, + input.text, + input.author, + input.source, + input.date, + auth2, + ], + )?; + + for tag in &input.tags { + conn.execute( + "INSERT OR IGNORE INTO quote_tags (quote_id, tag) VALUES (?1, ?2)", + rusqlite::params![id2, tag], + )?; + } + + // Read back the inserted row to obtain server-generated timestamps + let mut stmt = conn.prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?", + )?; + let mut rows = stmt.query([&id2 as &str])?; + let row = rows.next()?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; + let tags = fetch_tags_for_quote(conn, &id2)?; + let quote = row_to_quote(row, tags)?; + Ok((quote, auth2)) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Update non-`None` fields on an existing quote. + /// + /// Verifies `auth_code` before making any changes. If `input.tags` is + /// `Some`, the entire tag set is replaced. Updates `updated_at` to the + /// current UTC time. + async fn update_quote( + &self, + id: &str, + input: UpdateQuoteInput, + auth_code: &str, + ) -> Result { + let id = id.to_owned(); + let auth_code = auth_code.to_owned(); + + // Phase 1: fetch stored auth_code (returns DbError on failure) + let stored: Option = self + .conn + .call({ + let id = id.clone(); + move |conn| { + let result: Option = conn + .query_row( + "SELECT auth_code FROM quotes WHERE id = ?", + [&id as &str], + |row| row.get(0), + ) + .optional()?; + Ok(result) + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match stored { + None => return Err(DbError::NotFound), + Some(ref s) if s.as_str() == auth_code.as_str() => {} // exact match, proceed + Some(_) => { + // Check admin code fallback + let admin = self.get_admin_auth_code().await?; + if admin.as_deref() != Some(auth_code.as_str()) { + return Err(DbError::Forbidden); + } + } + } + + // Phase 2: apply the update + self.conn + .call(move |conn| { + let mut sets: Vec = Vec::new(); + if input.text.is_some() { + sets.push("text = ?".to_owned()); + } + if input.author.is_some() { + sets.push("author = ?".to_owned()); + } + sets.push("source = ?".to_owned()); + sets.push("date = ?".to_owned()); + if input.hidden.is_some() { + sets.push("hidden = ?".to_owned()); + } + sets.push("updated_at = datetime('now')".to_owned()); + + let sql = format!("UPDATE quotes SET {} WHERE id = ?", sets.join(", ")); + + // Build the params vector in the same order as the SET clause + let mut params: Vec> = Vec::new(); + if let Some(ref text) = input.text { + params.push(Box::new(text.clone())); + } + if let Some(ref author) = input.author { + params.push(Box::new(author.clone())); + } + // source and date may be null (None clears the field) + params.push(Box::new(input.source.clone())); + params.push(Box::new(input.date.clone())); + if let Some(h) = input.hidden { + params.push(Box::new(if h { 1i64 } else { 0i64 })); + } + params.push(Box::new(id.clone())); + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|b| b.as_ref()).collect(); + conn.execute(&sql, rusqlite::params_from_iter(param_refs.iter().copied()))?; + + // Replace tags if provided + if let Some(ref tags) = input.tags { + conn.execute("DELETE FROM quote_tags WHERE quote_id = ?", [&id as &str])?; + for tag in tags { + conn.execute( + "INSERT OR IGNORE INTO quote_tags (quote_id, tag) \ + VALUES (?1, ?2)", + rusqlite::params![id, tag], + )?; + } + } + + // Read back the updated quote + let mut stmt = conn.prepare( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?", + )?; + let mut rows = stmt.query([&id as &str])?; + let row = rows.next()?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; + let tags = fetch_tags_for_quote(conn, &id)?; + Ok(row_to_quote(row, tags)?) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Delete a quote by ID after verifying the auth code. + /// + /// Returns [`DeleteResult::NotFound`] if no quote has that ID, + /// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the + /// admin super auth code matches, or [`DeleteResult::Deleted`] on success. + /// Tags are removed automatically by the `ON DELETE CASCADE` constraint on + /// `quote_tags`. + async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { + let id = id.to_owned(); + let auth_code = auth_code.to_owned(); + + self.conn + .call(move |conn| { + let stored: Option = conn + .query_row( + "SELECT auth_code FROM quotes WHERE id = ?", + [&id as &str], + |row| row.get(0), + ) + .optional()?; + + match stored { + None => return Ok(DeleteResult::NotFound), + Some(ref s) if s == &auth_code => { + conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?; + return Ok(DeleteResult::Deleted); + } + Some(_) => {} + } + + // Check admin code as fallback + let admin: Option = conn + .query_row( + "SELECT value FROM admin_config WHERE key = 'admin_auth_code'", + [], + |row| row.get(0), + ) + .optional()?; + + if admin.as_deref() == Some(auth_code.as_str()) { + conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?; + Ok(DeleteResult::Deleted) + } else { + Ok(DeleteResult::Forbidden) + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Retrieve the admin super auth code from `admin_config`. + /// + /// Returns `Ok(None)` if the admin code has not been seeded yet. + async fn get_admin_auth_code(&self) -> Result, DbError> { + self.conn + .call(|conn| { + let result: Option = conn + .query_row( + "SELECT value FROM admin_config WHERE key = 'admin_auth_code'", + [], + |row| row.get(0), + ) + .optional()?; + Ok(result) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Insert the admin auth code if not already present. + /// + /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. + async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> { + let code = code.to_owned(); + self.conn + .call(move |conn| { + conn.execute( + "INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)", + rusqlite::params![code], + )?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Replace the admin auth code if `current` matches the stored value. + /// + /// Generates a fresh 4-word passphrase when `new_code` is `None`. + /// + /// The check and update are performed atomically via a single + /// `UPDATE … WHERE key = 'admin_auth_code' AND value = ?current` statement. + /// If zero rows are affected the stored code either does not exist or did not + /// match `current`, and `Err(DbError::Forbidden)` is returned. If one row is + /// affected the new code is returned. + async fn update_admin_auth_code( + &self, + current: &str, + new_code: Option<&str>, + ) -> Result { + let replacement = new_code + .map(|s| s.to_owned()) + .unwrap_or_else(generate_auth_code); + let current = current.to_owned(); + let replacement_inner = replacement.clone(); + + let changed = self + .conn + .call(move |conn| { + Ok(conn.execute( + "UPDATE admin_config \ + SET value = ?1 \ + WHERE key = 'admin_auth_code' AND value = ?2", + rusqlite::params![replacement_inner, current], + )?) + }) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + if changed == 0 { + return Err(DbError::Forbidden); + } + + Ok(replacement) + } + + /// Return whether submissions are currently locked. + /// + /// Reads the `submissions_locked` key from `admin_config`. + /// Returns `false` if the key has not been seeded yet. + async fn get_submissions_locked(&self) -> Result { + self.conn + .call(|conn| { + let result: Option = conn + .query_row( + "SELECT value FROM admin_config WHERE key = 'submissions_locked'", + [], + |row| row.get(0), + ) + .optional()?; + Ok(result) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + .map(|opt| opt.as_deref() == Some("1")) + } + + /// Persist the submissions lock state. + /// + /// Upserts `"1"` (locked) or `"0"` (unlocked) into the `submissions_locked` + /// key in `admin_config`. + async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> { + let value = if locked { "1" } else { "0" }; + self.conn + .call(move |conn| { + conn.execute( + "INSERT INTO admin_config (key, value) VALUES ('submissions_locked', ?1) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + rusqlite::params![value], + )?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Seed the `submissions_locked` key as `"0"` if not already present. + /// + /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. + async fn seed_submissions_locked(&self) -> Result<(), DbError> { + self.conn + .call(|conn| { + conn.execute( + "INSERT OR IGNORE INTO admin_config (key, value) \ + VALUES ('submissions_locked', '0')", + [], + )?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Create a moderation report for an existing quote. + /// + /// Uses a two-step approach: first checks that the quote exists, then + /// inserts the report row. Returns `Err(DbError::NotFound)` if the quote + /// does not exist. + async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + let reason = reason.map(|s| s.to_owned()); + + // Step 1: verify the quote exists. + let exists = self + .conn + .call({ + let quote_id = quote_id.clone(); + move |conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + |row| row.get(0), + )?; + Ok(count > 0) + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + if !exists { + return Err(DbError::NotFound); + } + + // Step 2: insert the report row. + self.conn + .call(move |conn| { + let id = crate::generate_id(); + conn.execute( + "INSERT INTO reports (id, quote_id, reason) VALUES (?1, ?2, ?3)", + rusqlite::params![id, quote_id, reason], + )?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// List all quotes that have at least one report, paginated (10 per page). + /// + /// Returns a [`super::ReportListResult`] with aggregated summary rows — one + /// row per quote — ordered by most recent report descending (most urgent + /// first). Page numbers are 1-based. + async fn list_reports(&self, page: u32) -> Result { + let page = page.max(1); + let offset = (page - 1) * 10; + + self.conn + .call(move |conn| { + // Count distinct quotes with at least one report. + let total_count: u32 = + conn.query_row("SELECT COUNT(DISTINCT quote_id) FROM reports", [], |row| { + row.get(0) + })?; + + let total_pages = total_count.div_ceil(10); + + // Aggregate: one row per quote, sorted by most recent report. + let mut stmt = conn.prepare( + "SELECT q.id, q.text, q.author, COUNT(r.id) AS report_count, \ + MAX(r.created_at) AS latest_report_at \ + FROM reports r \ + JOIN quotes q ON q.id = r.quote_id \ + GROUP BY r.quote_id \ + ORDER BY latest_report_at DESC \ + LIMIT 10 OFFSET ?1", + )?; + let summaries = stmt + .query_map(rusqlite::params![offset], |row| { + let text: String = row.get(1)?; + let truncated = if text.chars().count() > 80 { + text.chars().take(80).collect() + } else { + text + }; + Ok(super::ReportSummary { + quote_id: row.get(0)?, + text: truncated, + author: row.get(2)?, + report_count: row.get(3)?, + latest_report_at: row.get(4)?, + }) + })? + .collect::, _>>()?; + + Ok(super::ReportListResult { + reports: summaries, + page, + total_pages, + total_count, + }) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } + + /// Return the full quote plus all individual report rows for a quote. + /// + /// Reports are ordered oldest first by `created_at`. + /// Returns `Err(DbError::NotFound)` if no quote with `quote_id` exists. + async fn get_reports_for_quote(&self, quote_id: &str) -> Result { + let quote_id = quote_id.to_owned(); + + self.conn + .call(move |conn| { + // Fetch the quote itself. + let maybe_quote = conn + .query_row( + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + |row| { + let tags = vec![]; // fetched below + row_to_quote(row, tags) + }, + ) + .optional()?; + + let mut quote = maybe_quote.ok_or(rusqlite::Error::QueryReturnedNoRows)?; + quote.tags = fetch_tags_for_quote(conn, "e.id)?; + + // Fetch all reports for this quote. + let mut stmt = conn.prepare( + "SELECT id, reason, created_at FROM reports \ + WHERE quote_id = ?1 ORDER BY created_at ASC", + )?; + let reports = stmt + .query_map(rusqlite::params![quote_id], |row| { + Ok(super::ReportRow { + id: row.get(0)?, + reason: row.get(1)?, + created_at: row.get(2)?, + }) + })? + .collect::, _>>()?; + + Ok(super::QuoteReports { quote, reports }) + }) + .await + .map_err(|e| match e { + tokio_rusqlite::Error::Rusqlite(rusqlite::Error::QueryReturnedNoRows) => { + DbError::NotFound + } + other => DbError::Internal(other.to_string()), + }) + } + + /// Delete a quote unconditionally (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + /// Tags and reports are removed via `ON DELETE CASCADE`. + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + + self.conn + .call(move |conn| { + let rows = conn.execute( + "DELETE FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + )?; + Ok(rows) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + .and_then(|rows| { + if rows == 0 { + Err(DbError::NotFound) + } else { + Ok(()) + } + }) + } + + /// Set `hidden = 1` on a quote (admin action). + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + + self.conn + .call(move |conn| { + let rows = conn.execute( + "UPDATE quotes SET hidden = 1 WHERE id = ?1", + rusqlite::params![quote_id], + )?; + Ok(rows) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + .and_then(|rows| { + if rows == 0 { + Err(DbError::NotFound) + } else { + Ok(()) + } + }) + } + + /// Delete all reports for a quote without deleting the quote itself. + /// + /// Returns `Err(DbError::NotFound)` if the quote does not exist. + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + + // First verify the quote exists. + let exists = self + .conn + .call({ + let quote_id = quote_id.clone(); + move |conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + |row| row.get(0), + )?; + Ok(count > 0) + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + if !exists { + return Err(DbError::NotFound); + } + + self.conn + .call(move |conn| { + conn.execute( + "DELETE FROM reports WHERE quote_id = ?1", + rusqlite::params![quote_id], + )?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Open an in-memory SQLite database for testing. + async fn in_memory_repo() -> NativeRepository { + let conn = Connection::open_in_memory().await.unwrap(); + let repo = NativeRepository::new(conn); + repo.run_migrations().await.unwrap(); + repo + } + + fn make_input(text: &str, author: &str) -> CreateQuoteInput { + CreateQuoteInput { + text: text.to_owned(), + author: author.to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: None, + cf_turnstile_token: None, + } + } + + #[tokio::test] + async fn test_create_and_get_quote() { + let repo = in_memory_repo().await; + let input = CreateQuoteInput { + text: "Hello, world!".to_owned(), + author: "Test Author".to_owned(), + source: None, + date: None, + tags: vec!["test".to_owned()], + auth_code: Some("word-word-word-word".to_owned()), + cf_turnstile_token: None, + }; + let (quote, auth) = repo.create_quote(input).await.unwrap(); + assert_eq!(auth, "word-word-word-word"); + assert_eq!(quote.text, "Hello, world!"); + assert_eq!(quote.tags, vec!["test"]); + + let fetched = repo.get_quote("e.id).await.unwrap(); + assert!(fetched.is_some()); + assert_eq!(fetched.unwrap().id, quote.id); + } + + #[tokio::test] + async fn test_get_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.get_quote("nonexistent").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_list_quotes_pagination() { + let repo = in_memory_repo().await; + for i in 0..15 { + repo.create_quote(make_input(&format!("Quote {i}"), "Author")) + .await + .unwrap(); + } + let page1 = repo.list_quotes(1, None, None, None, None).await.unwrap(); + assert_eq!(page1.quotes.len(), 10); + assert_eq!(page1.total_count, 15); + assert_eq!(page1.total_pages, 2); + + let page2 = repo.list_quotes(2, None, None, None, None).await.unwrap(); + assert_eq!(page2.quotes.len(), 5); + } + + #[tokio::test] + async fn test_list_quotes_author_filter() { + let repo = in_memory_repo().await; + for author in ["Alice", "Bob", "alice"] { + repo.create_quote(make_input(&format!("Quote by {author}"), author)) + .await + .unwrap(); + } + let result = repo + .list_quotes(1, Some("alice"), None, None, None) + .await + .unwrap(); + // COLLATE NOCASE should match "Alice" and "alice" + assert_eq!(result.total_count, 2); + } + + #[tokio::test] + async fn test_list_quotes_tag_filter() { + let repo = in_memory_repo().await; + repo.create_quote(CreateQuoteInput { + text: "Tagged".to_owned(), + author: "A".to_owned(), + source: None, + date: None, + tags: vec!["rust".to_owned()], + auth_code: None, + cf_turnstile_token: None, + }) + .await + .unwrap(); + repo.create_quote(make_input("Not tagged", "B")) + .await + .unwrap(); + + let result = repo + .list_quotes(1, None, Some("rust"), None, None) + .await + .unwrap(); + assert_eq!(result.total_count, 1); + assert_eq!(result.quotes[0].text, "Tagged"); + } + + #[tokio::test] + async fn test_list_quotes_date_filter() { + let repo = in_memory_repo().await; + // Insert quotes with specific dates and one without a date + for (text, date) in &[ + ("Old quote", Some("1990-01-01")), + ("Mid quote", Some("2000-06-15")), + ("New quote", Some("2020-12-31")), + ("No date quote", None), + ] { + repo.create_quote(CreateQuoteInput { + text: text.to_string(), + author: "Author".to_owned(), + source: None, + date: date.map(|d| d.to_owned()), + tags: vec![], + auth_code: None, + cf_turnstile_token: None, + }) + .await + .unwrap(); + } + + // date_after only — should match 2000 and 2020 + let result = repo + .list_quotes(1, None, None, Some("2000"), None) + .await + .unwrap(); + assert_eq!(result.total_count, 2); + + // date_before only — should match 1990 and 2000 + let result = repo + .list_quotes(1, None, None, None, Some("2000-12-31")) + .await + .unwrap(); + assert_eq!(result.total_count, 2); + + // both bounds — should match only 2000 + let result = repo + .list_quotes(1, None, None, Some("2000"), Some("2010")) + .await + .unwrap(); + assert_eq!(result.total_count, 1); + assert_eq!(result.quotes[0].text, "Mid quote"); + + // No date quotes are excluded when a bound is active + let result_all = repo.list_quotes(1, None, None, None, None).await.unwrap(); + assert_eq!(result_all.total_count, 4); // includes "No date quote" + let result_bounded = repo + .list_quotes(1, None, None, Some("1900"), None) + .await + .unwrap(); + assert_eq!(result_bounded.total_count, 3); // "No date quote" excluded + } + + #[tokio::test] + async fn test_random_quote_empty() { + let repo = in_memory_repo().await; + let result = repo.get_random_quote().await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_random_quote_returns_one() { + let repo = in_memory_repo().await; + repo.create_quote(make_input("Random", "R")).await.unwrap(); + let result = repo.get_random_quote().await.unwrap(); + assert!(result.is_some()); + } + + #[tokio::test] + async fn test_update_quote_success() { + let repo = in_memory_repo().await; + let (quote, auth) = repo + .create_quote(CreateQuoteInput { + text: "Original".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec!["old".to_owned()], + auth_code: None, + cf_turnstile_token: None, + }) + .await + .unwrap(); + + let updated = repo + .update_quote( + "e.id, + UpdateQuoteInput { + text: Some("Updated".to_owned()), + author: None, + source: None, + date: None, + tags: Some(vec!["new".to_owned()]), + hidden: None, + }, + &auth, + ) + .await + .unwrap(); + + assert_eq!(updated.text, "Updated"); + assert_eq!(updated.tags, vec!["new"]); + } + + #[tokio::test] + async fn test_update_quote_wrong_auth() { + let repo = in_memory_repo().await; + let (quote, _) = repo + .create_quote(CreateQuoteInput { + text: "Original".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("correct-code-here-xx".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + + let result = repo + .update_quote("e.id, UpdateQuoteInput::default(), "wrong-auth-code-yy") + .await; + assert!(matches!(result, Err(DbError::Forbidden))); + } + + #[tokio::test] + async fn test_update_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo + .update_quote("nonexistent", UpdateQuoteInput::default(), "any") + .await; + assert!(matches!(result, Err(DbError::NotFound))); + } + + #[tokio::test] + async fn test_delete_quote_success() { + let repo = in_memory_repo().await; + let (quote, auth) = repo + .create_quote(make_input("Delete me", "Author")) + .await + .unwrap(); + + let result = repo.delete_quote("e.id, &auth).await.unwrap(); + assert_eq!(result, DeleteResult::Deleted); + + assert!(repo.get_quote("e.id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_delete_quote_wrong_auth() { + let repo = in_memory_repo().await; + let (quote, _) = repo + .create_quote(make_input("Protected", "Author")) + .await + .unwrap(); + + let result = repo.delete_quote("e.id, "wrong-auth").await.unwrap(); + assert_eq!(result, DeleteResult::Forbidden); + } + + #[tokio::test] + async fn test_delete_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.delete_quote("nonexistent", "any").await.unwrap(); + assert_eq!(result, DeleteResult::NotFound); + } + + // ── submissions_locked tests ─────────────────────────────────────────────── + + #[tokio::test] + async fn test_get_submissions_locked_default_false() { + // A freshly migrated repo has no 'submissions_locked' key — must return false. + let repo = in_memory_repo().await; + let locked = repo.get_submissions_locked().await.unwrap(); + assert!(!locked, "submissions_locked should default to false"); + } + + #[tokio::test] + async fn test_set_submissions_locked_true_then_get() { + let repo = in_memory_repo().await; + repo.set_submissions_locked(true).await.unwrap(); + let locked = repo.get_submissions_locked().await.unwrap(); + assert!(locked, "submissions_locked should be true after set"); + } + + #[tokio::test] + async fn test_seed_submissions_locked_does_not_overwrite() { + // Set to true first, then seed — should remain true. + let repo = in_memory_repo().await; + repo.set_submissions_locked(true).await.unwrap(); + repo.seed_submissions_locked().await.unwrap(); + let locked = repo.get_submissions_locked().await.unwrap(); + assert!( + locked, + "seed_submissions_locked must not overwrite an existing value" + ); + } + + // ── update_admin_auth_code tests ────────────────────────────────────────── + + #[tokio::test] + async fn test_update_admin_auth_code_correct_current_succeeds() { + let repo = in_memory_repo().await; + repo.seed_admin_auth_code("initial-code-here") + .await + .unwrap(); + + let new_code = repo + .update_admin_auth_code("initial-code-here", Some("brand-new-code")) + .await + .unwrap(); + assert_eq!(new_code, "brand-new-code"); + + // Confirm the stored code was actually updated. + let stored = repo.get_admin_auth_code().await.unwrap(); + assert_eq!(stored.as_deref(), Some("brand-new-code")); + } + + #[tokio::test] + async fn test_update_admin_auth_code_generates_passphrase_when_none() { + let repo = in_memory_repo().await; + repo.seed_admin_auth_code("old-code").await.unwrap(); + + let new_code = repo.update_admin_auth_code("old-code", None).await.unwrap(); + + // The generated passphrase should be non-empty and different from the old one. + assert!(!new_code.is_empty()); + assert_ne!(new_code, "old-code"); + + let stored = repo.get_admin_auth_code().await.unwrap(); + assert_eq!(stored.as_deref(), Some(new_code.as_str())); + } + + #[tokio::test] + async fn test_update_admin_auth_code_wrong_current_returns_forbidden() { + let repo = in_memory_repo().await; + repo.seed_admin_auth_code("correct-code").await.unwrap(); + + let result = repo + .update_admin_auth_code("wrong-code", Some("new-code")) + .await; + assert!( + matches!(result, Err(DbError::Forbidden)), + "expected Forbidden, got {result:?}" + ); + + // Stored code must be unchanged. + let stored = repo.get_admin_auth_code().await.unwrap(); + assert_eq!(stored.as_deref(), Some("correct-code")); + } + + // ── hidden flag filter tests ─────────────────────────────────────────────── + + /// `list_quotes` must exclude hidden quotes and include only visible ones. + #[tokio::test] + async fn test_list_quotes_excludes_hidden() { + let repo = in_memory_repo().await; + + // Create a visible quote and a hidden quote. + let (visible, _) = repo + .create_quote(CreateQuoteInput { + text: "Visible quote".to_owned(), + author: "Author A".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth-visible-xxxxx".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + + let (hidden, hidden_auth) = repo + .create_quote(CreateQuoteInput { + text: "Hidden quote".to_owned(), + author: "Author B".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth-hidden-xxxxxx".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + + // Mark the second quote as hidden. + repo.update_quote( + &hidden.id, + UpdateQuoteInput { + hidden: Some(true), + ..UpdateQuoteInput::default() + }, + &hidden_auth, + ) + .await + .unwrap(); + + let result = repo.list_quotes(1, None, None, None, None).await.unwrap(); + assert_eq!(result.total_count, 1, "only the visible quote should count"); + assert_eq!(result.quotes.len(), 1); + assert_eq!( + result.quotes[0].id, visible.id, + "the returned quote must be the visible one" + ); + } + + /// `get_random_quote` must return `None` when the only quote is hidden. + #[tokio::test] + async fn test_get_random_quote_excludes_hidden() { + let repo = in_memory_repo().await; + + // Create a single quote and immediately hide it. + let (quote, auth) = repo + .create_quote(CreateQuoteInput { + text: "Only quote, hidden".to_owned(), + author: "Ghost".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth-ghost-xxxxxxx".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + + repo.update_quote( + "e.id, + UpdateQuoteInput { + hidden: Some(true), + ..UpdateQuoteInput::default() + }, + &auth, + ) + .await + .unwrap(); + + let result = repo.get_random_quote().await.unwrap(); + assert!( + result.is_none(), + "get_random_quote should return None when only hidden quotes exist" + ); + } + + // ── create_report tests ──────────────────────────────────────────────────── + + /// `create_report` succeeds when the referenced quote exists. + #[tokio::test] + async fn test_create_report_success() { + let repo = in_memory_repo().await; + let (quote, _) = repo + .create_quote(make_input("Report me", "Author")) + .await + .unwrap(); + + let result = repo.create_report("e.id, Some("spam")).await; + assert!( + result.is_ok(), + "create_report should succeed for an existing quote; got {result:?}" + ); + } + + /// `create_report` returns `Err(DbError::NotFound)` when the quote does not exist. + #[tokio::test] + async fn test_create_report_not_found() { + let repo = in_memory_repo().await; + let result = repo.create_report("nonexistent-id", None).await; + assert!( + matches!(result, Err(DbError::NotFound)), + "create_report should return NotFound for an unknown quote; got {result:?}" + ); + } + + // ── Admin moderation DB method tests (ticket 6c5904) ────────────────────── + + /// `list_reports` returns an empty [`super::ReportListResult`] when there + /// are no reports in the database. + #[tokio::test] + async fn test_list_reports_empty() { + let repo = in_memory_repo().await; + let result = repo.list_reports(1).await.unwrap(); + assert_eq!(result.total_count, 0); + assert_eq!(result.total_pages, 0); + assert!(result.reports.is_empty()); + } + + /// `list_reports` returns a summary row for a quote that has been reported. + #[tokio::test] + async fn test_list_reports_with_report() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Reported", "Author")) + .await + .unwrap(); + repo.create_report("e.id, Some("spam")).await.unwrap(); + + let result = repo.list_reports(1).await.unwrap(); + assert_eq!(result.total_count, 1); + assert_eq!(result.reports[0].quote_id, quote.id); + assert_eq!(result.reports[0].report_count, 1); + } + + /// `get_reports_for_quote` returns `Err(DbError::NotFound)` for an unknown + /// quote ID. + #[tokio::test] + async fn test_get_reports_for_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.get_reports_for_quote("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + + /// `get_reports_for_quote` returns the quote and all associated reports. + #[tokio::test] + async fn test_get_reports_for_quote_with_data() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Text", "Author")) + .await + .unwrap(); + repo.create_report("e.id, Some("reason one")) + .await + .unwrap(); + repo.create_report("e.id, None).await.unwrap(); + + let qr = repo.get_reports_for_quote("e.id).await.unwrap(); + assert_eq!(qr.quote.id, quote.id); + assert_eq!(qr.reports.len(), 2); + // First report (oldest) should have a reason. + assert_eq!(qr.reports[0].reason.as_deref(), Some("reason one")); + } + + /// `admin_delete_quote` removes the quote and returns `Ok(())`. + #[tokio::test] + async fn test_admin_delete_quote_success() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Delete me", "Author")) + .await + .unwrap(); + repo.admin_delete_quote("e.id).await.unwrap(); + let fetched = repo.get_quote("e.id).await.unwrap(); + assert!(fetched.is_none(), "quote should be deleted"); + } + + /// `admin_delete_quote` returns `Err(DbError::NotFound)` for an unknown ID. + #[tokio::test] + async fn test_admin_delete_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.admin_delete_quote("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + + /// `hide_quote` sets `hidden = true` on the quote. + #[tokio::test] + async fn test_hide_quote_success() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Visible", "Author")) + .await + .unwrap(); + assert!(!quote.hidden, "quote should start visible"); + repo.hide_quote("e.id).await.unwrap(); + let fetched = repo.get_quote("e.id).await.unwrap().unwrap(); + assert!(fetched.hidden, "quote should be hidden after hide_quote"); + } + + /// `hide_quote` returns `Err(DbError::NotFound)` for an unknown ID. + #[tokio::test] + async fn test_hide_quote_not_found() { + let repo = in_memory_repo().await; + let result = repo.hide_quote("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + + /// `clear_reports` removes all reports for a quote but leaves the quote. + #[tokio::test] + async fn test_clear_reports_success() { + let repo = in_memory_repo().await; + let (quote, _auth) = repo + .create_quote(make_input("Spammy", "Author")) + .await + .unwrap(); + repo.create_report("e.id, Some("spam")).await.unwrap(); + repo.create_report("e.id, None).await.unwrap(); + + repo.clear_reports("e.id).await.unwrap(); + + // The quote should still exist. + let fetched = repo.get_quote("e.id).await.unwrap(); + assert!( + fetched.is_some(), + "quote must still exist after clear_reports" + ); + + // The reports should be gone. + let qr = repo.get_reports_for_quote("e.id).await.unwrap(); + assert!(qr.reports.is_empty(), "reports should be cleared"); + } + + /// `clear_reports` returns `Err(DbError::NotFound)` for an unknown quote ID. + #[tokio::test] + async fn test_clear_reports_not_found() { + let repo = in_memory_repo().await; + let result = repo.clear_reports("nonexistent").await; + assert!( + matches!(result, Err(DbError::NotFound)), + "expected NotFound, got {result:?}" + ); + } + + /// `get_quote` (direct ID lookup) must return the quote even when it is hidden. + #[tokio::test] + async fn test_get_quote_returns_hidden_quote() { + let repo = in_memory_repo().await; + + let (quote, auth) = repo + .create_quote(CreateQuoteInput { + text: "Accessible but hidden".to_owned(), + author: "Secret Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth-secret-xxxxxx".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + + repo.update_quote( + "e.id, + UpdateQuoteInput { + hidden: Some(true), + ..UpdateQuoteInput::default() + }, + &auth, + ) + .await + .unwrap(); + + let fetched = repo.get_quote("e.id).await.unwrap(); + assert!( + fetched.is_some(), + "get_quote must return the quote even when it is hidden" + ); + let fetched = fetched.unwrap(); + assert_eq!(fetched.id, quote.id); + assert!(fetched.hidden, "the returned quote must have hidden=true"); + } +} diff --git a/quotesdb/src/handlers/mod.rs b/quotesdb/src/handlers/mod.rs new file mode 100644 index 0000000..29a9c5a --- /dev/null +++ b/quotesdb/src/handlers/mod.rs @@ -0,0 +1,3156 @@ +//! 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 crate::{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/status` — return whether quote submissions are currently locked. +/// +/// This endpoint requires no authentication and is intended to be called by +/// the UI on mount for both the `/submit` and `/admin` pages. Returns a JSON +/// object with a single boolean field: +/// +/// ```json +/// { "submissions_locked": false } +/// ``` +/// +/// Returns `500 Internal Server Error` if the database query fails. +#[cfg_attr(target_arch = "wasm32", worker::send)] +pub async fn get_status(State(repo): State) -> Response { + match repo.get_submissions_locked().await { + Ok(locked) => Json(serde_json::json!({ "submissions_locked": locked })).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +/// `GET /api/quotes` — list quotes with optional filtering and pagination. +/// +/// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and +/// month/day variants) query parameters. Defaults to page 1 and no filters. +/// Returns [`crate::db::ListResult`] serialised as JSON. +/// +/// Returns `400 Bad Request` when date component ordering is violated (e.g. +/// `date_after_month` provided without `date_after_year`). +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn list_handler(State(repo): State, 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. +/// +/// Returns `423 Locked` with `{"error": "submissions are closed"}` when the +/// admin has locked new submissions via `POST /api/admin/lock`. +/// +/// When the `TURNSTILE_SECRET_KEY` environment variable is set, a valid +/// Cloudflare Turnstile token must be provided in the `cf_turnstile_token` +/// field. This check is skipped on wasm32 targets (Workers runtime). +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn create_handler(State(repo): State, Json(input): Json) -> Response { + // Pre-flight: reject new submissions when locked. + match repo.get_submissions_locked().await { + Ok(true) => { + return ( + StatusCode::LOCKED, + Json(serde_json::json!({ "error": "submissions are closed" })), + ) + .into_response(); + } + Ok(false) => {} + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + + // Verify Cloudflare Turnstile token (native builds only; skipped on wasm32). + #[cfg(not(target_arch = "wasm32"))] + { + if let Ok(secret) = std::env::var("TURNSTILE_SECRET_KEY") { + let token = match input.cf_turnstile_token.as_deref() { + Some(t) if !t.is_empty() => t.to_owned(), + _ => { + return error_response(StatusCode::BAD_REQUEST, "CAPTCHA token required") + .into_response() + } + }; + let verified = verify_turnstile(&token, &secret).await; + if !verified { + return error_response(StatusCode::FORBIDDEN, "CAPTCHA verification failed") + .into_response(); + } + } + } + + match repo.create_quote(input).await { + Ok((quote, auth_code)) => ( + StatusCode::CREATED, + Json(CreateResponse { quote, auth_code }), + ) + .into_response(), + Err(e) => db_error_response(e), + } +} + +/// Extract the `X-Auth-Code` header value from the request headers. +/// +/// Returns `None` if the header is absent or cannot be decoded as UTF-8. +fn extract_auth_code(headers: &HeaderMap) -> Option { + headers + .get("X-Auth-Code") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()) +} + +/// Extract the `X-Admin-Code` header value from the request headers. +/// +/// Returns `None` if the header is absent or cannot be decoded as UTF-8. +fn extract_admin_code(headers: &HeaderMap) -> Option { + headers + .get("X-Admin-Code") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()) +} + +/// Verify that the supplied admin code matches the one stored in the repository. +/// +/// Fetches the current admin code via [`QuoteRepository::get_admin_auth_code`] +/// and compares it with the supplied code using standard string equality. +/// Returns `true` if the codes match, `false` if the code is wrong, missing, +/// or the database query fails. +async fn verify_admin_code(repo: &Repo, code: &str) -> bool { + match repo.get_admin_auth_code().await { + Ok(Some(stored)) => stored == code, + _ => false, + } +} + +/// `POST /api/quotes/:id` — update an existing quote. +/// +/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, +/// `404` if the quote does not exist, or `200` with the updated quote. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn update_handler( + State(repo): State, + 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), + } +} + +/// `POST /api/admin/lock` — lock new quote submissions. +/// +/// Requires the `X-Admin-Code` header. Sets `submissions_locked = true` in +/// the repository and returns the updated lock state as JSON: +/// +/// ```json +/// { "submissions_locked": true } +/// ``` +/// +/// Returns `403 Forbidden` if the header is missing or the code is incorrect. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn lock_submissions(State(repo): State, headers: HeaderMap) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.set_submissions_locked(true).await { + Ok(()) => Json(serde_json::json!({ "submissions_locked": true })).into_response(), + Err(e) => db_error_response(e), + } +} + +/// `POST /api/admin/unlock` — unlock new quote submissions. +/// +/// Requires the `X-Admin-Code` header. Sets `submissions_locked = false` in +/// the repository and returns the updated lock state as JSON: +/// +/// ```json +/// { "submissions_locked": false } +/// ``` +/// +/// Returns `403 Forbidden` if the header is missing or the code is incorrect. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn unlock_submissions(State(repo): State, headers: HeaderMap) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.set_submissions_locked(false).await { + Ok(()) => Json(serde_json::json!({ "submissions_locked": false })).into_response(), + Err(e) => db_error_response(e), + } +} + +/// Request body for `POST /api/admin/reset-auth-code`. +#[derive(Debug, Deserialize)] +struct ResetAuthCodeRequest { + /// New admin auth code. If omitted, the server generates a fresh 4-word passphrase. + new_code: Option, +} + +/// Response body returned by `POST /api/admin/reset-auth-code`. +#[derive(Debug, Serialize)] +struct ResetAuthCodeResponse { + /// The new admin auth code that is now in effect. + auth_code: String, +} + +/// `POST /api/admin/reset-auth-code` — replace the stored admin auth code. +/// +/// Requires the `X-Admin-Code` header containing the **current** admin +/// passphrase. If the header matches the stored code, the code is replaced +/// with either the supplied `new_code` value or a freshly generated 4-word +/// passphrase when `new_code` is omitted. +/// +/// The new code is returned in the response body: +/// +/// ```json +/// { "auth_code": "word-word-word-word" } +/// ``` +/// +/// Returns `403 Forbidden` in two cases: +/// - Missing `X-Admin-Code` header — the handler returns `403` immediately, +/// before any database call. +/// - Wrong code — the DB layer (`update_admin_auth_code`) returns +/// `DbError::Forbidden` when the supplied code does not match the stored +/// value, which the handler maps to `403`. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn reset_auth_code( + State(repo): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let admin_code = match extract_admin_code(&headers) { + Some(c) => c, + None => return StatusCode::FORBIDDEN.into_response(), + }; + match repo + .update_admin_auth_code(&admin_code, payload.new_code.as_deref()) + .await + { + Ok(new_code) => Json(ResetAuthCodeResponse { + auth_code: new_code, + }) + .into_response(), + Err(crate::db::DbError::Forbidden) => StatusCode::FORBIDDEN.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +/// Request body for `POST /api/quotes/:id/report`. +/// +/// All fields are optional — a report can be submitted without a reason. +#[derive(Debug, Deserialize)] +struct ReportInput { + /// Optional human-readable reason for the report. At most 256 characters. + reason: Option, +} + +/// `POST /api/quotes/:id/report` — submit a moderation report for a quote. +/// +/// The request body is a JSON object with an optional `reason` field. The body +/// itself is also optional — omitting it entirely (or sending `{}`) is valid. +/// +/// Returns `201 Created` on success, `400 Bad Request` if the reason exceeds +/// 256 characters, `404 Not Found` if no quote with the given ID exists, or +/// `500 Internal Server Error` on a database failure. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn report_handler( + State(repo): State, + Path(id): Path, + body: Option>, +) -> Response { + let reason = body.and_then(|Json(input)| input.reason); + + // Validate reason length — enforced here before the DB call. + if reason.as_deref().map(|r| r.chars().count()).unwrap_or(0) > 256 { + return error_response( + StatusCode::BAD_REQUEST, + "reason must be at most 256 characters", + ); + } + + match repo.create_report(&id, reason.as_deref()).await { + Ok(()) => StatusCode::CREATED.into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +/// Query parameters for `GET /api/admin/reports`. +#[derive(Debug, Deserialize)] +struct AdminReportsParams { + /// 1-based page number. Defaults to 1. + #[serde(default = "default_page")] + page: u32, +} + +/// `GET /api/admin/reports` — paginated list of reported quotes. +/// +/// Returns a [`ReportListResult`] with 10 entries per page. Each entry +/// contains the quote ID, truncated text, author, total report count, and +/// the timestamp of the most recent report. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn list_reports_handler( + State(repo): State, + headers: HeaderMap, + Query(params): Query, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.list_reports(params.page).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(e) => db_error_response(e), + } +} + +/// `GET /api/admin/reports/:quote_id` — full quote and all reports for it. +/// +/// Returns a JSON object with `quote` and `reports` fields. Reports are +/// ordered oldest first. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn get_quote_reports_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.get_reports_for_quote("e_id).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +/// `DELETE /api/admin/reports/:quote_id/quote` — delete a quote as admin. +/// +/// Deletes the quote unconditionally (no per-quote auth code required). +/// Tags and reports are removed automatically via `ON DELETE CASCADE`. +/// Returns `204 No Content` on success. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn admin_delete_quote_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.admin_delete_quote("e_id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +/// `POST /api/admin/reports/:quote_id/hide` — hide a quote. +/// +/// Sets `hidden = 1` on the quote so it is excluded from public listing. +/// Returns `200 OK` with `{"hidden": true}` on success. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn hide_quote_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.hide_quote("e_id).await { + Ok(()) => Json(serde_json::json!({ "hidden": true })).into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +/// `DELETE /api/admin/reports/:quote_id/reports` — clear all reports for a quote. +/// +/// Removes all report rows for the given quote without deleting the quote +/// itself. Returns `204 No Content` on success. +/// +/// Requires the `X-Admin-Code` header. Returns `403 Forbidden` if the header +/// is absent or the code is incorrect, `404 Not Found` if the quote does not +/// exist. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn clear_reports_handler( + State(repo): State, + Path(quote_id): Path, + headers: HeaderMap, +) -> Response { + let Some(code) = extract_admin_code(&headers) else { + return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required"); + }; + if !verify_admin_code(&repo, &code).await { + return error_response(StatusCode::FORBIDDEN, "invalid admin code"); + } + match repo.clear_reports("e_id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + +// ── Router ──────────────────────────────────────────────────────────────────── + +/// Build the Axum [`Router`] with all API routes wired to their handlers. +/// +/// Route registration order is important: `GET /api/quotes/random` must +/// appear before `GET /api/quotes/:id` so Axum's static segment wins over +/// the dynamic `:id` capture. +/// +/// The repository must implement `Send + Sync` so it can be shared across +/// Tokio tasks by Axum's state mechanism. [`NativeRepository`] satisfies +/// both bounds via `tokio_rusqlite::Connection`. +/// +/// [`NativeRepository`]: crate::db::NativeRepository +pub fn router(repo: Arc) -> Router { + Router::new() + // Meta + .route("/api/", get(openapi_handler)) + // Public status — exposes whether submissions are currently locked. + .route("/api/status", get(get_status)) + // Admin endpoints — toggle the global submissions lock and reset auth code. + .route("/api/admin/lock", post(lock_submissions)) + .route("/api/admin/unlock", post(unlock_submissions)) + .route("/api/admin/reset-auth-code", post(reset_auth_code)) + // Admin moderation endpoints — report management. + .route("/api/admin/reports", get(list_reports_handler)) + .route( + "/api/admin/reports/{quote_id}", + get(get_quote_reports_handler), + ) + .route( + "/api/admin/reports/{quote_id}/quote", + delete(admin_delete_quote_handler), + ) + .route( + "/api/admin/reports/{quote_id}/hide", + post(hide_quote_handler), + ) + .route( + "/api/admin/reports/{quote_id}/reports", + delete(clear_reports_handler), + ) + // IMPORTANT: /random and /{id}/report must be registered before /{id} + // so static segments win over the dynamic capture. + .route("/api/quotes/random", get(random_handler)) + .route("/api/quotes/{id}/report", post(report_handler)) + .route("/api/quotes/{id}", get(get_quote_handler)) + .route("/api/quotes", get(list_handler)) + .route("/api/quotes", put(create_handler)) + .route("/api/quotes/{id}", post(update_handler)) + .route("/api/quotes/{id}", delete(delete_handler)) + .with_state(repo) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Method, Request}, + }; + use tower::util::ServiceExt; // for `oneshot` + + use crate::db::{DbError, DeleteResult, ListResult}; + use crate::{CreateQuoteInput, Quote, UpdateQuoteInput}; + + // ── Mock repository for handler tests ───────────────────────────────────── + + /// A simple mock [`QuoteRepository`] for unit-testing handlers. + /// + /// Tracks in-memory state for quotes, the admin auth code, and the + /// submissions-locked flag so all trait methods can be exercised without a + /// real database. + struct MockRepo { + quotes: std::sync::Mutex>, + /// Stored admin super auth code (`None` until seeded). + admin_auth_code: std::sync::Mutex>, + /// Whether new quote submissions are currently locked. + submissions_locked: std::sync::Mutex, + } + + impl MockRepo { + fn empty() -> Repo { + Arc::new(Self { + quotes: std::sync::Mutex::new(vec![]), + admin_auth_code: std::sync::Mutex::new(None), + submissions_locked: std::sync::Mutex::new(false), + }) + } + + fn with_quote(quote: Quote, auth: &str) -> Repo { + Arc::new(Self { + quotes: std::sync::Mutex::new(vec![(quote, auth.to_owned())]), + admin_auth_code: std::sync::Mutex::new(None), + submissions_locked: std::sync::Mutex::new(false), + }) + } + + /// Build a [`Repo`] pre-seeded with the given admin auth code. + fn with_admin_code(code: &str) -> Arc { + Arc::new(Self { + quotes: std::sync::Mutex::new(vec![]), + admin_auth_code: std::sync::Mutex::new(Some(code.to_owned())), + submissions_locked: std::sync::Mutex::new(false), + }) + } + + /// Build a [`Repo`] with submissions locked to the given state. + fn with_submissions_locked(locked: bool) -> Repo { + Arc::new(Self { + quotes: std::sync::Mutex::new(vec![]), + admin_auth_code: std::sync::Mutex::new(None), + submissions_locked: std::sync::Mutex::new(locked), + }) + } + } + + #[async_trait::async_trait] + impl QuoteRepository for MockRepo { + async fn run_migrations(&self) -> Result<(), DbError> { + Ok(()) + } + + async fn list_quotes( + &self, + page: u32, + _author: Option<&str>, + _tag: Option<&str>, + _date_after: Option<&str>, + _date_before: Option<&str>, + ) -> Result { + 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, + hidden: false, + created_at: "2024-01-01T00:00:00".to_owned(), + updated_at: "2024-01-01T00:00:00".to_owned(), + }; + self.quotes + .lock() + .unwrap() + .push((quote.clone(), auth.clone())); + Ok((quote, auth)) + } + + async fn update_quote( + &self, + id: &str, + input: UpdateQuoteInput, + auth_code: &str, + ) -> Result { + let mut quotes = self.quotes.lock().unwrap(); + let entry = quotes.iter_mut().find(|(q, _)| q.id == id); + match entry { + None => Err(DbError::NotFound), + Some((q, stored_auth)) => { + if stored_auth.as_str() != auth_code { + return Err(DbError::Forbidden); + } + if let Some(t) = input.text { + q.text = t; + } + if let Some(a) = input.author { + q.author = a; + } + q.source = input.source; + q.date = input.date; + if let Some(tags) = input.tags { + q.tags = tags; + } + if let Some(h) = input.hidden { + q.hidden = h; + } + Ok(q.clone()) + } + } + } + + async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { + 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(self.admin_auth_code.lock().unwrap().clone()) + } + + async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> { + let mut guard = self.admin_auth_code.lock().unwrap(); + if guard.is_none() { + *guard = Some(code.to_owned()); + } + Ok(()) + } + + async fn update_admin_auth_code( + &self, + current: &str, + new_code: Option<&str>, + ) -> Result { + let mut guard = self.admin_auth_code.lock().unwrap(); + match guard.as_deref() { + Some(stored) if stored == current => { + let replacement = new_code + .map(|s| s.to_owned()) + .unwrap_or_else(|| "new-mock-code".to_owned()); + *guard = Some(replacement.clone()); + Ok(replacement) + } + _ => Err(DbError::Forbidden), + } + } + + async fn get_submissions_locked(&self) -> Result { + Ok(*self.submissions_locked.lock().unwrap()) + } + + async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> { + *self.submissions_locked.lock().unwrap() = locked; + Ok(()) + } + + async fn seed_submissions_locked(&self) -> Result<(), DbError> { + Ok(()) + } + + async fn create_report( + &self, + quote_id: &str, + _reason: Option<&str>, + ) -> Result<(), DbError> { + let quotes = self.quotes.lock().unwrap(); + if quotes.iter().any(|(q, _)| q.id == quote_id) { + Ok(()) + } else { + Err(DbError::NotFound) + } + } + + async fn list_reports(&self, page: u32) -> Result { + Ok(crate::db::ReportListResult { + reports: vec![], + page, + total_pages: 0, + total_count: 0, + }) + } + + async fn get_reports_for_quote( + &self, + quote_id: &str, + ) -> Result { + let quotes = self.quotes.lock().unwrap(); + let maybe = quotes.iter().find(|(q, _)| q.id == quote_id); + match maybe { + None => Err(DbError::NotFound), + Some((q, _)) => Ok(crate::db::QuoteReports { + quote: q.clone(), + reports: vec![], + }), + } + } + + async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> { + let mut quotes = self.quotes.lock().unwrap(); + let pos = quotes.iter().position(|(q, _)| q.id == quote_id); + match pos { + None => Err(DbError::NotFound), + Some(i) => { + quotes.remove(i); + Ok(()) + } + } + } + + async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> { + let mut quotes = self.quotes.lock().unwrap(); + match quotes.iter_mut().find(|(q, _)| q.id == quote_id) { + None => Err(DbError::NotFound), + Some((q, _)) => { + q.hidden = true; + Ok(()) + } + } + } + + async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> { + let quotes = self.quotes.lock().unwrap(); + if quotes.iter().any(|(q, _)| q.id == quote_id) { + Ok(()) + } else { + Err(DbError::NotFound) + } + } + } + + fn sample_quote() -> Quote { + Quote { + id: "abc-123".to_owned(), + text: "Sample text".to_owned(), + author: "Sample Author".to_owned(), + source: None, + date: None, + tags: vec![], + hidden: false, + created_at: "2024-01-01T00:00:00".to_owned(), + updated_at: "2024-01-01T00:00:00".to_owned(), + } + } + + // ── Helper to send requests to the router ────────────────────────────────── + + async fn send(app: Router, req: Request) -> (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"); + } + + /// `PUT /api/quotes` while `submissions_locked = true` returns `423 Locked` + /// with `{"error": "submissions are closed"}`. + #[tokio::test] + async fn test_create_quote_locked_returns_423() { + let app = router(MockRepo::with_submissions_locked(true)); + let body = serde_json::json!({ + "text": "Locked quote", + "author": "Author", + "tags": [] + }); + let req = Request::builder() + .method(Method::PUT) + .uri("/api/quotes") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::LOCKED); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert_eq!(v["error"], "submissions are closed"); + } + + /// `PUT /api/quotes` while `submissions_locked = false` returns `201 Created` + /// (existing behaviour is unchanged). + #[tokio::test] + async fn test_create_quote_unlocked_returns_201() { + let app = router(MockRepo::with_submissions_locked(false)); + let body = serde_json::json!({ + "text": "Unlocked quote", + "author": "Author", + "tags": [] + }); + let req = Request::builder() + .method(Method::PUT) + .uri("/api/quotes") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::CREATED); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert!(v["auth_code"].is_string()); + } + + /// After unlocking (`submissions_locked = false` after being `true`), + /// `PUT /api/quotes` succeeds again with `201 Created`. + #[tokio::test] + async fn test_create_quote_after_unlock_returns_201() { + // Build a repo that starts locked. + let repo = MockRepo::with_submissions_locked(true); + // Unlock it. + repo.set_submissions_locked(false) + .await + .expect("set_submissions_locked should not fail"); + let app = router(repo); + let body = serde_json::json!({ + "text": "Re-enabled quote", + "author": "Author", + "tags": [] + }); + let req = Request::builder() + .method(Method::PUT) + .uri("/api/quotes") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::CREATED); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert!(v["auth_code"].is_string()); + assert_eq!(v["quote"]["text"], "Re-enabled quote"); + } + + #[tokio::test] + async fn test_update_quote_missing_auth() { + let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let body = serde_json::json!({"text": "Updated"}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/abc-123") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_update_quote_wrong_auth() { + let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let body = serde_json::json!({"text": "Updated"}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/abc-123") + .header("Content-Type", "application/json") + .header("X-Auth-Code", "wrong") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_update_quote_success() { + let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let body = serde_json::json!({"text": "Updated text"}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/abc-123") + .header("Content-Type", "application/json") + .header("X-Auth-Code", "correct") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert_eq!(v["text"], "Updated text"); + } + + #[tokio::test] + async fn test_delete_quote_missing_auth() { + let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/quotes/abc-123") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_delete_quote_success() { + let app = router(MockRepo::with_quote(sample_quote(), "correct")); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/quotes/abc-123") + .header("X-Auth-Code", "correct") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_delete_quote_not_found() { + let app = router(MockRepo::empty()); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/quotes/nonexistent") + .header("X-Auth-Code", "any") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + // ── Admin DB method tests ────────────────────────────────────────────────── + + /// `get_submissions_locked` returns `false` when the repo has just been + /// created (no `set_submissions_locked` call has been made yet). + #[tokio::test] + async fn test_get_submissions_locked_default_false() { + let repo = MockRepo::empty(); + let locked = repo + .get_submissions_locked() + .await + .expect("get_submissions_locked should not fail"); + assert!(!locked, "submissions should be unlocked by default"); + } + + /// After calling `set_submissions_locked(true)`, `get_submissions_locked` + /// must return `true`. + #[tokio::test] + async fn test_set_and_get_submissions_locked() { + let repo = MockRepo::empty(); + repo.set_submissions_locked(true) + .await + .expect("set_submissions_locked should not fail"); + let locked = repo + .get_submissions_locked() + .await + .expect("get_submissions_locked should not fail"); + assert!( + locked, + "submissions should be locked after set_submissions_locked(true)" + ); + } + + /// `update_admin_auth_code` with the correct current code succeeds and + /// returns the new code. + #[tokio::test] + async fn test_update_admin_auth_code_correct_current_succeeds() { + let repo = MockRepo::with_admin_code("old-code"); + let new_code = repo + .update_admin_auth_code("old-code", Some("brand-new-code")) + .await + .expect("update_admin_auth_code should succeed when current matches"); + assert_eq!(new_code, "brand-new-code"); + // The stored code should now be the new one. + let stored = repo + .get_admin_auth_code() + .await + .expect("get_admin_auth_code should not fail"); + assert_eq!(stored.as_deref(), Some("brand-new-code")); + } + + /// `update_admin_auth_code` with the wrong current code returns + /// `Err(DbError::Forbidden)`. + #[tokio::test] + async fn test_update_admin_auth_code_wrong_current_forbidden() { + let repo = MockRepo::with_admin_code("real-code"); + let result = repo + .update_admin_auth_code("wrong-code", Some("irrelevant")) + .await; + assert!( + matches!(result, Err(DbError::Forbidden)), + "expected Forbidden, got {result:?}", + ); + } + + // ── GET /api/status handler tests ───────────────────────────────────────── + + /// `GET /api/status` returns `200` with `{"submissions_locked": false}` when + /// the repo's submissions lock is unset (the default `false` state). + #[tokio::test] + async fn test_get_status_unlocked() { + let app = router(MockRepo::empty()); + let req = Request::builder() + .method(Method::GET) + .uri("/api/status") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], false); + } + + /// `GET /api/status` returns `200` with `{"submissions_locked": true}` after + /// the lock has been enabled via `set_submissions_locked(true)`. + #[tokio::test] + async fn test_get_status_locked() { + let repo = MockRepo::empty(); + repo.set_submissions_locked(true) + .await + .expect("set_submissions_locked should not fail"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/status") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], true); + } + + /// `get_submissions_locked` returns `false` for a freshly created repo + /// (graceful default — no DB row or explicit seed needed). + #[tokio::test] + async fn test_get_submissions_locked_default_is_false() { + let repo = MockRepo::empty(); + let locked = repo + .get_submissions_locked() + .await + .expect("get_submissions_locked should not fail"); + assert!(!locked, "submissions should default to unlocked"); + } + + // ── POST /api/admin/lock handler tests ──────────────────────────────────── + + /// `POST /api/admin/lock` with the correct admin code returns `200` and + /// `{ "submissions_locked": true }`. + #[tokio::test] + async fn test_lock_submissions_correct_code_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/lock") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], true); + } + + /// `POST /api/admin/unlock` with the correct admin code returns `200` and + /// `{ "submissions_locked": false }`. + #[tokio::test] + async fn test_unlock_submissions_correct_code_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + // Start in locked state. + repo.set_submissions_locked(true) + .await + .expect("set_submissions_locked should not fail"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/unlock") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], false); + } + + /// `POST /api/admin/lock` with a wrong admin code returns `403`. + #[tokio::test] + async fn test_lock_submissions_wrong_code_returns_403() { + let repo = MockRepo::with_admin_code("real-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/lock") + .header("X-Admin-Code", "wrong-code") + .body(Body::empty()) + .unwrap(); + let (status, _body) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// `POST /api/admin/unlock` with no `X-Admin-Code` header returns `403`. + #[tokio::test] + async fn test_unlock_submissions_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/unlock") + .body(Body::empty()) + .unwrap(); + let (status, _body) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// Locking when already locked is idempotent — returns `200` with + /// `{ "submissions_locked": true }`. + #[tokio::test] + async fn test_lock_submissions_idempotent() { + let repo = MockRepo::with_admin_code("admin-secret"); + // Lock once via the trait directly. + repo.set_submissions_locked(true) + .await + .expect("initial lock should not fail"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/lock") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["submissions_locked"], true); + } + + // ── POST /api/admin/reset-auth-code handler tests ───────────────────────── + + /// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no + /// `new_code` in the body returns `200` with a non-empty `auth_code`. + /// The MockRepo returns `"new-mock-code"` when `new_code` is `None`. + #[tokio::test] + async fn test_reset_auth_code_correct_code_no_new_code_returns_200() { + let repo = MockRepo::with_admin_code("current-secret"); + let app = router(repo); + let body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "current-secret") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert!( + v["auth_code"].is_string(), + "response must contain auth_code string" + ); + assert!( + !v["auth_code"].as_str().unwrap().is_empty(), + "auth_code must be non-empty" + ); + } + + /// `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and an + /// explicit `new_code` returns `200` and `auth_code` equals the supplied value. + #[tokio::test] + async fn test_reset_auth_code_correct_code_explicit_new_code_returns_200() { + let repo = MockRepo::with_admin_code("current-secret"); + let app = router(repo); + let body = serde_json::json!({ "new_code": "brand-new-passphrase" }); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "current-secret") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap(); + assert_eq!( + v["auth_code"], "brand-new-passphrase", + "auth_code must equal the supplied new_code" + ); + } + + /// `POST /api/admin/reset-auth-code` with a wrong `X-Admin-Code` returns `403`. + #[tokio::test] + async fn test_reset_auth_code_wrong_code_returns_403() { + let repo = MockRepo::with_admin_code("real-secret"); + let app = router(repo); + let body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "wrong-secret") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// `POST /api/admin/reset-auth-code` with no `X-Admin-Code` header returns `403`. + #[tokio::test] + async fn test_reset_auth_code_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("real-secret"); + let app = router(repo); + let body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── POST /api/quotes/:id/report handler tests ────────────────────────────── + + /// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`. + #[tokio::test] + async fn test_report_success() { + let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let body = serde_json::json!({ "reason": "inappropriate content" }); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/abc-123/report") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::CREATED); + } + + /// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`. + #[tokio::test] + async fn test_report_quote_not_found() { + let app = router(MockRepo::empty()); + let body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/unknown/report") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `POST /api/quotes/:id/report` with a reason longer than 256 characters + /// returns `400 Bad Request`. + #[tokio::test] + async fn test_report_reason_too_long() { + let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let long_reason = "x".repeat(257); + let body = serde_json::json!({ "reason": long_reason }); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/abc-123/report") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + /// After a successful reset, subsequent calls with the old code return `403` + /// and with the new code return `200`. + #[tokio::test] + async fn test_reset_auth_code_old_code_rejected_after_reset() { + let repo = MockRepo::with_admin_code("old-secret"); + + // First reset: change from "old-secret" to "new-secret". + let first_body = serde_json::json!({ "new_code": "new-secret" }); + let first_req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "old-secret") + .header("Content-Type", "application/json") + .body(Body::from(first_body.to_string())) + .unwrap(); + let app = router(Arc::clone(&repo) as Repo); + let (status, _) = send(app, first_req).await; + assert_eq!(status, StatusCode::OK, "first reset must succeed"); + + // Second call with old code must now be forbidden. + let second_body = serde_json::json!({}); + let second_req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "old-secret") + .header("Content-Type", "application/json") + .body(Body::from(second_body.to_string())) + .unwrap(); + let app2 = router(Arc::clone(&repo) as Repo); + let (status2, _) = send(app2, second_req).await; + assert_eq!( + status2, + StatusCode::FORBIDDEN, + "old code must be rejected after reset" + ); + + // Third call with the new code must succeed. + let third_body = serde_json::json!({}); + let third_req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reset-auth-code") + .header("X-Admin-Code", "new-secret") + .header("Content-Type", "application/json") + .body(Body::from(third_body.to_string())) + .unwrap(); + let app3 = router(repo as Repo); + let (status3, resp_body3) = send(app3, third_req).await; + assert_eq!( + status3, + StatusCode::OK, + "new code must be accepted after reset" + ); + let v: serde_json::Value = serde_json::from_str(&resp_body3).unwrap(); + assert!( + v["auth_code"].is_string(), + "response must include auth_code after second reset" + ); + } + + // ── GET /api/admin/reports handler tests ────────────────────────────────── + + /// `GET /api/admin/reports` with a valid admin code returns `200` and a + /// [`ReportListResult`] JSON body (empty list since MockRepo returns no rows). + #[tokio::test] + async fn test_list_reports_correct_code_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["total_count"], 0); + assert!(v["reports"].is_array()); + } + + /// `GET /api/admin/reports` with no `X-Admin-Code` header returns `403`. + #[tokio::test] + async fn test_list_reports_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// `GET /api/admin/reports` with a wrong admin code returns `403`. + #[tokio::test] + async fn test_list_reports_wrong_code_returns_403() { + let repo = MockRepo::with_admin_code("real-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "wrong-code") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── GET /api/admin/reports/:quote_id handler tests ──────────────────────── + + /// `GET /api/admin/reports/:quote_id` with a valid code and existing quote + /// returns `200` with the quote and an empty reports list. + #[tokio::test] + async fn test_get_quote_reports_found_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + // Seed a quote into the mock. + repo.create_quote(crate::CreateQuoteInput { + text: "Test".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + // Retrieve the quote id. + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::GET) + .uri(format!("/api/admin/reports/{quote_id}")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["quote"]["id"], quote_id.as_str()); + assert!(v["reports"].is_array()); + } + + /// `GET /api/admin/reports/:quote_id` for a nonexistent quote returns `404`. + #[tokio::test] + async fn test_get_quote_reports_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports/nonexistent") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `GET /api/admin/reports/:quote_id` with no admin code returns `403`. + #[tokio::test] + async fn test_get_quote_reports_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports/any-id") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── DELETE /api/admin/reports/:quote_id/quote handler tests ─────────────── + + /// `DELETE /api/admin/reports/:quote_id/quote` with a valid code deletes + /// the quote and returns `204 No Content`. + #[tokio::test] + async fn test_admin_delete_quote_returns_204() { + let repo = MockRepo::with_admin_code("admin-secret"); + repo.create_quote(crate::CreateQuoteInput { + text: "Delete me".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/quote")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // Verify the quote is gone. + let quotes = repo.quotes.lock().unwrap(); + assert!(quotes.is_empty(), "quote should have been deleted"); + } + + /// `DELETE /api/admin/reports/:quote_id/quote` for a nonexistent quote + /// returns `404`. + #[tokio::test] + async fn test_admin_delete_quote_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/nonexistent/quote") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `DELETE /api/admin/reports/:quote_id/quote` with no admin code returns + /// `403`. + #[tokio::test] + async fn test_admin_delete_quote_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/any-id/quote") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── POST /api/admin/reports/:quote_id/hide handler tests ────────────────── + + /// `POST /api/admin/reports/:quote_id/hide` with a valid code sets the + /// quote hidden and returns `200` with `{"hidden": true}`. + #[tokio::test] + async fn test_hide_quote_returns_200() { + let repo = MockRepo::with_admin_code("admin-secret"); + repo.create_quote(crate::CreateQuoteInput { + text: "Hide me".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::POST) + .uri(format!("/api/admin/reports/{quote_id}/hide")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, body) = send(app, req).await; + assert_eq!(status, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["hidden"], true); + + // Verify the quote is now hidden in the mock. + let quotes = repo.quotes.lock().unwrap(); + assert!(quotes[0].0.hidden, "quote should be marked hidden"); + } + + /// `POST /api/admin/reports/:quote_id/hide` for a nonexistent quote + /// returns `404`. + #[tokio::test] + async fn test_hide_quote_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reports/nonexistent/hide") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `POST /api/admin/reports/:quote_id/hide` with no admin code returns + /// `403`. + #[tokio::test] + async fn test_hide_quote_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/reports/any-id/hide") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + // ── DELETE /api/admin/reports/:quote_id/reports handler tests ───────────── + + /// `DELETE /api/admin/reports/:quote_id/reports` with a valid code clears + /// all reports and returns `204 No Content`. + #[tokio::test] + async fn test_clear_reports_returns_204() { + let repo = MockRepo::with_admin_code("admin-secret"); + repo.create_quote(crate::CreateQuoteInput { + text: "Reported".to_owned(), + author: "Author".to_owned(), + source: None, + date: None, + tags: vec![], + auth_code: Some("auth".to_owned()), + cf_turnstile_token: None, + }) + .await + .unwrap(); + let quotes = repo.quotes.lock().unwrap(); + let quote_id = quotes[0].0.id.clone(); + drop(quotes); + + let app = router(Arc::clone(&repo) as Repo); + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/reports")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + } + + /// `DELETE /api/admin/reports/:quote_id/reports` for a nonexistent quote + /// returns `404`. + #[tokio::test] + async fn test_clear_reports_not_found_returns_404() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/nonexistent/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `DELETE /api/admin/reports/:quote_id/reports` with no admin code returns + /// `403`. + #[tokio::test] + async fn test_clear_reports_missing_header_returns_403() { + let repo = MockRepo::with_admin_code("admin-secret"); + let app = router(repo); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/admin/reports/any-id/reports") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } +} + +// ── Integration tests (real NativeRepository + real SQLite) ───────────────── +// +// These tests spin up the full Axum router backed by a temporary file-based +// SQLite database. Each test gets its own database via `NamedTempFile` so +// there is no cross-test interference. +// +// Tickets covered: +// 789d0f GET /api/ returns OpenAPI JSON +// aa0eab GET /api/quotes/random +// f9f448 GET /api/quotes/:id +// 4a4c26 PUT /api/quotes (create) +// 93f1b6 GET /api/quotes (list + filters + pagination) +// fae330 POST /api/quotes/:id (update) +// 8c87db DELETE /api/quotes/:id +// 893eba Tag operations +// e8f5cf Router ordering (/random not matched as :id) +#[cfg(test)] +mod integration_tests { + use super::*; + use axum::http::Request; + use axum::{body::Body, http::Method}; + use serde_json::json; + use tempfile::NamedTempFile; + use tower::util::ServiceExt; + + use crate::db::connection; + + // ── Harness ─────────────────────────────────────────────────────────────── + + /// Create an Axum router backed by a real, migrated NativeRepository + /// stored in a temporary file. Returns both the router and the temp file + /// handle (which must be kept alive for the duration of the test). + async fn test_router() -> (Router, NamedTempFile) { + let f = NamedTempFile::new().expect("failed to create temp db file"); + let repo = connection::open(f.path().to_str().expect("non-utf8 temp path")) + .await + .expect("failed to open test database"); + repo.run_migrations().await.expect("migrations failed"); + let repo: Arc = 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); + } + + // ── Admin moderation endpoint integration tests (ticket 6c5904) ─────────── + + /// Build a `test_router` and seed a known admin auth code so integration + /// tests can authenticate without reading the printed code. + async fn test_router_with_admin(admin_code: &str) -> (Router, NamedTempFile) { + let f = NamedTempFile::new().expect("failed to create temp db file"); + let repo = connection::open(f.path().to_str().expect("non-utf8 temp path")) + .await + .expect("failed to open test database"); + repo.run_migrations().await.expect("migrations failed"); + repo.seed_admin_auth_code(admin_code) + .await + .expect("failed to seed admin code"); + repo.seed_submissions_locked() + .await + .expect("failed to seed submissions lock"); + let repo: Arc = Arc::new(repo); + (router(repo), f) + } + + /// Submit a report for a quote via `POST /api/quotes/:id/report`. + async fn report_quote(app: Router, quote_id: &str) -> Router { + let req = Request::builder() + .method(Method::POST) + .uri(format!("/api/quotes/{quote_id}/report")) + .header("Content-Type", "application/json") + .body(Body::from(r#"{"reason":"spam"}"#)) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::CREATED, + "report_quote: unexpected status" + ); + app + } + + /// `GET /api/admin/reports` returns `200` with an empty list when there + /// are no reported quotes. + #[tokio::test] + async fn integration_list_reports_empty_returns_200() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["total_count"], 0); + assert_eq!(v["reports"].as_array().unwrap().len(), 0); + } + + /// `GET /api/admin/reports` returns a summary entry after a report has + /// been submitted for a quote. + #[tokio::test] + async fn integration_list_reports_with_report_returns_entry() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Reported quote", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + let req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["total_count"], 1); + let entry = &v["reports"][0]; + assert_eq!(entry["quote_id"], quote_id.as_str()); + assert_eq!(entry["report_count"], 1); + } + + /// `GET /api/admin/reports/:quote_id` returns `200` with the full quote + /// and all report rows. + #[tokio::test] + async fn integration_get_quote_reports_returns_full_detail() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Flagged", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + let req = Request::builder() + .method(Method::GET) + .uri(format!("/api/admin/reports/{quote_id}")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["quote"]["id"], quote_id.as_str()); + assert_eq!(v["reports"].as_array().unwrap().len(), 1); + assert_eq!(v["reports"][0]["reason"], "spam"); + } + + /// `DELETE /api/admin/reports/:quote_id/quote` deletes the quote; a + /// subsequent `GET /api/quotes/:id` returns `404`. + #[tokio::test] + async fn integration_admin_delete_quote_removes_quote() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "To delete", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + // Delete via admin endpoint. + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/quote")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Confirm the quote is gone. + let req2 = Request::builder() + .method(Method::GET) + .uri(format!("/api/quotes/{quote_id}")) + .body(Body::empty()) + .unwrap(); + let resp2 = ServiceExt::>::oneshot(app, req2) + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::NOT_FOUND); + } + + /// `POST /api/admin/reports/:quote_id/hide` sets `hidden = true`; the + /// `hidden` field on the quote is `true` when fetched via GET afterward. + #[tokio::test] + async fn integration_hide_quote_sets_hidden_flag() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Hide me", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + // Hide the quote. + let req = Request::builder() + .method(Method::POST) + .uri(format!("/api/admin/reports/{quote_id}/hide")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["hidden"], true); + + // Verify via GET. + let req2 = Request::builder() + .method(Method::GET) + .uri(format!("/api/quotes/{quote_id}")) + .body(Body::empty()) + .unwrap(); + let resp2 = ServiceExt::>::oneshot(app, req2) + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::OK); + let v2 = body_json(resp2).await; + assert_eq!(v2["hidden"], true); + } + + /// `DELETE /api/admin/reports/:quote_id/reports` clears reports; the list + /// is empty afterward. + #[tokio::test] + async fn integration_clear_reports_empties_report_list() { + let (app, _f) = test_router_with_admin("admin-secret").await; + let (app, quote, _auth) = create_quote_raw(app, "Spammy", "Author", &[]).await; + let quote_id = quote["id"].as_str().unwrap().to_owned(); + let app = report_quote(app, "e_id).await; + + // Confirm there is one report before clearing. + let check_req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let check_resp = ServiceExt::>::oneshot(app.clone(), check_req) + .await + .unwrap(); + let v = body_json(check_resp).await; + assert_eq!( + v["total_count"], 1, + "should have one report before clearing" + ); + + // Clear reports. + let req = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/admin/reports/{quote_id}/reports")) + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Confirm no reports remain. + let check2_req = Request::builder() + .method(Method::GET) + .uri("/api/admin/reports") + .header("X-Admin-Code", "admin-secret") + .body(Body::empty()) + .unwrap(); + let check2_resp = ServiceExt::>::oneshot(app, check2_req) + .await + .unwrap(); + let v2 = body_json(check2_resp).await; + assert_eq!(v2["total_count"], 0, "all reports should be cleared"); + } +} diff --git a/quotesdb/wrangler.toml b/quotesdb/wrangler.toml new file mode 100644 index 0000000..2ef32e4 --- /dev/null +++ b/quotesdb/wrangler.toml @@ -0,0 +1,23 @@ +# Wrangler configuration for the quotesdb API Worker. +# Used by `wrangler deploy` (invoked via `just deploy-api` or tofu apply). +# wrangler is required because the Cloudflare Terraform provider v4 does not +# support uploading ES module workers that import wasm files — wrangler handles +# the multipart module bundle upload correctly. + +name = "quotesdb-api" +main = "build/index.js" +compatibility_date = "2024-11-01" + +# Route: maps quotes.elijah.run/api/* to this worker. +# Managed here rather than in Terraform because wrangler already owns the script +# upload (provider v4 can't handle ES module + wasm), so it's simpler to let +# wrangler own the route too. +[[routes]] +pattern = "quotes.elijah.run/api/*" +zone_name = "elijah.run" + +# D1 database binding — referenced in workers-rs code as `env.DB`. +[[d1_databases]] +binding = "DB" +database_name = "quotesdb" +database_id = "42aadd79-ce31-4ee0-b9d7-04d06062c607"