From 8ceb5e95d749266549d7377e99a0a1a342813167 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:50:58 +0200 Subject: [PATCH] feat(shopping): addRecipeToCart (idempotent via ON CONFLICT) --- src/lib/server/shopping/repository.ts | 31 +++++++-- tests/integration/shopping-repository.test.ts | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 tests/integration/shopping-repository.test.ts diff --git a/src/lib/server/shopping/repository.ts b/src/lib/server/shopping/repository.ts index bc0efd7..833e174 100644 --- a/src/lib/server/shopping/repository.ts +++ b/src/lib/server/shopping/repository.ts @@ -25,12 +25,20 @@ export type ShoppingListSnapshot = { }; export function addRecipeToCart( - _db: Database.Database, - _recipeId: number, - _profileId: number | null, - _servings?: number + db: Database.Database, + recipeId: number, + profileId: number | null, + servings?: number ): void { - throw new Error('not implemented'); + const row = db + .prepare('SELECT servings_default FROM recipe WHERE id = ?') + .get(recipeId) as { servings_default: number | null } | undefined; + const resolved = servings ?? row?.servings_default ?? 4; + db.prepare( + `INSERT INTO shopping_cart_recipe (recipe_id, servings, added_by_profile_id) + VALUES (?, ?, ?) + ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings` + ).run(recipeId, resolved, profileId); } export function removeRecipeFromCart(_db: Database.Database, _recipeId: number): void { @@ -45,8 +53,17 @@ export function setCartServings( throw new Error('not implemented'); } -export function listShoppingList(_db: Database.Database): ShoppingListSnapshot { - throw new Error('not implemented'); +export function listShoppingList(db: Database.Database): ShoppingListSnapshot { + const recipes = db + .prepare( + `SELECT cr.recipe_id, r.title, r.image_path, cr.servings, + COALESCE(r.servings_default, cr.servings) AS servings_default + FROM shopping_cart_recipe cr + JOIN recipe r ON r.id = cr.recipe_id + ORDER BY cr.added_at ASC` + ) + .all() as ShoppingCartRecipe[]; + return { recipes, rows: [], uncheckedCount: 0 }; } export function toggleCheck( diff --git a/tests/integration/shopping-repository.test.ts b/tests/integration/shopping-repository.test.ts new file mode 100644 index 0000000..2487f2d --- /dev/null +++ b/tests/integration/shopping-repository.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { openInMemoryForTest } from '../../src/lib/server/db'; +import { insertRecipe } from '../../src/lib/server/recipes/repository'; +import { + addRecipeToCart, + listShoppingList +} from '../../src/lib/server/shopping/repository'; +import type { Recipe } from '../../src/lib/types'; + +function recipe(overrides: Partial = {}): Recipe { + return { + id: null, + title: 'Test', + description: null, + source_url: null, + source_domain: null, + image_path: null, + servings_default: 4, + servings_unit: null, + prep_time_min: null, + cook_time_min: null, + total_time_min: null, + cuisine: null, + category: null, + ingredients: [], + steps: [], + tags: [], + ...overrides + }; +} + +describe('addRecipeToCart', () => { + it('inserts recipe with default servings from recipe.servings_default', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, recipe({ title: 'Pasta', servings_default: 4 })); + addRecipeToCart(db, id, null); + const snap = listShoppingList(db); + expect(snap.recipes).toHaveLength(1); + expect(snap.recipes[0].servings).toBe(4); + }); + + it('respects explicit servings override', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, recipe({ servings_default: 4 })); + addRecipeToCart(db, id, null, 2); + expect(listShoppingList(db).recipes[0].servings).toBe(2); + }); + + it('is idempotent: second insert updates servings, not fails', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, recipe({ servings_default: 4 })); + addRecipeToCart(db, id, null, 2); + addRecipeToCart(db, id, null, 6); + const snap = listShoppingList(db); + expect(snap.recipes).toHaveLength(1); + expect(snap.recipes[0].servings).toBe(6); + }); + + it('falls back to servings=4 when recipe has no default', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, recipe({ servings_default: null })); + addRecipeToCart(db, id, null); + expect(listShoppingList(db).recipes[0].servings).toBe(4); + }); +});