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.
795 lines
29 KiB
Rust
795 lines
29 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, 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> {
|
|
Ok(Quote {
|
|
id: row.get(0)?,
|
|
text: row.get(1)?,
|
|
author: row.get(2)?,
|
|
source: row.get(3)?,
|
|
date: row.get(4)?,
|
|
created_at: row.get(5)?,
|
|
updated_at: row.get(6)?,
|
|
tags,
|
|
})
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl QuoteRepository for NativeRepository {
|
|
/// Run the five DDL migration statements from [`super::migrations`].
|
|
///
|
|
/// Covers `quotes`, `quote_tags`, tag index, author index, and
|
|
/// `admin_config`. Safe to call multiple times — all statements use
|
|
/// `IF NOT EXISTS`.
|
|
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};"
|
|
))?;
|
|
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 ────────────────────────────────────
|
|
let mut conditions: Vec<String> = Vec::new();
|
|
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 = if conditions.is_empty() {
|
|
String::new()
|
|
} else {
|
|
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.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| {
|
|
Ok(Quote {
|
|
id: row.get(0)?,
|
|
text: row.get(1)?,
|
|
author: row.get(2)?,
|
|
source: row.get(3)?,
|
|
date: row.get(4)?,
|
|
created_at: row.get(5)?,
|
|
updated_at: row.get(6)?,
|
|
tags: 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, 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, created_at, updated_at \
|
|
FROM quotes 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, 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());
|
|
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()));
|
|
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, 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()))
|
|
}
|
|
}
|
|
|
|
#[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()]),
|
|
},
|
|
&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);
|
|
}
|
|
}
|