2026-04-17 15:11:23 +02:00
|
|
|
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,
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
deleteRecipe,
|
|
|
|
|
updateRecipeMeta,
|
|
|
|
|
replaceIngredients,
|
|
|
|
|
replaceSteps
|
2026-04-17 15:11:23 +02:00
|
|
|
} 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> = {}): 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,
|
2026-04-19 14:55:46 +02:00
|
|
|
raw_text: '200 g Pancetta',
|
|
|
|
|
section_heading: null
|
2026-04-17 15:11:23 +02:00
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
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();
|
|
|
|
|
});
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
|
|
|
|
|
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: [
|
2026-04-19 14:55:46 +02:00
|
|
|
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta', section_heading: null }
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
replaceIngredients(db, id, [
|
2026-04-19 14:55:46 +02:00
|
|
|
{ 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 }
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
]);
|
|
|
|
|
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']);
|
|
|
|
|
});
|
2026-04-19 14:55:46 +02:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
2026-04-17 15:11:23 +02:00
|
|
|
});
|