import { describe, it, expect } from 'vitest'; import { openInMemoryForTest } from '../../src/lib/server/db'; import { insertRecipe } from '../../src/lib/server/recipes/repository'; import { addRecipeToCart, removeRecipeFromCart, listShoppingList, setCartServings, toggleCheck, clearCheckedItems, clearCart } 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); }); }); describe('removeRecipeFromCart', () => { it('deletes only the given recipe', () => { const db = openInMemoryForTest(); const a = insertRecipe(db, recipe({ title: 'A' })); const b = insertRecipe(db, recipe({ title: 'B' })); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); removeRecipeFromCart(db, a); const snap = listShoppingList(db); expect(snap.recipes).toHaveLength(1); expect(snap.recipes[0].recipe_id).toBe(b); }); it('is idempotent when recipe is not in cart', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe()); expect(() => removeRecipeFromCart(db, id)).not.toThrow(); }); }); describe('setCartServings', () => { it('updates servings for a cart recipe', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe()); addRecipeToCart(db, id, null, 4); setCartServings(db, id, 8); expect(listShoppingList(db).recipes[0].servings).toBe(8); }); it('rejects non-positive servings', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe()); addRecipeToCart(db, id, null, 4); expect(() => setCartServings(db, id, 0)).toThrow(); expect(() => setCartServings(db, id, -3)).toThrow(); }); }); describe('listShoppingList aggregation', () => { it('aggregates same name+unit across recipes', () => { const db = openInMemoryForTest(); const a = insertRecipe(db, recipe({ title: 'Carbonara', servings_default: 4, ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }] })); const b = insertRecipe(db, recipe({ title: 'Lasagne', servings_default: 4, ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, a, null, 4); addRecipeToCart(db, b, null, 4); const rows = listShoppingList(db).rows; expect(rows).toHaveLength(1); expect(rows[0].name_key).toBe('mehl'); expect(rows[0].unit_key).toBe('weight'); expect(rows[0].total_quantity).toBe(400); expect(rows[0].from_recipes).toContain('Carbonara'); expect(rows[0].from_recipes).toContain('Lasagne'); }); it('keeps different units as separate rows', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ servings_default: 4, ingredients: [ { position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }, { position: 2, quantity: 1, unit: 'Pck', name: 'Mehl', note: null, raw_text: '', section_heading: null } ] })); addRecipeToCart(db, id, null, 4); const rows = listShoppingList(db).rows; expect(rows).toHaveLength(2); }); it('scales quantities by servings/servings_default', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ servings_default: 4, ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, id, null, 2); expect(listShoppingList(db).rows[0].total_quantity).toBe(100); }); it('null quantity stays null after aggregation', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ ingredients: [{ position: 1, quantity: null, unit: null, name: 'Salz', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, id, null); const rows = listShoppingList(db).rows; expect(rows[0].total_quantity).toBeNull(); expect(rows[0].unit_key).toBe(''); }); it('counts unchecked rows in uncheckedCount', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ ingredients: [ { position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }, { position: 2, quantity: 1, unit: 'Stk', name: 'Birne', note: null, raw_text: '', section_heading: null } ] })); addRecipeToCart(db, id, null); expect(listShoppingList(db).uncheckedCount).toBe(2); }); it('does not blow up when servings_default is zero (silent NULL total_quantity)', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ servings_default: 0, ingredients: [{ position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, id, null, 4); const rows = listShoppingList(db).rows; expect(rows).toHaveLength(1); expect(rows[0].total_quantity).toBeNull(); }); }); describe('toggleCheck', () => { function setupOneRowCart() { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, id, null); return { db, id }; } it('marks a row as checked', () => { const { db } = setupOneRowCart(); toggleCheck(db, 'mehl', 'weight', true); const rows = listShoppingList(db).rows; expect(rows[0].checked).toBe(1); }); it('unchecks a row when passed false', () => { const { db } = setupOneRowCart(); toggleCheck(db, 'mehl', 'weight', true); toggleCheck(db, 'mehl', 'weight', false); expect(listShoppingList(db).rows[0].checked).toBe(0); }); it('check survives removal of one recipe when another still contributes', () => { const db = openInMemoryForTest(); const a = insertRecipe(db, recipe({ title: 'A', ingredients: [{ position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }] })); const b = insertRecipe(db, recipe({ title: 'B', ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); toggleCheck(db, 'mehl', 'weight', true); // Rezept A weg, Mehl kommt noch aus B — check bleibt, mit neuer Menge removeRecipeFromCart(db, a); const rows = listShoppingList(db).rows; expect(rows[0].checked).toBe(1); expect(rows[0].total_quantity).toBe(200); }); }); describe('clearCheckedItems', () => { it('removes recipes where ALL rows are checked', () => { const db = openInMemoryForTest(); const a = insertRecipe(db, recipe({ title: 'A', ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }] })); const b = insertRecipe(db, recipe({ title: 'B', ingredients: [ { position: 1, quantity: 1, unit: 'Stk', name: 'Birne', note: null, raw_text: '', section_heading: null }, { position: 2, quantity: 1, unit: 'Stk', name: 'Salz', note: null, raw_text: '', section_heading: null } ] })); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); toggleCheck(db, 'apfel', 'stk', true); toggleCheck(db, 'birne', 'stk', true); // Salz aus B noch nicht abgehakt → B bleibt, A fliegt clearCheckedItems(db); const snap = listShoppingList(db); expect(snap.recipes.map((r) => r.recipe_id)).toEqual([b]); // Birne-Check bleibt, weil B noch im Cart und Birne noch aktiv const birneRow = snap.rows.find((r) => r.name_key === 'birne'); expect(birneRow?.checked).toBe(1); }); it('purges orphan checks that no longer map to any cart recipe', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, id, null); toggleCheck(db, 'apfel', 'stk', true); clearCheckedItems(db); // Apfel-Check haengt jetzt an nichts mehr → muss aus der Tabelle raus sein const row = db .prepare('SELECT * FROM shopping_cart_check WHERE name_key = ?') .get('apfel'); expect(row).toBeUndefined(); }); it('is a no-op when nothing is checked', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, id, null); clearCheckedItems(db); expect(listShoppingList(db).recipes).toHaveLength(1); }); }); describe('clearCart', () => { it('deletes all cart recipes and all checks', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }] })); addRecipeToCart(db, id, null); toggleCheck(db, 'apfel', 'stk', true); clearCart(db); const snap = listShoppingList(db); expect(snap.recipes).toEqual([]); expect(snap.rows).toEqual([]); expect(snap.uncheckedCount).toBe(0); const anyCheck = db.prepare('SELECT 1 FROM shopping_cart_check').get(); expect(anyCheck).toBeUndefined(); }); }); describe('toggleCheck — stabil ueber Unit-Family', () => { it('haekchen bleibt erhalten wenn Gesamtmenge von kg auf g faellt', () => { const db = openInMemoryForTest(); const a = insertRecipe( db, recipe({ title: 'R1', servings_default: 4, ingredients: [{ position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }] }) ); const b = insertRecipe( db, recipe({ title: 'R2', servings_default: 4, ingredients: [{ position: 1, name: 'Kartoffeln', quantity: 1, unit: 'kg', note: null, raw_text: '', section_heading: null }] }) ); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); // Abhaken der konsolidierten 1,5-kg-Zeile via family-key const before = listShoppingList(db).rows[0]; toggleCheck(db, before.name_key, before.unit_key, true); expect(listShoppingList(db).rows[0].checked).toBe(1); // Ein Rezept rausnehmen → nur noch 500 g, display wechselt auf g removeRecipeFromCart(db, b); const after = listShoppingList(db).rows[0]; expect(after.display_unit).toBe('g'); expect(after.total_quantity).toBe(500); // Haekchen bleibt: unit_key ist weiterhin 'weight' expect(after.checked).toBe(1); }); it('clearCheckedItems respektiert family-key beim Orphan-Cleanup', () => { const db = openInMemoryForTest(); const a = insertRecipe( db, recipe({ title: 'R1', servings_default: 4, ingredients: [ { position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }, { position: 2, name: 'Salz', quantity: 1, unit: 'Prise', note: null, raw_text: '', section_heading: null } ] }) ); addRecipeToCart(db, a, null); const rows = listShoppingList(db).rows; // Alle abhaken for (const r of rows) toggleCheck(db, r.name_key, r.unit_key, true); clearCheckedItems(db); // Das Rezept sollte raus sein expect(listShoppingList(db).recipes).toHaveLength(0); // Check-Tabelle sollte leer sein (keine Orphans) const remaining = (db.prepare('SELECT COUNT(*) AS c FROM shopping_cart_check').get() as { c: number }).c; expect(remaining).toBe(0); }); }); describe('listShoppingList — Konsolidierung ueber Einheiten', () => { it('fasst 500 g + 1 kg Kartoffeln zu 1,5 kg zusammen', () => { const db = openInMemoryForTest(); const a = insertRecipe( db, recipe({ title: 'Kartoffelsuppe', servings_default: 4, ingredients: [ { position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null } ] }) ); const b = insertRecipe( db, recipe({ title: 'Kartoffelpuffer', servings_default: 4, ingredients: [ { position: 1, name: 'Kartoffeln', quantity: 1, unit: 'kg', note: null, raw_text: '', section_heading: null } ] }) ); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); const snap = listShoppingList(db); const kartoffeln = snap.rows.filter((r) => r.display_name.toLowerCase() === 'kartoffeln'); expect(kartoffeln).toHaveLength(1); expect(kartoffeln[0].total_quantity).toBe(1.5); expect(kartoffeln[0].display_unit).toBe('kg'); }); it('kombiniert ml + l korrekt (400 ml + 0,5 l → 900 ml)', () => { const db = openInMemoryForTest(); const a = insertRecipe( db, recipe({ title: 'R1', servings_default: 4, ingredients: [{ position: 1, name: 'Milch', quantity: 400, unit: 'ml', note: null, raw_text: '', section_heading: null }] }) ); const b = insertRecipe( db, recipe({ title: 'R2', servings_default: 4, ingredients: [{ position: 1, name: 'Milch', quantity: 0.5, unit: 'l', note: null, raw_text: '', section_heading: null }] }) ); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); const milch = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'milch'); expect(milch).toHaveLength(1); expect(milch[0].total_quantity).toBe(900); expect(milch[0].display_unit).toBe('ml'); }); it('laesst inkompatible Families getrennt (5 Stueck Eier + 500 g Eier = 2 Zeilen)', () => { const db = openInMemoryForTest(); const a = insertRecipe( db, recipe({ title: 'R1', servings_default: 4, ingredients: [{ position: 1, name: 'Eier', quantity: 5, unit: 'Stück', note: null, raw_text: '', section_heading: null }] }) ); const b = insertRecipe( db, recipe({ title: 'R2', servings_default: 4, ingredients: [{ position: 1, name: 'Eier', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }] }) ); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); const eier = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'eier'); expect(eier).toHaveLength(2); }); it('summiert gleiche Unit-Family ohne Konversion (2 Bund + 1 Bund → 3 Bund)', () => { const db = openInMemoryForTest(); const a = insertRecipe( db, recipe({ title: 'R1', servings_default: 4, ingredients: [{ position: 1, name: 'Petersilie', quantity: 2, unit: 'Bund', note: null, raw_text: '', section_heading: null }] }) ); const b = insertRecipe( db, recipe({ title: 'R2', servings_default: 4, ingredients: [{ position: 1, name: 'Petersilie', quantity: 1, unit: 'Bund', note: null, raw_text: '', section_heading: null }] }) ); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); const petersilie = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'petersilie'); expect(petersilie).toHaveLength(1); expect(petersilie[0].total_quantity).toBe(3); expect(petersilie[0].display_unit?.toLowerCase()).toBe('bund'); }); });