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:
+738
-60
@@ -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(¬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::<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(¬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,
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user