meal categories
This commit is contained in:
+229
-13
@@ -7,13 +7,13 @@ use sha2::{Digest, Sha256};
|
||||
use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions};
|
||||
|
||||
use crate::domain::{
|
||||
Category, DomainError, DomainResult, GroceryList, Item, Meal, MealIngredient, Passkey,
|
||||
SessionUser, User,
|
||||
Category, DomainError, DomainResult, GroceryList, Item, Meal, MealCategory, MealIngredient,
|
||||
Passkey, SessionUser, User,
|
||||
};
|
||||
use crate::ports::{
|
||||
CategoryRepository, InvitationRepository, ItemRepository, ListRepository,
|
||||
MealIngredientRepository, MealRepository, NewItem, PasskeyRepository, SessionRepository,
|
||||
UserRepository,
|
||||
MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository,
|
||||
SessionRepository, UserRepository,
|
||||
};
|
||||
|
||||
/// The embedded SQL migrations, applied automatically on startup.
|
||||
@@ -35,6 +35,7 @@ impl SqliteDatabase {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ impl SqliteDatabase {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -117,6 +119,30 @@ async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> {
|
||||
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;
|
||||
|
||||
@@ -779,6 +805,78 @@ impl InvitationRepository for SqliteInvitationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SqliteMealCategoryRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl MealCategoryRepository for SqliteMealCategoryRepository {
|
||||
async fn meal_categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<MealCategory>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name
|
||||
FROM meal_categories
|
||||
ORDER BY position ASC, name COLLATE NOCASE ASC",
|
||||
)
|
||||
.fetch_all(&mut *txn)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| MealCategory {
|
||||
id: row.get(0),
|
||||
name: row.get(1),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_meal_category(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
name: String,
|
||||
) -> DomainResult<i64> {
|
||||
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM meal_categories")
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.get(0);
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO meal_categories (name, position, created_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(position)
|
||||
.bind(now())
|
||||
.execute(&mut *txn)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
|
||||
Err(error) => return Err(db_error(error)),
|
||||
}
|
||||
Ok(sqlx::query("SELECT last_insert_rowid()")
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.get::<i64, _>(0))
|
||||
}
|
||||
|
||||
async fn delete_meal_category(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
category_id: i64,
|
||||
) -> DomainResult<()> {
|
||||
let changed = sqlx::query("DELETE FROM meal_categories WHERE id = ?1")
|
||||
.bind(category_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.rows_affected();
|
||||
if changed == 0 {
|
||||
return Err(DomainError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SqliteMealRepository;
|
||||
|
||||
@@ -789,14 +887,16 @@ impl MealRepository for SqliteMealRepository {
|
||||
txn: &mut SqliteConnection,
|
||||
name: String,
|
||||
description: String,
|
||||
category_id: Option<i64>,
|
||||
) -> DomainResult<Meal> {
|
||||
let now = now();
|
||||
sqlx::query(
|
||||
"INSERT INTO meals (name, description, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)",
|
||||
"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
|
||||
@@ -810,6 +910,7 @@ impl MealRepository for SqliteMealRepository {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category_id,
|
||||
ingredients: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -820,7 +921,7 @@ impl MealRepository for SqliteMealRepository {
|
||||
meal_id: i64,
|
||||
) -> DomainResult<Option<Meal>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, description
|
||||
"SELECT id, name, description, category_id
|
||||
FROM meals
|
||||
WHERE id = ?1",
|
||||
)
|
||||
@@ -835,6 +936,7 @@ impl MealRepository for SqliteMealRepository {
|
||||
id: row.get(0),
|
||||
name: row.get(1),
|
||||
description: row.get(2),
|
||||
category_id: row.get(3),
|
||||
ingredients: Vec::new(),
|
||||
};
|
||||
let ingredients = SqliteMealIngredientRepository
|
||||
@@ -848,7 +950,7 @@ impl MealRepository for SqliteMealRepository {
|
||||
|
||||
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, description
|
||||
"SELECT id, name, description, category_id
|
||||
FROM meals
|
||||
ORDER BY name COLLATE NOCASE ASC",
|
||||
)
|
||||
@@ -861,6 +963,7 @@ impl MealRepository for SqliteMealRepository {
|
||||
id: row.get(0),
|
||||
name: row.get(1),
|
||||
description: row.get(2),
|
||||
category_id: row.get(3),
|
||||
ingredients: Vec::new(),
|
||||
};
|
||||
let ingredients = SqliteMealIngredientRepository
|
||||
@@ -880,14 +983,16 @@ impl MealRepository for SqliteMealRepository {
|
||||
meal_id: i64,
|
||||
name: String,
|
||||
description: String,
|
||||
category_id: Option<i64>,
|
||||
) -> DomainResult<()> {
|
||||
let changed = sqlx::query(
|
||||
"UPDATE meals
|
||||
SET name = ?1, description = ?2, updated_at = ?3
|
||||
WHERE id = ?4",
|
||||
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)
|
||||
@@ -1076,6 +1181,15 @@ const DEFAULT_CATEGORIES: &[&str] = &[
|
||||
"Household",
|
||||
];
|
||||
|
||||
const DEFAULT_MEAL_CATEGORIES: &[&str] = &[
|
||||
"Beef",
|
||||
"Chicken",
|
||||
"Pasta",
|
||||
"Sandwiches",
|
||||
"Salads",
|
||||
"Soups",
|
||||
];
|
||||
|
||||
fn hash_secret(secret: &str) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret.as_bytes());
|
||||
@@ -1479,6 +1593,108 @@ mod tests {
|
||||
assert!(matches!(result, Err(DomainError::Conflict)));
|
||||
}
|
||||
|
||||
// ---- MealCategoryRepository ----
|
||||
|
||||
async fn get_meal_categories(db: &SqliteDatabase) -> Vec<MealCategory> {
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
db.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
Box::pin(async move { categories.meal_categories(txn).await })
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn meal_categories_are_seeded_with_defaults() {
|
||||
let db = setup().await;
|
||||
let categories = get_meal_categories(&db).await;
|
||||
let names = categories.iter().map(|c| c.name.as_str()).collect::<Vec<_>>();
|
||||
assert!(names.contains(&"Beef"));
|
||||
assert!(names.contains(&"Chicken"));
|
||||
assert!(names.contains(&"Pasta"));
|
||||
assert!(names.contains(&"Sandwiches"));
|
||||
assert!(names.contains(&"Salads"));
|
||||
assert!(names.contains(&"Soups"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_meal_category_returns_id_and_lists() {
|
||||
let db = setup().await;
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
let id = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
Box::pin(async move {
|
||||
categories.create_meal_category(txn, "Breakfast".into()).await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(id > 0);
|
||||
let cats = get_meal_categories(&db).await;
|
||||
assert!(cats.iter().any(|c| c.id == id && c.name == "Breakfast"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_duplicate_meal_category_conflicts() {
|
||||
let db = setup().await;
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
Box::pin(async move {
|
||||
categories.create_meal_category(txn, "Beef".into()).await
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(DomainError::Conflict)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_meal_category_cascades_to_null_on_meals() {
|
||||
let db = setup().await;
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
let category_id = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
Box::pin(async move {
|
||||
categories.create_meal_category(txn, "Breakfast".into()).await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let meal = create_meal(&db, "Pancakes").await;
|
||||
let meals = SqliteMealRepository;
|
||||
db.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
Box::pin(async move {
|
||||
meals
|
||||
.update_meal(txn, meal.id, "Pancakes".into(), String::new(), Some(category_id))
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
Box::pin(async move { categories.delete_meal_category(txn, category_id).await })
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(fetched.category_id, None);
|
||||
}
|
||||
|
||||
// ---- ItemRepository ----
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1828,7 +2044,7 @@ mod tests {
|
||||
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 })
|
||||
Box::pin(async move { meals.create_meal(txn, name, String::new(), None).await })
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -1946,7 +2162,7 @@ mod tests {
|
||||
let meals = meals.clone();
|
||||
Box::pin(async move {
|
||||
meals
|
||||
.update_meal(txn, meal.id, "Pasta al pomodoro".into(), "desc".into())
|
||||
.update_meal(txn, meal.id, "Pasta al pomodoro".into(), "desc".into(), None)
|
||||
.await
|
||||
})
|
||||
})
|
||||
@@ -1973,7 +2189,7 @@ mod tests {
|
||||
let meals = meals.clone();
|
||||
Box::pin(async move {
|
||||
meals
|
||||
.update_meal(txn, 9999, "X".into(), String::new())
|
||||
.update_meal(txn, 9999, "X".into(), String::new(), None)
|
||||
.await
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user