Add support for meals (#1)

Reviewed-on: #1
Co-authored-by: Simon Bernier St-Pierre <git.sbstp.ca@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-01 20:27:38 -04:00
committed by sbstp
parent 6a2cef2003
commit 9b32fd23a7
11 changed files with 1858 additions and 135 deletions
+738 -60
View File
@@ -6,10 +6,13 @@ 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, SessionUser, User};
use crate::domain::{
Category, DomainError, DomainResult, GroceryList, Item, Meal, MealIngredient, SessionUser,
User,
};
use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListRepository, SessionRepository,
UserRepository,
CategoryRepository, InvitationRepository, ItemRepository, ListRepository, MealIngredientRepository,
MealRepository, NewItem, SessionRepository, UserRepository,
};
#[derive(Clone)]
@@ -27,6 +30,7 @@ impl SqliteDatabase {
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?;
seed_default_categories(&pool).await?;
Ok(Self { pool })
}
@@ -52,6 +56,7 @@ impl SqliteDatabase {
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?;
seed_default_categories(&pool).await?;
Ok(Self { pool })
}
}
@@ -109,11 +114,9 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
name TEXT NOT NULL COLLATE NOCASE,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
UNIQUE (list_id, name)
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY,
@@ -133,8 +136,24 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meal_ingredients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id);
CREATE INDEX IF NOT EXISTS meal_ingredients_meal_idx ON meal_ingredients(meal_id);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);",
)
.execute(pool)
@@ -143,6 +162,28 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
Ok(())
}
/// 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(())
}
#[derive(Clone, Copy)]
pub struct SqliteUserRepository;
@@ -323,19 +364,6 @@ impl ListRepository for SqliteListRepository {
.await
.map_err(db_error)?
.get::<i64, _>(0);
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
sqlx::query(
"INSERT INTO categories (list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(list_id)
.bind(category_name)
.bind(position as i64)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
}
Ok(GroceryList {
id: list_id,
name,
@@ -370,18 +398,12 @@ pub struct SqliteCategoryRepository;
#[async_trait]
impl CategoryRepository for SqliteCategoryRepository {
async fn categories(
&self,
txn: &mut SqliteConnection,
list_id: i64,
) -> DomainResult<Vec<Category>> {
async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>> {
let rows = sqlx::query(
"SELECT id, name
FROM categories
WHERE list_id = ?1
ORDER BY position ASC, name COLLATE NOCASE ASC",
)
.bind(list_id)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
@@ -397,23 +419,17 @@ impl CategoryRepository for SqliteCategoryRepository {
async fn create_category(
&self,
txn: &mut SqliteConnection,
list_id: i64,
name: String,
) -> DomainResult<i64> {
let position: i64 = sqlx::query(
"SELECT COALESCE(MAX(position), -1) + 1
FROM categories WHERE list_id = ?1",
)
.bind(list_id)
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
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 (list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4)",
"INSERT INTO categories (name, position, created_at)
VALUES (?1, ?2, ?3)",
)
.bind(list_id)
.bind(&name)
.bind(position)
.bind(now())
@@ -424,7 +440,32 @@ impl CategoryRepository for SqliteCategoryRepository {
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
Err(error) => return Err(db_error(error)),
}
bump_revision(txn, list_id).await
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(id)
}
async fn category_by_name(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<Option<Category>> {
let row = sqlx::query(
"SELECT id, name
FROM categories
WHERE name = ?1 COLLATE NOCASE",
)
.bind(&name)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| Category {
id: row.get(0),
name: row.get(1),
}))
}
}
@@ -468,7 +509,7 @@ impl ItemRepository for SqliteItemRepository {
note: String,
category_id: Option<i64>,
) -> DomainResult<i64> {
ensure_category(txn, list_id, category_id).await?;
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)
@@ -494,6 +535,42 @@ impl ItemRepository for SqliteItemRepository {
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, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?7)",
)
.bind(list_id)
.bind(&item.name)
.bind(&item.quantity)
.bind(&item.note)
.bind(item.category_id)
.bind(position)
.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,
@@ -530,7 +607,7 @@ impl ItemRepository for SqliteItemRepository {
note: String,
category_id: Option<i64>,
) -> DomainResult<i64> {
ensure_category(txn, list_id, category_id).await?;
ensure_category(txn, category_id).await?;
let changed = sqlx::query(
"UPDATE items
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4,
@@ -639,17 +716,274 @@ impl InvitationRepository for SqliteInvitationRepository {
}
}
#[derive(Clone, Copy)]
pub struct SqliteMealRepository;
#[async_trait]
impl MealRepository for SqliteMealRepository {
async fn create_meal(
&self,
txn: &mut SqliteConnection,
name: String,
description: String,
) -> DomainResult<Meal> {
let now = now();
sqlx::query(
"INSERT INTO meals (name, description, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)",
)
.bind(&name)
.bind(&description)
.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,
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
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),
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
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),
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,
) -> DomainResult<()> {
let changed = sqlx::query(
"UPDATE meals
SET name = ?1, description = ?2, updated_at = ?3
WHERE id = ?4",
)
.bind(&name)
.bind(&description)
.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,
list_id: i64,
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 AND list_id = ?2")
let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1")
.bind(category_id)
.bind(list_id)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
@@ -782,11 +1116,11 @@ mod tests {
.unwrap()
}
async fn get_categories(db: &SqliteDatabase, list_id: i64) -> Vec<Category> {
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, list_id).await })
Box::pin(async move { categories.categories(txn).await })
})
.await
.unwrap()
@@ -985,12 +1319,15 @@ mod tests {
// ---- ListRepository ----
#[tokio::test]
async fn create_list_seeds_default_categories() {
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);
assert_eq!(get_categories(&db, list.id).await.len(), 6);
// Categories are global now; creating a list must not add any.
assert_eq!(get_categories(&db).await.len(), before);
}
#[tokio::test]
@@ -1038,39 +1375,36 @@ mod tests {
// ---- CategoryRepository ----
#[tokio::test]
async fn create_category_adds_and_bumps_revision() {
async fn create_category_is_global_and_returns_id() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let categories = SqliteCategoryRepository;
let revision = db
let id = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.create_category(txn, list.id, "Bakery".into())
.create_category(txn, "Bakery".into())
.await
})
})
.await
.unwrap();
assert_eq!(revision, 1);
assert!(id > 0);
let cats = get_categories(&db, list.id).await;
assert_eq!(cats.len(), 7);
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 list = create_list(&db, "Weekly shop").await;
let categories = SqliteCategoryRepository;
let result = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.create_category(txn, list.id, "Produce".into())
.create_category(txn, "Produce".into())
.await
})
})
@@ -1078,6 +1412,24 @@ mod tests {
assert!(matches!(result, Err(DomainError::Conflict)));
}
#[tokio::test]
async fn category_by_name_resolves_globally() {
let db = setup().await;
let categories = SqliteCategoryRepository;
let found = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.category_by_name(txn, "produce".into())
.await
})
})
.await
.unwrap();
assert_eq!(found.unwrap().name, "Produce");
}
// ---- ItemRepository ----
#[tokio::test]
@@ -1139,6 +1491,40 @@ mod tests {
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,
},
NewItem {
name: "Tomato".into(),
quantity: "2".into(),
note: String::new(),
category_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;
@@ -1385,4 +1771,296 @@ mod tests {
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()).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())
.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())
.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)));
}
}