You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1106 lines
40 KiB
Rust

//! 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<String>` of tag values, or an empty vec if none exist.
fn fetch_tags_for_quote(
conn: &rusqlite::Connection,
quote_id: &str,
) -> Result<Vec<String>, 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::<Result<Vec<_>, _>>()?;
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<String>) -> Result<Quote, rusqlite::Error> {
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<ListResult, DbError> {
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<String> = 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<Box<dyn rusqlite::types::ToSql>> = 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<Box<dyn rusqlite::types::ToSql>> = 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<Quote> = stmt
.query_map(
rusqlite::params_from_iter(param_refs2.iter().copied()),
|row| row_to_quote(row, vec![]),
)?
.collect::<Result<Vec<_>, _>>()?;
// 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::<Result<Vec<_>, 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<Option<Quote>, 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<Option<Quote>, 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<Quote, DbError> {
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<String> = self
.conn
.call({
let id = id.clone();
move |conn| {
let result: Option<String> = 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<String> = 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<Box<dyn rusqlite::types::ToSql>> = 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<DeleteResult, DbError> {
let id = id.to_owned();
let auth_code = auth_code.to_owned();
self.conn
.call(move |conn| {
let stored: Option<String> = 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<String> = 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<Option<String>, DbError> {
self.conn
.call(|conn| {
let result: Option<String> = 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<String, DbError> {
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<bool, DbError> {
self.conn
.call(|conn| {
let result: Option<String> = 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(&quote.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(
&quote.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(&quote.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(&quote.id, &auth).await.unwrap();
assert_eq!(result, DeleteResult::Deleted);
assert!(repo.get_quote(&quote.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(&quote.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(
&quote.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(
&quote.id,
UpdateQuoteInput {
hidden: Some(true),
..UpdateQuoteInput::default()
},
&auth,
)
.await
.unwrap();
let fetched = repo.get_quote(&quote.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");
}
}