import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { openInMemoryForTest } from '../../src/lib/server/db'; import { insertRecipe, getRecipeById, getRecipeIdBySourceUrl, deleteRecipe, updateRecipeMeta, replaceIngredients, replaceSteps } from '../../src/lib/server/recipes/repository'; import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe'; import type { Recipe } from '../../src/lib/types'; const here = dirname(fileURLToPath(import.meta.url)); const fixture = (name: string): string => readFileSync(join(here, '../fixtures', name), 'utf8'); function baseRecipe(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('recipe repository', () => { it('round-trips an imported chefkoch recipe', () => { const db = openInMemoryForTest(); const parsed = extractRecipeFromHtml(fixture('chefkoch-schupfnudeln.html')); expect(parsed).not.toBeNull(); parsed!.source_url = 'https://www.chefkoch.de/rezepte/4094871643016343/x.html'; parsed!.source_domain = 'chefkoch.de'; const id = insertRecipe(db, parsed!); const loaded = getRecipeById(db, id); expect(loaded).not.toBeNull(); expect(loaded!.title).toBe(parsed!.title); expect(loaded!.ingredients.length).toBe(parsed!.ingredients.length); expect(loaded!.steps.length).toBe(parsed!.steps.length); expect(loaded!.ingredients[0].name).toBe(parsed!.ingredients[0].name); }); it('FTS finds recipe by ingredient after refresh', () => { const db = openInMemoryForTest(); insertRecipe( db, baseRecipe({ title: 'Spaghetti Carbonara', ingredients: [ { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta', section_heading: null } ], tags: ['Italienisch'] }) ); const byIngredient = db .prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'pancetta'") .all(); expect(byIngredient.length).toBe(1); const byTag = db .prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'italienisch'") .all(); expect(byTag.length).toBe(1); }); it('looks up by source_url and deletes cascading', () => { const db = openInMemoryForTest(); const id = insertRecipe( db, baseRecipe({ title: 'Foo', source_url: 'https://example.com/foo', source_domain: 'example.com' }) ); expect(getRecipeIdBySourceUrl(db, 'https://example.com/foo')).toBe(id); deleteRecipe(db, id); expect(getRecipeById(db, id)).toBeNull(); }); it('updateRecipeMeta patches only supplied fields', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, baseRecipe({ title: 'A', prep_time_min: 10 })); updateRecipeMeta(db, id, { description: 'neu', prep_time_min: 15 }); const loaded = getRecipeById(db, id); expect(loaded?.title).toBe('A'); // unverändert expect(loaded?.description).toBe('neu'); expect(loaded?.prep_time_min).toBe(15); }); it('replaceIngredients swaps full list and rebuilds FTS', () => { const db = openInMemoryForTest(); const id = insertRecipe( db, baseRecipe({ title: 'Pasta', ingredients: [ { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta', section_heading: null } ] }) ); replaceIngredients(db, id, [ { position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln', section_heading: null }, { position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier', section_heading: null } ]); const loaded = getRecipeById(db, id); expect(loaded?.ingredients.length).toBe(2); expect(loaded?.ingredients[0].name).toBe('Nudeln'); // FTS index should reflect new ingredient const hit = db .prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'nudeln'") .all(); expect(hit.length).toBe(1); }); it('replaceSteps swaps full list', () => { const db = openInMemoryForTest(); const id = insertRecipe( db, baseRecipe({ title: 'S', steps: [ { position: 1, text: 'Alt' } ] }) ); replaceSteps(db, id, [ { position: 1, text: 'Erst' }, { position: 2, text: 'Dann' } ]); const loaded = getRecipeById(db, id); expect(loaded?.steps.map((s) => s.text)).toEqual(['Erst', 'Dann']); }); it('persistiert section_heading und gibt es beim Laden zurueck', () => { const db = openInMemoryForTest(); const recipe = baseRecipe({ title: 'Torte', ingredients: [ { position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Für den Teig' }, { position: 2, quantity: 100, unit: 'g', name: 'Zucker', note: null, raw_text: '100 g Zucker', section_heading: null }, { position: 3, quantity: 300, unit: 'g', name: 'Beeren', note: null, raw_text: '300 g Beeren', section_heading: 'Für die Füllung' } ] }); const id = insertRecipe(db, recipe); const loaded = getRecipeById(db, id); expect(loaded!.ingredients[0].section_heading).toBe('Für den Teig'); expect(loaded!.ingredients[1].section_heading).toBeNull(); expect(loaded!.ingredients[2].section_heading).toBe('Für die Füllung'); }); it('replaceIngredients persistiert section_heading', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, baseRecipe({ title: 'X' })); replaceIngredients(db, id, [ { position: 1, quantity: null, unit: null, name: 'A', note: null, raw_text: 'A', section_heading: 'Kopf' } ]); const loaded = getRecipeById(db, id); expect(loaded!.ingredients[0].section_heading).toBe('Kopf'); }); });