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"