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 { 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 { // 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(&self, operation: F) -> DomainResult where T: Send + 'static, F: for<'a> FnOnce( &'a mut SqliteConnection, ) -> Pin> + 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 { // 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::(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> { 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, ) -> DomainResult> { 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 { let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users)") .fetch_one(&mut *txn) .await .map_err(db_error)?; Ok(row.get::(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 { 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::(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> { 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> { 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> { 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::, _>(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> { let rows = sqlx::query( "SELECT l.id, l.name, l.revision FROM lists l 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), }) .collect()) } async fn create_list( &self, txn: &mut SqliteConnection, name: String, ) -> DomainResult { 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::(0); Ok(GroceryList { id: list_id, name, revision: 0, }) } async fn get_list( &self, txn: &mut SqliteConnection, list_id: i64, ) -> DomainResult> { let row = sqlx::query( "SELECT l.id, l.name, l.revision 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), })) } } #[derive(Clone, Copy)] pub struct SqliteCategoryRepository; #[async_trait] impl CategoryRepository for SqliteCategoryRepository { async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult> { 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 { 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::(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> { 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::(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, ) -> DomainResult { 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(¬e) .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, ) -> DomainResult { 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 { 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, ) -> DomainResult { 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(¬e) .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 { 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> { 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 { 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::(0); Ok(id) } async fn remove_meal( &self, txn: &mut SqliteConnection, list_id: i64, list_meal_id: i64, ) -> DomainResult { 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 { 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 { 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> { 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 { 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::(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, ) -> DomainResult { 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::(0); Ok(Meal { id, name, description, category_id, ingredients: Vec::new(), }) } async fn get_meal( &self, txn: &mut SqliteConnection, meal_id: i64, ) -> DomainResult> { 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> { 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, ) -> 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> { 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, ) -> DomainResult { 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(¬e) .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::(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, ) -> 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(¬e) .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) -> 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 { 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 { 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 { 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 { 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 { 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::>(); ids.sort_unstable(); assert_eq!(ids, vec![first.id, second.id]); } #[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 { 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::>(); 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::>(); 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, ) -> 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::>(); 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::>(); 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))); } }