diff --git a/flake.lock b/flake.lock index 0197e99..fcec752 100644 --- a/flake.lock +++ b/flake.lock @@ -51,11 +51,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1772419343, - "narHash": "sha256-QU3Cd5DJH7dHyMnGEFfPcZDaCAsJQ6tUD+JuUsYqnKU=", + "lastModified": 1772956932, + "narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "93178f6a00c22fcdee1c6f5f9ab92f2072072ea9", + "rev": "608d0cadfed240589a7eea422407a547ad626a14", "type": "github" }, "original": { @@ -101,11 +101,11 @@ ] }, "locked": { - "lastModified": 1772420823, - "narHash": "sha256-q3oVwz1Rx41D1D+F6vg41kpOkk3Zi3KwnkHEZp7DCGs=", + "lastModified": 1773025773, + "narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "458eea8d905c609e9d889423e6b8a1c7bc2f792c", + "rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 56f6b3b..505414e 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,7 @@ # Cloudflare pkgs.wrangler + pkgs.worker-build # General pkgs.pkg-config @@ -62,6 +63,8 @@ # Build book pkgs.mdbook + + pkgs.just ]; shellHook = '' diff --git a/quotesdb/.gitignore b/quotesdb/.gitignore index 43217b4..29b01f0 100644 --- a/quotesdb/.gitignore +++ b/quotesdb/.gitignore @@ -1,2 +1,7 @@ dist/ + +# Binary database file quotesdb.sqlite* + +# compiled files +*.wasm diff --git a/quotesdb/Cargo.lock b/quotesdb/Cargo.lock index 2f7bf65..8782dd0 100644 --- a/quotesdb/Cargo.lock +++ b/quotesdb/Cargo.lock @@ -455,7 +455,7 @@ dependencies = [ "gloo-events", "gloo-utils", "serde", - "serde-wasm-bindgen 0.6.5", + "serde-wasm-bindgen", "serde_urlencoded", "thiserror 1.0.69", "wasm-bindgen", @@ -1465,17 +1465,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-wasm-bindgen" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - [[package]] name = "serde-wasm-bindgen" version = "0.6.5" @@ -2091,9 +2080,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -2404,9 +2393,9 @@ dependencies = [ [[package]] name = "worker" -version = "0.5.0" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727789ca7eff9733efbea9d0e97779edc1cf1926e98aee7d7d8afe32805458aa" +checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" dependencies = [ "async-trait", "bytes", @@ -2419,7 +2408,7 @@ dependencies = [ "matchit 0.7.3", "pin-project", "serde", - "serde-wasm-bindgen 0.6.5", + "serde-wasm-bindgen", "serde_json", "serde_urlencoded", "tokio", @@ -2428,31 +2417,15 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "worker-kv", "worker-macros", "worker-sys", ] -[[package]] -name = "worker-kv" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f06d4d1416a9f8346ee9123b0d9a11b3cfa38e6cfb5a139698017d1597c4d41" -dependencies = [ - "js-sys", - "serde", - "serde-wasm-bindgen 0.5.0", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", -] - [[package]] name = "worker-macros" -version = "0.5.0" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d625c24570ba9207a2617476013335f28a95cbe513e59bb814ffba092a18058" +checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" dependencies = [ "async-trait", "proc-macro2", @@ -2466,9 +2439,9 @@ dependencies = [ [[package]] name = "worker-sys" -version = "0.5.0" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34563340d41016b4381257c5a16b0d2bc590dbe00500ecfbebcaa16f5f85ce90" +checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" dependencies = [ "cfg-if", "js-sys", diff --git a/quotesdb/Cargo.toml b/quotesdb/Cargo.toml index f4bc41a..ab425fe 100644 --- a/quotesdb/Cargo.toml +++ b/quotesdb/Cargo.toml @@ -5,6 +5,15 @@ edition = "2021" license = "MIT OR Apache-2.0" default-run = "api" +[features] +# Enable API server code: Cloudflare D1 bindings, route handlers, and the +# Workers fetch event entry point. Required for `cargo run` (native) and +# `worker-build` (wasm32 Workers). Do NOT enable for Trunk UI builds. +workers-api = [] + +[lib] +crate-type = ["cdylib", "rlib"] + [[bin]] name = "api" path = "src/bin/api/main.rs" @@ -52,7 +61,7 @@ uuid = { version = "1", features = ["js"] } # Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc. # The `http` feature makes the fetch handler accept/return standard http types, # enabling direct Axum router integration without manual request conversion. -worker = { version = "0.5", features = ["d1", "http"] } +worker = { version = "0.7", features = ["d1", "http"] } # Axum HTTP framework for the WASM Workers entry point — minimal feature set # (no full tokio runtime needed; json and query features required for handlers). axum = { version = "0.8", default-features = false, features = ["json", "query"] } diff --git a/quotesdb/index.html b/quotesdb/index.html index f761992..dd626a8 100644 --- a/quotesdb/index.html +++ b/quotesdb/index.html @@ -5,7 +5,7 @@ QuotesDB - + diff --git a/quotesdb/infra/README.md b/quotesdb/infra/README.md index daa2780..43f0e57 100644 --- a/quotesdb/infra/README.md +++ b/quotesdb/infra/README.md @@ -8,7 +8,7 @@ OpenTofu configuration for deploying quotesdb to Cloudflare. |---|---| | `cloudflare_d1_database.quotesdb` | D1 SQLite database backing the API | | `cloudflare_workers_script.api` | Compiled Wasm Worker serving `/api/*` | -| `cloudflare_worker_route.api` | Routes `quotes.elijah.run/api/*` to the Worker | +| `cloudflare_workers_route.api` | Routes `quotes.elijah.run/api/*` to the Worker | | `cloudflare_pages_project.ui` | Pages project hosting the Yew SPA | | `cloudflare_record.ui` | CNAME `quotes.elijah.run` → Pages | | `cloudflare_pages_domain.ui` | Custom domain binding on Pages | diff --git a/quotesdb/infra/main.tf b/quotesdb/infra/main.tf index 26f67e1..e85e64b 100644 --- a/quotesdb/infra/main.tf +++ b/quotesdb/infra/main.tf @@ -7,5 +7,10 @@ terraform { source = "registry.terraform.io/cloudflare/cloudflare" version = "~> 4" } + # null provider for wrangler-based worker deployment via local-exec. + null = { + source = "registry.terraform.io/hashicorp/null" + version = "~> 3" + } } } diff --git a/quotesdb/infra/rate-limits.tf b/quotesdb/infra/rate-limits.tf index 196b6d5..21e02af 100644 --- a/quotesdb/infra/rate-limits.tf +++ b/quotesdb/infra/rate-limits.tf @@ -1,8 +1,9 @@ # Cloudflare WAF rate limiting rules for the quotesdb API. # Uses the http_ratelimit phase of cloudflare_ruleset to enforce per-IP request caps -# on all mutating endpoints. Rules are evaluated top-to-bottom; first match wins. -# The report rule must appear before the general update rule to prevent the broader -# /api/quotes/* pattern from matching /api/quotes/*/report first. +# on all mutating endpoints. +# +# NOTE: The Cloudflare Free plan allows only 1 rule per zone in the http_ratelimit +# phase, so all mutating endpoints are combined into a single rule. resource "cloudflare_ruleset" "api_rate_limits" { # Scoped to the elijah.run zone that hosts quotes.elijah.run. zone_id = var.cloudflare_zone_id @@ -12,58 +13,22 @@ resource "cloudflare_ruleset" "api_rate_limits" { phase = "http_ratelimit" rules { - # Limit quote creation via PUT /api/quotes: 5 requests per IP per 10 minutes. - # PUT is the create verb per the quotesdb API design. - description = "Limit PUT /api/quotes to 5 per IP per 10 minutes" - expression = "(http.request.method eq \"PUT\" and http.request.uri.path eq \"/api/quotes\")" + # Limit all requests to /api/ to 2 per IP per 10-second window. + # Free plan restrictions: + # - expression fields: Path and Verified Bot only (no Method, no regex) + # - characteristics: IP only (no cf.colo.id) + # - period: must be 10 (only allowed value on Free) + # - operator: no `matches` (regex); use `contains` instead + description = "Limit /api/ requests to 2 per IP per 10 seconds" + expression = "http.request.uri.path contains \"/api/quotes\"" action = "block" ratelimit { - characteristics = ["ip.src"] - period = 600 - requests_per_period = 5 - mitigation_timeout = 600 - } - } - - rules { - # Limit report submissions: 3 POST /api/quotes/:id/report per IP per hour. - # This rule must precede the general update rule below so the more-specific - # /report path is matched first before the broader /api/quotes/* pattern. - description = "Limit POST /api/quotes/*/report to 3 per IP per hour" - expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+/report$\")" - action = "block" - ratelimit { - characteristics = ["ip.src"] - period = 3600 - requests_per_period = 3 - mitigation_timeout = 3600 - } - } - - rules { - # Limit quote updates: 10 POST /api/quotes/:id per IP per minute. - # Excludes the /report sub-path (handled by the rule above). - description = "Limit POST /api/quotes/:id to 10 per IP per minute" - expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")" - action = "block" - ratelimit { - characteristics = ["ip.src"] - period = 60 - requests_per_period = 10 - mitigation_timeout = 60 - } - } - - rules { - # Limit quote deletes: 10 DELETE /api/quotes/:id per IP per minute. - description = "Limit DELETE /api/quotes/:id to 10 per IP per minute" - expression = "(http.request.method eq \"DELETE\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")" - action = "block" - ratelimit { - characteristics = ["ip.src"] - period = 60 - requests_per_period = 10 - mitigation_timeout = 60 + # cf.colo.id is required by the API (20155) — rate limiting is processed + # per-colocation and must always be included alongside ip.src. + characteristics = ["ip.src", "cf.colo.id"] + period = 10 + requests_per_period = 2 + mitigation_timeout = 10 } } } diff --git a/quotesdb/infra/worker.tf b/quotesdb/infra/worker.tf index ca15f38..7914ab3 100644 --- a/quotesdb/infra/worker.tf +++ b/quotesdb/infra/worker.tf @@ -1,27 +1,30 @@ -# Cloudflare Workers script for the quotesdb API. -# Compiled from the `api` binary targeting wasm32-unknown-unknown. -# Build before applying: cargo build --release --bin api --target wasm32-unknown-unknown -resource "cloudflare_workers_script" "api" { - account_id = var.cloudflare_account_id - - # Script name used in the Cloudflare dashboard and for routing. - name = "quotesdb-api" - - # Compiled Wasm binary — path is relative to the infra/ directory. - content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm") - - # D1 database binding — referenced in workers-rs code as `env.DB`. - # Depends implicitly on cloudflare_d1_database.quotesdb via attribute reference. - d1_database_binding { - name = "DB" - database_id = cloudflare_d1_database.quotesdb.id +# Deploys the quotesdb API Worker using wrangler. +# The Cloudflare Terraform provider v4 cannot upload ES module workers that +# import wasm files (error 10021). wrangler handles the multipart module bundle +# upload correctly, so we use a null_resource with local-exec instead. +# +# The worker route (quotes.elijah.run/api/*) is also managed by wrangler via +# wrangler.toml [[routes]] — keeping route and script lifecycle together avoids +# authentication errors from split ownership. +# +# Build before applying: just build-api (runs worker-build --release) +# Output lands in build/: index.js (ES module entry) + index_bg.wasm (wasm-bindgen output). +resource "null_resource" "worker_deploy" { + # Re-deploy whenever the compiled JS or wasm changes. + triggers = { + js_hash = filemd5("../build/index.js") + wasm_hash = filemd5("../build/index_bg.wasm") } -} -# Route that maps quotes.elijah.run/api/* to the quotesdb-api Worker. -# All other requests on the domain are served by Cloudflare Pages. -resource "cloudflare_worker_route" "api" { - zone_id = var.cloudflare_zone_id - pattern = "quotes.elijah.run/api/*" - script_name = cloudflare_workers_script.api.name + # Run wrangler deploy from the quotesdb project root (where build/ and wrangler.toml live). + provisioner "local-exec" { + command = "wrangler deploy --config wrangler.toml" + working_dir = ".." + environment = { + CLOUDFLARE_API_TOKEN = var.cloudflare_api_token + # Providing the account ID avoids a /memberships API call that fails + # with restricted-scope tokens. + CLOUDFLARE_ACCOUNT_ID = var.cloudflare_account_id + } + } } diff --git a/quotesdb/src/bin/api/db/connection.rs b/quotesdb/src/bin/api/db/connection.rs deleted file mode 100644 index 14f480a..0000000 --- a/quotesdb/src/bin/api/db/connection.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! 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> { -/// 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/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs deleted file mode 100644 index d729735..0000000 --- a/quotesdb/src/bin/api/db/d1.rs +++ /dev/null @@ -1,942 +0,0 @@ -//! 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 quotesdb::{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. - 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 - .exec(sql) - .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.exec(ALTER_QUOTES_ADD_HIDDEN).await; - 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/bin/api/db/migrations.rs b/quotesdb/src/bin/api/db/migrations.rs deleted file mode 100644 index a76a56f..0000000 --- a/quotesdb/src/bin/api/db/migrations.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! 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/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs deleted file mode 100644 index c8fe3cc..0000000 --- a/quotesdb/src/bin/api/db/mod.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! 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(target_arch = "wasm32")] -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: quotesdb::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: quotesdb::CreateQuoteInput, - ) -> Result<(quotesdb::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: quotesdb::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/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs deleted file mode 100644 index dc873e4..0000000 --- a/quotesdb/src/bin/api/db/native.rs +++ /dev/null @@ -1,1524 +0,0 @@ -//! 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 quotesdb::{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 = quotesdb::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/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs deleted file mode 100644 index 1f5b549..0000000 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ /dev/null @@ -1,3156 +0,0 @@ -//! HTTP request handlers for the `quotesdb` API. -//! -//! Each handler maps to one route in the API specification. The [`router`] -//! function assembles the Axum [`Router`] with all routes in the required -//! order — in particular, `GET /api/quotes/random` is registered **before** -//! `GET /api/quotes/:id` to prevent "random" being captured as an id. -//! -//! All handlers share a [`crate::db::QuoteRepository`] via Axum's state -//! mechanism, wrapped in an [`Arc`] to allow cheap cloning across tasks. - -use std::sync::Arc; - -use axum::{ - extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Json, Response}, - routing::{delete, get, post, put}, - Router, -}; -use serde::{Deserialize, Serialize}; - -use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; - -use crate::db::{DeleteResult, QuoteRepository}; - -// ── Shared application state ────────────────────────────────────────────────── - -/// Type alias for the shared repository handle. -/// -/// `Send + Sync` are required by Axum's native router so the state can be -/// shared across Tokio tasks. `NativeRepository` satisfies both bounds. -type Repo = Arc; - -// ── Error response helpers ───────────────────────────────────────────────────── - -/// JSON envelope for all API error responses. -/// -/// Serialised as `{"error": "..."}` with the appropriate HTTP status code. -#[derive(Debug, Serialize)] -struct ErrorBody { - error: String, -} - -/// Build a JSON error response with the given status code and message. -fn error_response(status: StatusCode, msg: impl Into) -> Response { - (status, Json(ErrorBody { error: msg.into() })).into_response() -} - -/// Map a [`crate::db::DbError`] to an appropriate HTTP error response. -fn db_error_response(err: crate::db::DbError) -> Response { - use crate::db::DbError; - match err { - DbError::NotFound => error_response(StatusCode::NOT_FOUND, "not found"), - DbError::Forbidden => error_response(StatusCode::FORBIDDEN, "forbidden"), - DbError::Internal(msg) => error_response(StatusCode::INTERNAL_SERVER_ERROR, msg), - } -} - -// ── Response types ──────────────────────────────────────────────────────────── - -/// Response body returned by the create (PUT) endpoint. -/// -/// Includes the full [`Quote`] plus the `auth_code` string (only time it is -/// sent to the client). -#[derive(Debug, Serialize)] -struct CreateResponse { - /// The created quote (without auth_code in the embedded struct). - quote: Quote, - /// The auth code for future update/delete operations. Store it. - auth_code: String, -} - -// ── Query parameter structs ──────────────────────────────────────────────────── - -/// Query parameters for `GET /api/quotes`. -#[derive(Debug, Deserialize)] -struct ListParams { - /// 1-based page number. Defaults to 1. - #[serde(default = "default_page")] - page: u32, - /// Filter by author name (case-insensitive). - author: Option, - /// Filter by tag. - tag: Option, - /// Only include quotes dated on or after this year. - date_after_year: Option, - /// Narrows after-bound to this month (1–12). Requires `date_after_year`. - date_after_month: Option, - /// Narrows after-bound to this day (1–31). Requires `date_after_year` and `date_after_month`. - date_after_day: Option, - /// Only include quotes dated on or before this year. - date_before_year: Option, - /// Narrows before-bound to this month (1–12). Requires `date_before_year`. - date_before_month: Option, - /// Narrows before-bound to this day (1–31). Requires `date_before_year` and `date_before_month`. - date_before_day: Option, -} - -fn default_page() -> u32 { - 1 -} - -/// Build an ISO date prefix string from optional year/month/day components. -/// -/// Returns `None` if no year is given. For before-bounds, missing month -/// defaults to `12` and missing day defaults to `31` so the bound is -/// inclusive of the entire specified year/month. -/// -/// # Examples -/// -/// ```ignore -/// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string())); -/// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string())); -/// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string())); -/// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string())); -/// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None); -/// ``` -fn build_date_bound( - year: Option, - month: Option, - day: Option, - is_before: bool, -) -> Option { - match (year, month, day) { - (None, _, _) => None, - (Some(y), None, _) => { - if is_before { - Some(format!("{y:04}-12-31")) - } else { - Some(format!("{y:04}")) - } - } - (Some(y), Some(m), None) => { - if is_before { - Some(format!("{y:04}-{m:02}-31")) - } else { - Some(format!("{y:04}-{m:02}")) - } - } - (Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")), - } -} - -// ── Handlers ────────────────────────────────────────────────────────────────── - -/// `GET /api/` — return the OpenAPI specification as JSON. -/// -/// The spec is embedded at compile time from `api/openapi.yaml` (converted to -/// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw -/// spec string. -#[cfg_attr(target_arch = "wasm32", worker::send)] -async fn openapi_handler() -> Response { - const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json")); - ( - StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, "application/json")], - OPENAPI_JSON, - ) - .into_response() -} - -/// `GET /api/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 quotesdb::{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(quotesdb::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(quotesdb::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(quotesdb::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(quotesdb::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/src/bin/api/main.rs b/quotesdb/src/bin/api/main.rs index 413064d..c7eaf1b 100644 --- a/quotesdb/src/bin/api/main.rs +++ b/quotesdb/src/bin/api/main.rs @@ -1,15 +1,17 @@ //! API server binary entrypoint. //! //! On native targets: starts a Tokio/Axum HTTP server on `0.0.0.0:3000`. -//! On wasm32 targets: exports a `fetch` event handler for Cloudflare Workers, -//! wiring Cloudflare D1 through [`db::d1::D1Repository`] into the Axum router. +//! On wasm32 targets: the Workers entry point is exported from the lib crate +//! (`src/lib.rs`) via `#[worker::event(fetch)]`. // On wasm32 there is no `main` entry point; the Workers runtime invokes the // exported `fetch` function produced by `#[worker::event(fetch)]` instead. #![cfg_attr(target_arch = "wasm32", no_main)] -mod db; -mod handlers; +#[cfg(not(target_arch = "wasm32"))] +use quotesdb::db; +#[cfg(not(target_arch = "wasm32"))] +use quotesdb::handlers; #[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; @@ -75,70 +77,5 @@ async fn main() { axum::serve(listener, app).await.expect("server error"); } -/// Cloudflare Workers `fetch` event handler. -/// -/// With the `http` feature enabled on `worker`, the macro converts the -/// incoming JS request into an `http::Request` before passing -/// it here. The Axum router is called directly and its response is returned -/// as `http::Response`, which the macro converts back to a -/// JS response before returning it to the runtime. -/// -/// D1 bindings are retrieved from the Worker environment and wrapped in an -/// `Arc` for sharing across Axum state. -#[cfg(target_arch = "wasm32")] -#[worker::event(fetch)] -pub async fn fetch( - req: worker::HttpRequest, - env: worker::Env, - _ctx: worker::Context, -) -> worker::Result> { - use std::sync::Arc; - use tower_service::Service; - - // Retrieve the D1 database binding named "DB" from the Worker environment. - let db = env.d1("DB")?; - - // Create the D1-backed repository and run schema migrations on startup. - let repo = db::d1::D1Repository::new(db); - // Bring QuoteRepository trait into scope for `run_migrations`. - use db::QuoteRepository as _; - repo.run_migrations() - .await - .map_err(|e| worker::Error::RustError(e.to_string()))?; - - // Seed admin auth code on first startup (no-op if already present). - if repo - .get_admin_auth_code() - .await - .map_err(|e| worker::Error::RustError(e.to_string()))? - .is_none() - { - let code = quotesdb::generate_auth_code(); - repo.seed_admin_auth_code(&code) - .await - .map_err(|e| worker::Error::RustError(e.to_string()))?; - } - - // Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op - // if the key already exists, so this never overwrites an active lock). - repo.seed_submissions_locked() - .await - .map_err(|e| worker::Error::RustError(e.to_string()))?; - - // Wrap in Arc so it can be shared across handlers via Axum state. - // D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32. - let repo: Arc = Arc::new(repo); - - // Build the Axum router with all routes registered. - let mut router = handlers::router(repo); - - // Call the Axum router directly; the http feature ensures request/response - // types are compatible with standard http crate types that Axum expects. - Ok(router - .call(req) - .await - .map_err(|e| worker::Error::RustError(e.to_string()))?) -} - #[cfg(test)] mod tests {} diff --git a/quotesdb/src/lib.rs b/quotesdb/src/lib.rs index f846d4f..7d7b2d6 100644 --- a/quotesdb/src/lib.rs +++ b/quotesdb/src/lib.rs @@ -1,4 +1,4 @@ -//! Shared types and utilities for the `quotesdb` crate. +//! Shared types, utilities, and API modules for the `quotesdb` crate. //! //! This module is used by both the `api` and `ui` binaries. //! Code placed here must compile for both the host target (api) @@ -7,6 +7,10 @@ //! Use `#[cfg(not(target_arch = "wasm32"))]` for host-only items //! and `#[cfg(target_arch = "wasm32")]` for wasm-only items. +pub mod db; +#[cfg(any(not(target_arch = "wasm32"), feature = "workers-api"))] +pub mod handlers; + use serde::{Deserialize, Serialize}; // ── EFF Short Word List 1 (1296 words) ─────────────────────────────────────── @@ -313,3 +317,70 @@ mod tests { } } } + +// ── Cloudflare Workers entry point ──────────────────────────────────────────── + +/// Cloudflare Workers `fetch` event handler. +/// +/// With the `http` feature enabled on `worker`, the macro converts the +/// incoming JS request into an `http::Request` before passing +/// it here. The Axum router is called directly and its response is returned +/// as `http::Response`, which the macro converts back to a +/// JS response before returning it to the runtime. +/// +/// D1 bindings are retrieved from the Worker environment and wrapped in an +/// `Arc` for sharing across Axum state. +#[cfg(all(target_arch = "wasm32", feature = "workers-api"))] +#[worker::event(fetch)] +pub async fn fetch( + req: worker::HttpRequest, + env: worker::Env, + _ctx: worker::Context, +) -> worker::Result> { + use std::sync::Arc; + use tower_service::Service; + + // Retrieve the D1 database binding named "DB" from the Worker environment. + let db = env.d1("DB")?; + + // Create the D1-backed repository and run schema migrations on startup. + let repo = crate::db::d1::D1Repository::new(db); + // Bring QuoteRepository trait into scope for `run_migrations`. + use crate::db::QuoteRepository as _; + repo.run_migrations() + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?; + + // Seed admin auth code on first startup (no-op if already present). + if repo + .get_admin_auth_code() + .await + .map_err(|e| worker::Error::RustError(e.to_string()))? + .is_none() + { + let code = crate::generate_auth_code(); + repo.seed_admin_auth_code(&code) + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?; + } + + // Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op + // if the key already exists, so this never overwrites an active lock). + repo.seed_submissions_locked() + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?; + + // Wrap in Arc so it can be shared across handlers via Axum state. + // D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32. + let repo: Arc = Arc::new(repo); + + // Build the Axum router with all routes registered. + let mut router = crate::handlers::router(repo); + + // Call the Axum router directly; the http feature ensures request/response + // types are compatible with standard http crate types that Axum expects. + Ok(router + .call(req) + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?) +}