Files
sustenance/src/sqlite.rs
T
sbstp 689bd95ff0
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/fmt Pipeline failed
ci/woodpecker/push/test Pipeline was successful
list archive
2026-08-07 22:16:29 -04:00

2776 lines
84 KiB
Rust

use std::future::Future;
use std::pin::Pin;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions};
use crate::domain::{
Category, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory,
MealIngredient, Passkey, SessionUser, User,
};
use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository,
SessionRepository, UserRepository,
};
/// The embedded SQL migrations, applied automatically on startup.
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
#[derive(Clone)]
pub struct SqliteDatabase {
pool: SqlitePool,
}
impl SqliteDatabase {
pub async fn open(path: &str) -> DomainResult<Self> {
let options = SqliteConnectOptions::new()
.filename(path)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.foreign_keys(true)
.busy_timeout(std::time::Duration::from_secs(5))
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
seed_default_categories(&pool).await?;
seed_default_meal_categories(&pool).await?;
Ok(Self { pool })
}
#[cfg(test)]
pub async fn open_in_memory() -> DomainResult<Self> {
// A pool of `:memory:` connections would each get a separate database,
// so use a unique temporary file that shares the schema across the pool.
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"sustenance-test-{}-{}-{}.db",
std::process::id(),
now(),
unique
));
let path = path.to_str().unwrap();
let options = SqliteConnectOptions::new()
.filename(path)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.foreign_keys(true)
.busy_timeout(std::time::Duration::from_secs(5))
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
seed_default_categories(&pool).await?;
seed_default_meal_categories(&pool).await?;
Ok(Self { pool })
}
}
impl SqliteDatabase {
/// Runs `operation` inside a single transaction, committing on success and
/// rolling back on error. Multiple repositories can participate in the same
/// transaction so their writes commit together atomically.
pub async fn run<T, F>(&self, operation: F) -> DomainResult<T>
where
T: Send + 'static,
F: for<'a> FnOnce(
&'a mut SqliteConnection,
)
-> Pin<Box<dyn Future<Output = DomainResult<T>> + Send + 'a>>
+ Send
+ 'static,
{
let mut connection = self.pool.acquire().await.map_err(db_error)?;
let mut transaction = connection.begin().await.map_err(db_error)?;
let result = operation(&mut transaction).await;
match result {
Ok(value) => {
transaction.commit().await.map_err(db_error)?;
Ok(value)
}
Err(error) => {
transaction.rollback().await.map_err(db_error)?;
Err(error)
}
}
}
}
/// Inserts the default global categories once, if the categories table is empty.
async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> {
let count: i64 = sqlx::query("SELECT COUNT(*) FROM categories")
.fetch_one(pool)
.await
.map_err(db_error)?
.get(0);
if count > 0 {
return Ok(());
}
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
sqlx::query("INSERT INTO categories (name, position, created_at) VALUES (?1, ?2, ?3)")
.bind(category_name)
.bind(position as i64)
.bind(now())
.execute(pool)
.await
.map_err(db_error)?;
}
Ok(())
}
/// Inserts the default meal categories once, if the meal_categories table is empty.
async fn seed_default_meal_categories(pool: &SqlitePool) -> DomainResult<()> {
let count: i64 = sqlx::query("SELECT COUNT(*) FROM meal_categories")
.fetch_one(pool)
.await
.map_err(db_error)?
.get(0);
if count > 0 {
return Ok(());
}
for (position, category_name) in DEFAULT_MEAL_CATEGORIES.iter().enumerate() {
sqlx::query("INSERT INTO meal_categories (name, position, created_at) VALUES (?1, ?2, ?3)")
.bind(category_name)
.bind(position as i64)
.bind(now())
.execute(pool)
.await
.map_err(db_error)?;
}
Ok(())
}
#[derive(Clone, Copy)]
pub struct SqliteUserRepository;
#[async_trait]
impl UserRepository for SqliteUserRepository {
async fn create_user(
&self,
txn: &mut SqliteConnection,
email: String,
display_name: String,
password_hash: String,
) -> DomainResult<User> {
// Generate a random, high-entropy user handle per the WebAuthn spec so
// the value embedded in authenticators is opaque and unguessable.
let user_handle = new_user_handle();
let result = sqlx::query(
"INSERT INTO users (email, display_name, password_hash, user_handle, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(&email)
.bind(&display_name)
.bind(&password_hash)
.bind(&user_handle)
.bind(now())
.execute(&mut *txn)
.await;
match result {
Ok(_) => {
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(User {
id,
email,
display_name,
user_handle,
})
}
Err(error) if is_unique_violation(&error) => Err(DomainError::Conflict),
Err(error) => Err(db_error(error)),
}
}
async fn find_user_by_email(
&self,
txn: &mut SqliteConnection,
email: String,
) -> DomainResult<Option<(User, String)>> {
let row = sqlx::query(
"SELECT id, email, display_name, user_handle, password_hash
FROM users WHERE email = ?1 COLLATE NOCASE",
)
.bind(&email)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| {
(
User {
id: row.get(0),
email: row.get(1),
display_name: row.get(2),
user_handle: row.get(3),
},
row.get(4),
)
}))
}
async fn find_user_by_handle(
&self,
txn: &mut SqliteConnection,
user_handle: Vec<u8>,
) -> DomainResult<Option<User>> {
let row = sqlx::query(
"SELECT id, email, display_name, user_handle
FROM users WHERE user_handle = ?1",
)
.bind(&user_handle)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| User {
id: row.get(0),
email: row.get(1),
display_name: row.get(2),
user_handle: row.get(3),
}))
}
async fn update_password_hash(
&self,
txn: &mut SqliteConnection,
user_id: i64,
password_hash: String,
) -> DomainResult<()> {
sqlx::query("UPDATE users SET password_hash = ?1 WHERE id = ?2")
.bind(&password_hash)
.bind(user_id)
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok(())
}
async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult<bool> {
let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users)")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.get::<i64, _>(0) != 0)
}
}
#[derive(Clone, Copy)]
pub struct SqlitePasskeyRepository;
#[async_trait]
impl PasskeyRepository for SqlitePasskeyRepository {
async fn create_passkey(
&self,
txn: &mut SqliteConnection,
user_id: i64,
credential_id: String,
credential: String,
counter: i64,
) -> DomainResult<Passkey> {
let result = sqlx::query(
"INSERT INTO passkeys (user_id, credential_id, credential, counter, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(user_id)
.bind(&credential_id)
.bind(&credential)
.bind(counter)
.bind(now())
.execute(&mut *txn)
.await;
match result {
Ok(_) => {
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(Passkey {
id,
user_id,
credential_id,
credential,
counter,
})
}
Err(error) if is_unique_violation(&error) => Err(DomainError::Conflict),
Err(error) => Err(db_error(error)),
}
}
async fn find_by_credential_id(
&self,
txn: &mut SqliteConnection,
credential_id: String,
) -> DomainResult<Option<Passkey>> {
let row = sqlx::query(
"SELECT id, user_id, credential_id, credential, counter
FROM passkeys WHERE credential_id = ?1",
)
.bind(&credential_id)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| Passkey {
id: row.get(0),
user_id: row.get(1),
credential_id: row.get(2),
credential: row.get(3),
counter: row.get(4),
}))
}
async fn list_for_user(
&self,
txn: &mut SqliteConnection,
user_id: i64,
) -> DomainResult<Vec<Passkey>> {
let rows = sqlx::query(
"SELECT id, user_id, credential_id, credential, counter
FROM passkeys WHERE user_id = ?1 ORDER BY id ASC",
)
.bind(user_id)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| Passkey {
id: row.get(0),
user_id: row.get(1),
credential_id: row.get(2),
credential: row.get(3),
counter: row.get(4),
})
.collect())
}
async fn delete_passkey(
&self,
txn: &mut SqliteConnection,
user_id: i64,
passkey_id: i64,
) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM passkeys WHERE id = ?1 AND user_id = ?2")
.bind(passkey_id)
.bind(user_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct SqliteSessionRepository;
#[async_trait]
impl SessionRepository for SqliteSessionRepository {
async fn create_session(
&self,
txn: &mut SqliteConnection,
user_id: i64,
) -> DomainResult<(String, String)> {
let session_token = hex::encode(crate::security::new_secret());
let csrf_token = hex::encode(crate::security::new_secret());
sqlx::query(
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(hash_secret(&session_token))
.bind(user_id)
.bind(hex::decode(&csrf_token).expect("csrf_token is valid hex"))
.bind(now() + 60 * 60 * 24 * 30)
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok((session_token, csrf_token))
}
async fn session_user(
&self,
txn: &mut SqliteConnection,
session_token: String,
) -> DomainResult<Option<SessionUser>> {
let row = sqlx::query(
"SELECT u.id, u.email, u.display_name, u.user_handle, s.csrf_token
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?1 AND s.expires_at > ?2",
)
.bind(hash_secret(&session_token))
.bind(now())
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| SessionUser {
user: User {
id: row.get(0),
email: row.get(1),
display_name: row.get(2),
user_handle: row.get(3),
},
csrf_token: hex::encode(row.get::<Vec<u8>, _>(4)),
}))
}
async fn delete_session(
&self,
txn: &mut SqliteConnection,
session_token: String,
) -> DomainResult<()> {
sqlx::query("DELETE FROM sessions WHERE token_hash = ?1")
.bind(hash_secret(&session_token))
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct SqliteListRepository;
#[async_trait]
impl ListRepository for SqliteListRepository {
async fn list_summaries(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<GroceryList>> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.revision, l.created_at, l.archived_at
FROM lists l
WHERE l.archived_at IS NULL
ORDER BY l.created_at DESC",
)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| GroceryList {
id: row.get(0),
name: row.get(1),
revision: row.get(2),
created_at: row.get(3),
archived_at: row.get(4),
})
.collect())
}
async fn list_archived_summaries(
&self,
txn: &mut SqliteConnection,
) -> DomainResult<Vec<GroceryList>> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.revision, l.created_at, l.archived_at
FROM lists l
WHERE l.archived_at IS NOT NULL
ORDER BY l.archived_at DESC, l.created_at DESC",
)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| GroceryList {
id: row.get(0),
name: row.get(1),
revision: row.get(2),
created_at: row.get(3),
archived_at: row.get(4),
})
.collect())
}
async fn create_list(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<GroceryList> {
sqlx::query("INSERT INTO lists (name, revision, created_at) VALUES (?1, 0, ?2)")
.bind(&name)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
let list_id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(GroceryList {
id: list_id,
name,
revision: 0,
created_at: now(),
archived_at: None,
})
}
async fn get_list(
&self,
txn: &mut SqliteConnection,
list_id: i64,
) -> DomainResult<Option<GroceryList>> {
let row = sqlx::query(
"SELECT l.id, l.name, l.revision, l.created_at, l.archived_at
FROM lists l
WHERE l.id = ?1",
)
.bind(list_id)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| GroceryList {
id: row.get(0),
name: row.get(1),
revision: row.get(2),
created_at: row.get(3),
archived_at: row.get(4),
}))
}
async fn set_archived(
&self,
txn: &mut SqliteConnection,
list_id: i64,
archived: bool,
) -> DomainResult<()> {
let result = if archived {
sqlx::query("UPDATE lists SET archived_at = ?1 WHERE id = ?2")
.bind(now())
.bind(list_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
} else {
sqlx::query("UPDATE lists SET archived_at = NULL WHERE id = ?1")
.bind(list_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
};
if result.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct SqliteCategoryRepository;
#[async_trait]
impl CategoryRepository for SqliteCategoryRepository {
async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>> {
let rows = sqlx::query(
"SELECT id, name
FROM categories
ORDER BY position ASC, name COLLATE NOCASE ASC",
)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| Category {
id: row.get(0),
name: row.get(1),
})
.collect())
}
async fn create_category(&self, txn: &mut SqliteConnection, name: String) -> DomainResult<i64> {
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM categories")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
let result = sqlx::query(
"INSERT INTO categories (name, position, created_at)
VALUES (?1, ?2, ?3)",
)
.bind(&name)
.bind(position)
.bind(now())
.execute(&mut *txn)
.await;
match result {
Ok(_) => {}
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
Err(error) => return Err(db_error(error)),
}
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(id)
}
}
#[derive(Clone, Copy)]
pub struct SqliteItemRepository;
#[async_trait]
impl ItemRepository for SqliteItemRepository {
async fn items(&self, txn: &mut SqliteConnection, list_id: i64) -> DomainResult<Vec<Item>> {
let rows = sqlx::query(
"SELECT id, list_id, name, quantity, note, category_id, checked, version
FROM items
WHERE list_id = ?1
ORDER BY position ASC, created_at ASC",
)
.bind(list_id)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| Item {
id: row.get(0),
list_id: row.get(1),
name: row.get(2),
quantity: row.get(3),
note: row.get(4),
category_id: row.get(5),
checked: row.get::<i64, _>(6) != 0,
version: row.get(7),
})
.collect())
}
async fn add_item(
&self,
txn: &mut SqliteConnection,
list_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<i64> {
ensure_category(txn, category_id).await?;
let position: i64 =
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1")
.bind(list_id)
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
sqlx::query(
"INSERT INTO items
(list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?7)",
)
.bind(list_id)
.bind(&name)
.bind(&quantity)
.bind(&note)
.bind(category_id)
.bind(position)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
bump_revision(txn, list_id).await
}
async fn add_items_bulk(
&self,
txn: &mut SqliteConnection,
list_id: i64,
items: Vec<NewItem>,
) -> DomainResult<i64> {
let mut position: i64 =
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1")
.bind(list_id)
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
let now = now();
for item in items {
ensure_category(txn, item.category_id).await?;
sqlx::query(
"INSERT INTO items
(list_id, name, quantity, note, category_id, checked, version, position, list_meal_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?8, ?8)",
)
.bind(list_id)
.bind(&item.name)
.bind(&item.quantity)
.bind(&item.note)
.bind(item.category_id)
.bind(position)
.bind(item.list_meal_id)
.bind(now)
.execute(&mut *txn)
.await
.map_err(db_error)?;
position += 1;
}
bump_revision(txn, list_id).await
}
async fn set_item_checked(
&self,
txn: &mut SqliteConnection,
list_id: i64,
item_id: i64,
checked: bool,
) -> DomainResult<i64> {
let changed = sqlx::query(
"UPDATE items
SET checked = ?1, version = version + 1, updated_at = ?2
WHERE id = ?3 AND list_id = ?4",
)
.bind(checked as i64)
.bind(now())
.bind(item_id)
.bind(list_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
bump_revision(txn, list_id).await
}
async fn update_item(
&self,
txn: &mut SqliteConnection,
list_id: i64,
item_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<i64> {
ensure_category(txn, category_id).await?;
let changed = sqlx::query(
"UPDATE items
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4,
version = version + 1, updated_at = ?5
WHERE id = ?6 AND list_id = ?7",
)
.bind(&name)
.bind(&quantity)
.bind(&note)
.bind(category_id)
.bind(now())
.bind(item_id)
.bind(list_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
bump_revision(txn, list_id).await
}
async fn delete_item(
&self,
txn: &mut SqliteConnection,
list_id: i64,
item_id: i64,
) -> DomainResult<i64> {
let changed = sqlx::query("DELETE FROM items WHERE id = ?1 AND list_id = ?2")
.bind(item_id)
.bind(list_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
bump_revision(txn, list_id).await
}
}
#[derive(Clone, Copy)]
pub struct SqliteListMealRepository;
#[async_trait]
impl ListMealRepository for SqliteListMealRepository {
async fn list_meals(
&self,
txn: &mut SqliteConnection,
list_id: i64,
) -> DomainResult<Vec<ListMeal>> {
let rows = sqlx::query(
"SELECT id, meal_id, name, created_at
FROM list_meals
WHERE list_id = ?1
ORDER BY created_at ASC, id ASC",
)
.bind(list_id)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| ListMeal {
id: row.get(0),
meal_id: row.get(1),
name: row.get(2),
created_at: row.get(3),
})
.collect())
}
async fn add_meal(
&self,
txn: &mut SqliteConnection,
list_id: i64,
meal_id: i64,
name: String,
) -> DomainResult<i64> {
sqlx::query(
"INSERT INTO list_meals (list_id, meal_id, name, created_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(list_id)
.bind(meal_id)
.bind(&name)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(id)
}
async fn remove_meal(
&self,
txn: &mut SqliteConnection,
list_id: i64,
list_meal_id: i64,
) -> DomainResult<i64> {
let changed = sqlx::query("DELETE FROM list_meals WHERE id = ?1 AND list_id = ?2")
.bind(list_meal_id)
.bind(list_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
bump_revision(txn, list_id).await
}
}
#[derive(Clone, Copy)]
pub struct SqliteInvitationRepository;
#[async_trait]
impl InvitationRepository for SqliteInvitationRepository {
async fn create_invitation(
&self,
txn: &mut SqliteConnection,
created_by: i64,
token: String,
) -> DomainResult<i64> {
let expires_at = now() + 60 * 60 * 24 * 7;
sqlx::query(
"INSERT INTO invitations (token_hash, created_by, expires_at)
VALUES (?1, ?2, ?3)",
)
.bind(hash_secret(&token))
.bind(created_by)
.bind(expires_at)
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok(expires_at)
}
async fn invitation(&self, txn: &mut SqliteConnection, token: String) -> DomainResult<bool> {
let row = sqlx::query(
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
)
.bind(hash_secret(&token))
.bind(now())
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.is_some())
}
async fn accept_invitation(
&self,
txn: &mut SqliteConnection,
token: String,
) -> DomainResult<()> {
let valid = sqlx::query(
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
)
.bind(hash_secret(&token))
.bind(now())
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?
.is_some();
if !valid {
return Err(DomainError::NotFound);
}
sqlx::query("DELETE FROM invitations WHERE token_hash = ?1")
.bind(hash_secret(&token))
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct SqliteMealCategoryRepository;
#[async_trait]
impl MealCategoryRepository for SqliteMealCategoryRepository {
async fn meal_categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<MealCategory>> {
let rows = sqlx::query(
"SELECT id, name
FROM meal_categories
ORDER BY position ASC, name COLLATE NOCASE ASC",
)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| MealCategory {
id: row.get(0),
name: row.get(1),
})
.collect())
}
async fn create_meal_category(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<i64> {
let position: i64 =
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM meal_categories")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
let result = sqlx::query(
"INSERT INTO meal_categories (name, position, created_at)
VALUES (?1, ?2, ?3)",
)
.bind(&name)
.bind(position)
.bind(now())
.execute(&mut *txn)
.await;
match result {
Ok(_) => {}
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
Err(error) => return Err(db_error(error)),
}
Ok(sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0))
}
async fn delete_meal_category(
&self,
txn: &mut SqliteConnection,
category_id: i64,
) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM meal_categories WHERE id = ?1")
.bind(category_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct SqliteMealRepository;
#[async_trait]
impl MealRepository for SqliteMealRepository {
async fn create_meal(
&self,
txn: &mut SqliteConnection,
name: String,
description: String,
category_id: Option<i64>,
) -> DomainResult<Meal> {
let now = now();
sqlx::query(
"INSERT INTO meals (name, description, category_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?4)",
)
.bind(&name)
.bind(&description)
.bind(category_id)
.bind(now)
.execute(&mut *txn)
.await
.map_err(db_error)?;
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(Meal {
id,
name,
description,
category_id,
ingredients: Vec::new(),
})
}
async fn get_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
) -> DomainResult<Option<Meal>> {
let row = sqlx::query(
"SELECT id, name, description, category_id
FROM meals
WHERE id = ?1",
)
.bind(meal_id)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
let Some(row) = row else {
return Ok(None);
};
let meal = Meal {
id: row.get(0),
name: row.get(1),
description: row.get(2),
category_id: row.get(3),
ingredients: Vec::new(),
};
let ingredients = SqliteMealIngredientRepository
.ingredients_for_meal(txn, meal.id)
.await?;
Ok(Some(Meal {
ingredients,
..meal
}))
}
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>> {
let rows = sqlx::query(
"SELECT id, name, description, category_id
FROM meals
ORDER BY name COLLATE NOCASE ASC",
)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
let mut meals = Vec::new();
for row in rows {
let meal = Meal {
id: row.get(0),
name: row.get(1),
description: row.get(2),
category_id: row.get(3),
ingredients: Vec::new(),
};
let ingredients = SqliteMealIngredientRepository
.ingredients_for_meal(txn, meal.id)
.await?;
meals.push(Meal {
ingredients,
..meal
});
}
Ok(meals)
}
async fn update_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
name: String,
description: String,
category_id: Option<i64>,
) -> DomainResult<()> {
let changed = sqlx::query(
"UPDATE meals
SET name = ?1, description = ?2, category_id = ?3, updated_at = ?4
WHERE id = ?5",
)
.bind(&name)
.bind(&description)
.bind(category_id)
.bind(now())
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn delete_meal(&self, txn: &mut SqliteConnection, meal_id: i64) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM meals WHERE id = ?1")
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct SqliteMealIngredientRepository;
#[async_trait]
impl MealIngredientRepository for SqliteMealIngredientRepository {
async fn ingredients_for_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
) -> DomainResult<Vec<MealIngredient>> {
let rows = sqlx::query(
"SELECT id, name, quantity, note, category_id
FROM meal_ingredients
WHERE meal_id = ?1
ORDER BY position ASC, id ASC",
)
.bind(meal_id)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| MealIngredient {
id: row.get(0),
name: row.get(1),
quantity: row.get(2),
note: row.get(3),
category_id: row.get(4),
})
.collect())
}
async fn add_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<i64> {
ensure_category(txn, category_id).await?;
let position: i64 = sqlx::query(
"SELECT COALESCE(MAX(position), -1) + 1
FROM meal_ingredients WHERE meal_id = ?1",
)
.bind(meal_id)
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
sqlx::query(
"INSERT INTO meal_ingredients (meal_id, name, quantity, note, category_id, position)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(meal_id)
.bind(&name)
.bind(&quantity)
.bind(&note)
.bind(category_id)
.bind(position)
.execute(&mut *txn)
.await
.map_err(db_error)?;
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(id)
}
async fn update_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
ingredient_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<()> {
ensure_category(txn, category_id).await?;
let changed = sqlx::query(
"UPDATE meal_ingredients
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4
WHERE id = ?5 AND meal_id = ?6",
)
.bind(&name)
.bind(&quantity)
.bind(&note)
.bind(category_id)
.bind(ingredient_id)
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn delete_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
ingredient_id: i64,
) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM meal_ingredients WHERE id = ?1 AND meal_id = ?2")
.bind(ingredient_id)
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
async fn ensure_category(txn: &mut SqliteConnection, category_id: Option<i64>) -> DomainResult<()> {
let Some(category_id) = category_id else {
return Ok(());
};
let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1")
.bind(category_id)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
if row.is_none() {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn bump_revision(txn: &mut SqliteConnection, list_id: i64) -> DomainResult<i64> {
sqlx::query("UPDATE lists SET revision = revision + 1 WHERE id = ?1")
.bind(list_id)
.execute(&mut *txn)
.await
.map_err(db_error)?;
let row = sqlx::query("SELECT revision FROM lists WHERE id = ?1")
.bind(list_id)
.fetch_one(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.get(0))
}
const DEFAULT_CATEGORIES: &[&str] = &[
"Produce",
"Meat & seafood",
"Dairy & eggs",
"Pantry",
"Frozen",
"Household",
];
const DEFAULT_MEAL_CATEGORIES: &[&str] =
&["Beef", "Chicken", "Pasta", "Sandwiches", "Salads", "Soups"];
fn hash_secret(secret: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
hasher.finalize().to_vec()
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
/// A random, high-entropy user handle used as the WebAuthn userHandle.
/// 32 random bytes, which is exactly the 64-byte maximum the WebAuthn spec
/// allows for a userHandle while still providing 256 bits of entropy. Opaque
/// and unguessable per the spec. Stored as raw bytes.
fn new_user_handle() -> Vec<u8> {
use rand::RngCore;
let mut bytes = [0_u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
bytes.to_vec()
}
fn is_unique_violation(error: &sqlx::Error) -> bool {
error
.as_database_error()
.map(|database_error| database_error.message().to_uppercase().contains("UNIQUE"))
.unwrap_or(false)
}
fn db_error(error: sqlx::Error) -> DomainError {
DomainError::Database(error.to_string())
}
fn migrate_error(error: sqlx::migrate::MigrateError) -> DomainError {
DomainError::Database(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
async fn setup() -> SqliteDatabase {
SqliteDatabase::open_in_memory().await.unwrap()
}
async fn create_user(db: &SqliteDatabase, email: &str) -> User {
let users = SqliteUserRepository;
let email = email.to_owned();
db.run(move |txn| {
let users = users.clone();
Box::pin(async move {
users
.create_user(txn, email, "Test User".into(), "hash".into())
.await
})
})
.await
.unwrap()
}
async fn create_list(db: &SqliteDatabase, name: &str) -> GroceryList {
let lists = SqliteListRepository;
let name = name.to_owned();
db.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.create_list(txn, name).await })
})
.await
.unwrap()
}
async fn add_item(db: &SqliteDatabase, list_id: i64, name: &str) -> Item {
let items = SqliteItemRepository;
let name_for_insert = name.to_owned();
db.run(move |txn| {
let items = items.clone();
Box::pin(async move {
items
.add_item(
txn,
list_id,
name_for_insert,
String::new(),
String::new(),
None,
)
.await
})
})
.await
.unwrap();
let items = SqliteItemRepository;
db.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.items(txn, list_id).await })
})
.await
.unwrap()
.into_iter()
.find(|item| item.name == name)
.unwrap()
}
async fn get_items(db: &SqliteDatabase, list_id: i64) -> Vec<Item> {
let items = SqliteItemRepository;
db.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.items(txn, list_id).await })
})
.await
.unwrap()
}
async fn get_categories(db: &SqliteDatabase) -> Vec<Category> {
let categories = SqliteCategoryRepository;
db.run(move |txn| {
let categories = categories.clone();
Box::pin(async move { categories.categories(txn).await })
})
.await
.unwrap()
}
// ---- UserRepository ----
#[tokio::test]
async fn create_user_returns_user_with_id() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
assert!(user.id > 0);
assert_eq!(user.email, "alice@example.com");
assert_eq!(user.display_name, "Test User");
}
#[tokio::test]
async fn create_user_with_duplicate_email_conflicts() {
let db = setup().await;
create_user(&db, "alice@example.com").await;
let users = SqliteUserRepository;
let result = db
.run(move |txn| {
let users = users.clone();
Box::pin(async move {
users
.create_user(
txn,
"alice@example.com".into(),
"Other".into(),
"hash".into(),
)
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::Conflict)));
}
#[tokio::test]
async fn find_user_by_email_returns_user_and_hash() {
let db = setup().await;
create_user(&db, "alice@example.com").await;
let users = SqliteUserRepository;
let found = db
.run(move |txn| {
let users = users.clone();
Box::pin(async move {
users
.find_user_by_email(txn, "alice@example.com".into())
.await
})
})
.await
.unwrap();
let (user, hash) = found.unwrap();
assert_eq!(user.email, "alice@example.com");
assert_eq!(hash, "hash");
}
#[tokio::test]
async fn find_user_by_email_is_case_insensitive() {
let db = setup().await;
create_user(&db, "alice@example.com").await;
let users = SqliteUserRepository;
let found = db
.run(move |txn| {
let users = users.clone();
Box::pin(async move {
users
.find_user_by_email(txn, "ALICE@EXAMPLE.COM".into())
.await
})
})
.await
.unwrap();
assert!(found.is_some());
}
#[tokio::test]
async fn find_user_by_email_returns_none_for_unknown() {
let db = setup().await;
let users = SqliteUserRepository;
let found = db
.run(move |txn| {
let users = users.clone();
Box::pin(async move {
users
.find_user_by_email(txn, "nobody@example.com".into())
.await
})
})
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn has_users_reflects_user_count() {
let db = setup().await;
let users = SqliteUserRepository;
let empty = db
.run(move |txn| {
let users = users.clone();
Box::pin(async move { users.has_users(txn).await })
})
.await
.unwrap();
assert!(!empty);
create_user(&db, "alice@example.com").await;
let has = db
.run(move |txn| {
let users = users.clone();
Box::pin(async move { users.has_users(txn).await })
})
.await
.unwrap();
assert!(has);
}
// ---- SessionRepository ----
#[tokio::test]
async fn create_and_lookup_session() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let sessions = SqliteSessionRepository;
let (token, csrf) = db
.run(move |txn| {
let sessions = sessions.clone();
Box::pin(async move { sessions.create_session(txn, user.id).await })
})
.await
.unwrap();
assert!(!token.is_empty());
assert!(!csrf.is_empty());
let session = db
.run(move |txn| {
let sessions = sessions.clone();
Box::pin(async move { sessions.session_user(txn, token.clone()).await })
})
.await
.unwrap()
.unwrap();
assert_eq!(session.user.id, user.id);
assert_eq!(session.csrf_token, csrf);
}
#[tokio::test]
async fn session_user_returns_none_for_unknown_token() {
let db = setup().await;
let sessions = SqliteSessionRepository;
let session = db
.run(move |txn| {
let sessions = sessions.clone();
Box::pin(async move { sessions.session_user(txn, "bogus".into()).await })
})
.await
.unwrap();
assert!(session.is_none());
}
#[tokio::test]
async fn delete_session_removes_it() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let sessions = SqliteSessionRepository;
let (token, _) = db
.run(move |txn| {
let sessions = sessions.clone();
Box::pin(async move { sessions.create_session(txn, user.id).await })
})
.await
.unwrap();
let token_for_delete = token.clone();
db.run(move |txn| {
let sessions = sessions.clone();
Box::pin(async move { sessions.delete_session(txn, token_for_delete).await })
})
.await
.unwrap();
let session = db
.run(move |txn| {
let sessions = sessions.clone();
Box::pin(async move { sessions.session_user(txn, token).await })
})
.await
.unwrap();
assert!(session.is_none());
}
// ---- ListRepository ----
#[tokio::test]
async fn create_list_does_not_seed_categories() {
let db = setup().await;
// Default categories are seeded globally at startup.
let before = get_categories(&db).await.len();
let list = create_list(&db, "Weekly shop").await;
assert!(list.id > 0);
assert_eq!(list.revision, 0);
// Categories are global now; creating a list must not add any.
assert_eq!(get_categories(&db).await.len(), before);
}
#[tokio::test]
async fn list_summaries_returns_all_lists() {
let db = setup().await;
let first = create_list(&db, "First").await;
let second = create_list(&db, "Second").await;
let lists = SqliteListRepository;
let summaries = db
.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.list_summaries(txn).await })
})
.await
.unwrap();
let mut ids = summaries.iter().map(|l| l.id).collect::<Vec<_>>();
ids.sort_unstable();
assert_eq!(ids, vec![first.id, second.id]);
}
#[tokio::test]
async fn archiving_a_list_hides_it_from_summaries() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let lists = SqliteListRepository;
let list_id = list.id;
db.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.set_archived(txn, list_id, true).await })
})
.await
.unwrap();
let summaries = db
.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.list_summaries(txn).await })
})
.await
.unwrap();
assert!(summaries.is_empty());
let archived = db
.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.list_archived_summaries(txn).await })
})
.await
.unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0].id, list.id);
assert!(archived[0].archived_at.is_some());
}
#[tokio::test]
async fn unarchiving_a_list_restores_it_to_summaries() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let lists = SqliteListRepository;
let list_id = list.id;
db.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.set_archived(txn, list_id, true).await })
})
.await
.unwrap();
db.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.set_archived(txn, list_id, false).await })
})
.await
.unwrap();
let summaries = db
.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.list_summaries(txn).await })
})
.await
.unwrap();
assert_eq!(summaries.len(), 1);
assert!(summaries[0].archived_at.is_none());
}
#[tokio::test]
async fn archiving_a_missing_list_fails() {
let db = setup().await;
let lists = SqliteListRepository;
let result = db
.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.set_archived(txn, 9999, true).await })
})
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_list_returns_list_or_none() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let lists = SqliteListRepository;
let found = db
.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.get_list(txn, list.id).await })
})
.await
.unwrap();
assert_eq!(found.unwrap().name, "Weekly shop");
let missing = db
.run(move |txn| {
let lists = lists.clone();
Box::pin(async move { lists.get_list(txn, 9999).await })
})
.await
.unwrap();
assert!(missing.is_none());
}
// ---- CategoryRepository ----
#[tokio::test]
async fn create_category_is_global_and_returns_id() {
let db = setup().await;
let categories = SqliteCategoryRepository;
let id = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move { categories.create_category(txn, "Bakery".into()).await })
})
.await
.unwrap();
assert!(id > 0);
let cats = get_categories(&db).await;
assert!(cats.iter().any(|c| c.name == "Bakery"));
}
#[tokio::test]
async fn create_duplicate_category_conflicts() {
let db = setup().await;
let categories = SqliteCategoryRepository;
let result = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move { categories.create_category(txn, "Produce".into()).await })
})
.await;
assert!(matches!(result, Err(DomainError::Conflict)));
}
// ---- MealCategoryRepository ----
async fn get_meal_categories(db: &SqliteDatabase) -> Vec<MealCategory> {
let categories = SqliteMealCategoryRepository;
db.run(move |txn| {
let categories = categories.clone();
Box::pin(async move { categories.meal_categories(txn).await })
})
.await
.unwrap()
}
#[tokio::test]
async fn meal_categories_are_seeded_with_defaults() {
let db = setup().await;
let categories = get_meal_categories(&db).await;
let names = categories
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"Beef"));
assert!(names.contains(&"Chicken"));
assert!(names.contains(&"Pasta"));
assert!(names.contains(&"Sandwiches"));
assert!(names.contains(&"Salads"));
assert!(names.contains(&"Soups"));
}
#[tokio::test]
async fn create_meal_category_returns_id_and_lists() {
let db = setup().await;
let categories = SqliteMealCategoryRepository;
let id = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.create_meal_category(txn, "Breakfast".into())
.await
})
})
.await
.unwrap();
assert!(id > 0);
let cats = get_meal_categories(&db).await;
assert!(cats.iter().any(|c| c.id == id && c.name == "Breakfast"));
}
#[tokio::test]
async fn create_duplicate_meal_category_conflicts() {
let db = setup().await;
let categories = SqliteMealCategoryRepository;
let result = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move { categories.create_meal_category(txn, "Beef".into()).await })
})
.await;
assert!(matches!(result, Err(DomainError::Conflict)));
}
#[tokio::test]
async fn delete_meal_category_cascades_to_null_on_meals() {
let db = setup().await;
let categories = SqliteMealCategoryRepository;
let category_id = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.create_meal_category(txn, "Breakfast".into())
.await
})
})
.await
.unwrap();
let meal = create_meal(&db, "Pancakes").await;
let meals = SqliteMealRepository;
db.run(move |txn| {
let meals = meals.clone();
Box::pin(async move {
meals
.update_meal(
txn,
meal.id,
"Pancakes".into(),
String::new(),
Some(category_id),
)
.await
})
})
.await
.unwrap();
db.run(move |txn| {
let categories = categories.clone();
Box::pin(async move { categories.delete_meal_category(txn, category_id).await })
})
.await
.unwrap();
let fetched = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, meal.id).await })
})
.await
.unwrap()
.unwrap();
assert_eq!(fetched.category_id, None);
}
// ---- ItemRepository ----
#[tokio::test]
async fn add_item_returns_revision_and_is_listed() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let items = SqliteItemRepository;
let revision = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move {
items
.add_item(
txn,
list.id,
"Milk".into(),
"2 litres".into(),
"note".into(),
None,
)
.await
})
})
.await
.unwrap();
assert_eq!(revision, 1);
let items = get_items(&db, list.id).await;
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "Milk");
assert_eq!(items[0].quantity, "2 litres");
assert_eq!(items[0].note, "note");
assert!(!items[0].checked);
assert_eq!(items[0].version, 1);
}
#[tokio::test]
async fn add_item_with_unknown_category_fails() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let items = SqliteItemRepository;
let result = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move {
items
.add_item(
txn,
list.id,
"Milk".into(),
String::new(),
String::new(),
Some(9999),
)
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn add_items_bulk_inserts_all_and_bumps_revision_once() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let items = SqliteItemRepository;
let new_items = vec![
NewItem {
name: "Penne".into(),
quantity: "500g".into(),
note: String::new(),
category_id: None,
list_meal_id: None,
},
NewItem {
name: "Tomato".into(),
quantity: "2".into(),
note: String::new(),
category_id: None,
list_meal_id: None,
},
];
let revision = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await })
})
.await
.unwrap();
assert_eq!(revision, 1);
let listed = get_items(&db, list.id).await;
let mut names = listed.iter().map(|i| i.name.clone()).collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["Penne".to_owned(), "Tomato".to_owned()]);
}
#[tokio::test]
async fn update_item_changes_fields_and_bumps_version() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let item = add_item(&db, list.id, "Milk").await;
let items = SqliteItemRepository;
let revision = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move {
items
.update_item(
txn,
list.id,
item.id,
"Oat milk".into(),
"1 litre".into(),
"chilled".into(),
None,
)
.await
})
})
.await
.unwrap();
assert_eq!(revision, 2);
let updated = get_items(&db, list.id).await.remove(0);
assert_eq!(updated.name, "Oat milk");
assert_eq!(updated.quantity, "1 litre");
assert_eq!(updated.note, "chilled");
assert_eq!(updated.version, 2);
}
#[tokio::test]
async fn update_missing_item_fails() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let items = SqliteItemRepository;
let result = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move {
items
.update_item(
txn,
list.id,
9999,
"X".into(),
String::new(),
String::new(),
None,
)
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn delete_item_removes_it_and_bumps_revision() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let item = add_item(&db, list.id, "Milk").await;
let items = SqliteItemRepository;
let revision = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.delete_item(txn, list.id, item.id).await })
})
.await
.unwrap();
assert_eq!(revision, 2);
assert!(get_items(&db, list.id).await.is_empty());
}
#[tokio::test]
async fn delete_missing_item_fails() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let items = SqliteItemRepository;
let result = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.delete_item(txn, list.id, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn set_item_checked_on_missing_item_fails() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let items = SqliteItemRepository;
let result = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.set_item_checked(txn, list.id, 9999, true).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
// ---- InvitationRepository ----
#[tokio::test]
async fn create_invitation_is_valid() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let invitations = SqliteInvitationRepository;
let expires = db
.run(move |txn| {
let invitations = invitations.clone();
Box::pin(async move {
invitations
.create_invitation(txn, user.id, "token-1".into())
.await
})
})
.await
.unwrap();
assert!(expires > now());
let valid = db
.run(move |txn| {
let invitations = invitations.clone();
Box::pin(async move { invitations.invitation(txn, "token-1".into()).await })
})
.await
.unwrap();
assert!(valid);
}
#[tokio::test]
async fn invitation_is_false_for_unknown_token() {
let db = setup().await;
let invitations = SqliteInvitationRepository;
let valid = db
.run(move |txn| {
let invitations = invitations.clone();
Box::pin(async move { invitations.invitation(txn, "bogus".into()).await })
})
.await
.unwrap();
assert!(!valid);
}
#[tokio::test]
async fn accept_invitation_consumes_it() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let invitations = SqliteInvitationRepository;
db.run(move |txn| {
let invitations = invitations.clone();
Box::pin(async move {
invitations
.create_invitation(txn, user.id, "token-1".into())
.await
})
})
.await
.unwrap();
db.run(move |txn| {
let invitations = invitations.clone();
Box::pin(async move { invitations.accept_invitation(txn, "token-1".into()).await })
})
.await
.unwrap();
let valid = db
.run(move |txn| {
let invitations = invitations.clone();
Box::pin(async move { invitations.invitation(txn, "token-1".into()).await })
})
.await
.unwrap();
assert!(!valid);
}
#[tokio::test]
async fn accept_invitation_for_unknown_token_fails() {
let db = setup().await;
let invitations = SqliteInvitationRepository;
let result = db
.run(move |txn| {
let invitations = invitations.clone();
Box::pin(async move { invitations.accept_invitation(txn, "bogus".into()).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
// ---- Existing integration-style tests ----
#[tokio::test]
async fn checked_state_is_set_not_toggled() {
let db = setup().await;
let list = create_list(&db, "List").await;
let item = add_item(&db, list.id, "Coffee").await;
let items = SqliteItemRepository;
db.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.set_item_checked(txn, list.id, item.id, true).await })
})
.await
.unwrap();
db.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.set_item_checked(txn, list.id, item.id, true).await })
})
.await
.unwrap();
let item = get_items(&db, list.id).await.remove(0);
assert!(item.checked);
assert_eq!(item.version, 3);
}
#[tokio::test]
async fn checking_an_item_does_not_change_list_order() {
let db = setup().await;
let list = create_list(&db, "List").await;
add_item(&db, list.id, "First").await;
add_item(&db, list.id, "Second").await;
let items = SqliteItemRepository;
let first_item = get_items(&db, list.id).await.remove(0);
db.run(move |txn| {
let items = items.clone();
Box::pin(async move {
items
.set_item_checked(txn, list.id, first_item.id, true)
.await
})
})
.await
.unwrap();
let items = get_items(&db, list.id).await;
assert_eq!(items[0].name, "First");
assert!(items[0].checked);
assert_eq!(items[1].name, "Second");
}
// ---- MealRepository / MealIngredientRepository ----
async fn create_meal(db: &SqliteDatabase, name: &str) -> Meal {
let meals = SqliteMealRepository;
let name = name.to_owned();
db.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.create_meal(txn, name, String::new(), None).await })
})
.await
.unwrap()
}
async fn add_ingredient(
db: &SqliteDatabase,
meal_id: i64,
name: &str,
category_id: Option<i64>,
) -> MealIngredient {
let ingredients = SqliteMealIngredientRepository;
let name_for_insert = name.to_owned();
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.add_ingredient(
txn,
meal_id,
name_for_insert,
String::new(),
String::new(),
category_id,
)
.await
})
})
.await
.unwrap();
let ingredients = SqliteMealIngredientRepository;
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal_id).await })
})
.await
.unwrap()
.into_iter()
.find(|ingredient| ingredient.name == name)
.unwrap()
}
#[tokio::test]
async fn create_meal_returns_meal_with_id() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
assert!(meal.id > 0);
assert_eq!(meal.name, "Pasta");
assert!(meal.ingredients.is_empty());
}
#[tokio::test]
async fn list_meals_returns_all_meals() {
let db = setup().await;
create_meal(&db, "Pasta").await;
create_meal(&db, "Salad").await;
let meals = SqliteMealRepository;
let meals = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.list_meals(txn).await })
})
.await
.unwrap();
let mut names = meals.iter().map(|m| m.name.clone()).collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["Pasta".to_owned(), "Salad".to_owned()]);
}
#[tokio::test]
async fn get_meal_returns_meal_with_ingredients() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
add_ingredient(&db, meal.id, "Penne", None).await;
add_ingredient(&db, meal.id, "Tomato", None).await;
let meals = SqliteMealRepository;
let fetched = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, meal.id).await })
})
.await
.unwrap()
.unwrap();
assert_eq!(fetched.name, "Pasta");
let mut names = fetched
.ingredients
.iter()
.map(|i| i.name.clone())
.collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["Penne".to_owned(), "Tomato".to_owned()]);
}
#[tokio::test]
async fn get_meal_returns_none_for_unknown() {
let db = setup().await;
let meals = SqliteMealRepository;
let found = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, 9999).await })
})
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn update_meal_changes_fields() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let meals = SqliteMealRepository;
db.run(move |txn| {
let meals = meals.clone();
Box::pin(async move {
meals
.update_meal(
txn,
meal.id,
"Pasta al pomodoro".into(),
"desc".into(),
None,
)
.await
})
})
.await
.unwrap();
let fetched = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, meal.id).await })
})
.await
.unwrap()
.unwrap();
assert_eq!(fetched.name, "Pasta al pomodoro");
assert_eq!(fetched.description, "desc");
}
#[tokio::test]
async fn update_missing_meal_fails() {
let db = setup().await;
let meals = SqliteMealRepository;
let result = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move {
meals
.update_meal(txn, 9999, "X".into(), String::new(), None)
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn delete_meal_removes_it_and_ingredients() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
add_ingredient(&db, meal.id, "Penne", None).await;
let meals = SqliteMealRepository;
db.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.delete_meal(txn, meal.id).await })
})
.await
.unwrap();
let found = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, meal.id).await })
})
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn delete_missing_meal_fails() {
let db = setup().await;
let meals = SqliteMealRepository;
let result = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.delete_meal(txn, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn add_ingredient_with_unknown_category_fails() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredients = SqliteMealIngredientRepository;
let result = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.add_ingredient(
txn,
meal.id,
"X".into(),
String::new(),
String::new(),
Some(9999),
)
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn update_ingredient_changes_fields() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
let ingredients = SqliteMealIngredientRepository;
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.update_ingredient(
txn,
meal.id,
ingredient.id,
"Rigatoni".into(),
"500g".into(),
String::new(),
None,
)
.await
})
})
.await
.unwrap();
let fetched = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
})
.await
.unwrap();
assert_eq!(fetched[0].name, "Rigatoni");
assert_eq!(fetched[0].quantity, "500g");
}
#[tokio::test]
async fn delete_ingredient_removes_it() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
let ingredients = SqliteMealIngredientRepository;
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.delete_ingredient(txn, meal.id, ingredient.id)
.await
})
})
.await
.unwrap();
let remaining = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
})
.await
.unwrap();
assert!(remaining.is_empty());
}
#[tokio::test]
async fn delete_missing_ingredient_fails() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredients = SqliteMealIngredientRepository;
let result = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.delete_ingredient(txn, meal.id, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
// ---- ListMealRepository ----
async fn add_meal_to_list(db: &SqliteDatabase, list_id: i64, meal: &Meal) -> ListMeal {
let list_meals = SqliteListMealRepository;
let name = meal.name.clone();
let meal_id = meal.id;
let id = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move { list_meals.add_meal(txn, list_id, meal_id, name).await })
})
.await
.unwrap();
ListMeal {
id,
meal_id: Some(meal.id),
name: meal.name.clone(),
created_at: 0,
}
}
#[tokio::test]
async fn list_meals_returns_meals_added_to_a_list() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let meal = create_meal(&db, "Pasta").await;
add_meal_to_list(&db, list.id, &meal).await;
let list_meals = SqliteListMealRepository;
let meals = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
})
.await
.unwrap();
assert_eq!(meals.len(), 1);
assert_eq!(meals[0].name, "Pasta");
assert_eq!(meals[0].meal_id, Some(meal.id));
}
#[tokio::test]
async fn removing_a_meal_deletes_its_items_and_bumps_revision() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let meal = create_meal(&db, "Pasta").await;
add_ingredient(&db, meal.id, "Penne", None).await;
add_ingredient(&db, meal.id, "Tomato", None).await;
let list_meal = add_meal_to_list(&db, list.id, &meal).await;
let items = SqliteItemRepository;
let ingredients = SqliteMealIngredientRepository;
let ingredient_rows = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
})
.await
.unwrap();
let new_items = ingredient_rows
.into_iter()
.map(|ingredient| NewItem {
name: ingredient.name,
quantity: ingredient.quantity,
note: ingredient.note,
category_id: ingredient.category_id,
list_meal_id: Some(list_meal.id),
})
.collect();
db.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await })
})
.await
.unwrap();
assert_eq!(get_items(&db, list.id).await.len(), 2);
let list_meals = SqliteListMealRepository;
let revision = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move { list_meals.remove_meal(txn, list.id, list_meal.id).await })
})
.await
.unwrap();
assert_eq!(revision, 2);
let meals = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
})
.await
.unwrap();
assert!(meals.is_empty());
assert!(get_items(&db, list.id).await.is_empty());
}
#[tokio::test]
async fn removing_an_unknown_meal_from_a_list_fails() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let list_meals = SqliteListMealRepository;
let result = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move { list_meals.remove_meal(txn, list.id, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
// ---- PasskeyRepository ----
#[tokio::test]
async fn passkey_crud_roundtrip() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let passkeys = SqlitePasskeyRepository;
let created = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move {
passkeys
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
.await
})
})
.await
.unwrap();
assert!(created.id > 0);
assert_eq!(created.user_id, user.id);
assert_eq!(created.credential_id, "cred-1");
let found = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move { passkeys.find_by_credential_id(txn, "cred-1".into()).await })
})
.await
.unwrap()
.unwrap();
assert_eq!(found.id, created.id);
let listed = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move { passkeys.list_for_user(txn, user.id).await })
})
.await
.unwrap();
assert_eq!(listed.len(), 1);
db.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move { passkeys.delete_passkey(txn, user.id, created.id).await })
})
.await
.unwrap();
let after = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move { passkeys.list_for_user(txn, user.id).await })
})
.await
.unwrap();
assert!(after.is_empty());
}
#[tokio::test]
async fn duplicate_passkey_credential_id_conflicts() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let passkeys = SqlitePasskeyRepository;
db.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move {
passkeys
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
.await
})
})
.await
.unwrap();
let result = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move {
passkeys
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::Conflict)));
}
#[tokio::test]
async fn delete_missing_passkey_fails() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let passkeys = SqlitePasskeyRepository;
let result = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move { passkeys.delete_passkey(txn, user.id, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
}