//! 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};" ))?; // 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())) } } #[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" ); } /// `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"); } }