meal categories
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/fmt Pipeline failed
ci/woodpecker/push/test Pipeline was successful

This commit is contained in:
2026-08-03 21:29:33 -04:00
parent cf6853d71e
commit 3695fc68d6
11 changed files with 680 additions and 67 deletions
+229 -13
View File
@@ -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
})
})