feat(core): add secure recipe APIs, precise scaling and versioned imports

This commit is contained in:
OpenCode committed 2026-10-24 15:40:44 +02:00
commit 5d71d32e20
31 files changed
+6293

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
import { recipeSchema } from '../src/lib/recipe';
export const pasta = recipeSchema.parse({
id: '10000000-0000-4000-8000-000000000001',
title: 'Grüne Pesto-Pasta',
subtitle: 'Ein Teller Sommer, ganz ohne Eile.',
description:
'Frisches Basilikum, geröstete Pinienkerne und ein Hauch Zitrone. Unsere liebste Pasta für Tage, an denen es einfach gut sein soll.',
image: '/images/kitchen.jpg',
categories: ['Alltagsküche', 'Vegetarisch'],
tags: ['Schnell', 'Familienliebling'],
cuisine: 'Italien',
servings: 2,
prepMinutes: 10,
cookMinutes: 12,
published: true,
ingredients: [
{ id: 'pasta', name: 'Pasta', amount: 200, unit: 'g', section: 'Für die Pasta' },
{ id: 'basilikum', name: 'Basilikum', amount: 40, unit: 'g', section: 'Für das Pesto' },
{ id: 'kerne', name: 'Pinienkerne', amount: 30, unit: 'g', section: 'Für das Pesto' },
{ id: 'oel', name: 'Olivenöl', amount: 60, unit: 'ml', section: 'Für das Pesto' },
{
id: 'kaese',
name: 'Hartkäse mit mikrobiellem Lab',
amount: 40,
unit: 'g',
section: 'Für das Pesto',
},
{
id: 'zitrone',
name: 'Bio-Zitrone',
amount: 0.5,
unit: 'Stück',
section: 'Zum Abschmecken',
optional: true,
},
{
id: 'salz',
name: 'Salz',
amount: null,
unit: '',
note: 'nach Geschmack',
section: 'Zum Abschmecken',
},
],
steps: [
{
id: 'vorbereiten',
title: 'Ein bisschen Vorbereitung',
text: '{{basilikum}} waschen und trocken schütteln. {{kaese}} fein reiben. Einen großen Topf Salzwasser aufsetzen.',
ingredients: ['basilikum', 'kaese', 'salz'],
},
{
id: 'pesto',
title: 'Das Pesto zum Leuchten bringen',
text: '{{kerne}} in einer trockenen Pfanne goldbraun rösten. Mit Basilikum und {{oel}} fein mixen, anschließend den geriebenen Käse unterheben.',
ingredients: ['kerne', 'basilikum', 'oel', 'kaese'],
timers: [{ id: 'roesten', label: 'Pinienkerne rösten', seconds: 180 }],
},
{
id: 'kochen',
title: 'Pasta trifft Pesto',
text: '{{pasta}} nach Packungsangabe bissfest kochen. Vor dem Abgießen etwas Kochwasser auffangen. Pasta mit dem Pesto vermengen und mit Kochwasser geschmeidig rühren.',
ingredients: ['pasta'],
timers: [{ id: 'nudeln', label: 'Pasta kochen', seconds: 600 }],
},
{
id: 'geniessen',
title: 'Der schönste Moment',
text: 'Mit dem Saft von {{zitrone}} und Salz abschmecken. Auf warme Teller verteilen und sofort genießen.',
ingredients: ['zitrone', 'salz'],
},
],
tips: 'Pesto nicht stark erhitzen: So bleibt es leuchtend grün. Ein Schuss stärkehaltiges Nudelwasser macht die Sauce wunderbar cremig.',
storage:
'Pesto in einem sauberen Glas mit einer dünnen Ölschicht bedecken und im Kühlschrank innerhalb von zwei Tagen verbrauchen.',
nutrition: { kcal: 780, protein: 22, carbs: 79, fat: 42 },
});
export const salad = recipeSchema.parse({
id: '10000000-0000-4000-8000-000000000002',
title: 'Bunte Garten-Bowl',
subtitle: 'Knackig, frisch und voller guter Dinge.',
description:
'Eine großzügige Schüssel Gemüse, cremige Kichererbsen und ein zitroniges Tahini-Dressing. Genau richtig für einen frischen Start.',
image: '/images/garden.jpg',
categories: ['Vegetarisch'],
tags: ['Meal Prep'],
cuisine: 'Mediterran',
servings: 2,
prepMinutes: 20,
cookMinutes: 10,
published: true,
ingredients: [
{ id: 'kichererbsen', name: 'Kichererbsen, gekocht', amount: 240, unit: 'g' },
{ id: 'salat', name: 'Blattsalat', amount: 100, unit: 'g' },
{ id: 'tomaten', name: 'Cherrytomaten', amount: 200, unit: 'g' },
{ id: 'gurke', name: 'Gurke', amount: 0.5, unit: 'Stück' },
{ id: 'tahini', name: 'Tahini', amount: 2, unit: 'EL' },
{ id: 'zitrone', name: 'Zitronensaft', amount: 2, unit: 'EL' },
{ id: 'wasser', name: 'Wasser', amount: 3, unit: 'EL' },
{ id: 'salz', name: 'Salz', amount: null, note: 'nach Geschmack' },
],
steps: [
{
id: 'gemuese',
title: 'Bunt vorbereiten',
text: '{{salat}} waschen und trocknen. {{tomaten}} halbieren und {{gurke}} in Scheiben schneiden.',
ingredients: ['salat', 'tomaten', 'gurke'],
},
{
id: 'roesten',
title: 'Kichererbsen rösten',
text: '{{kichererbsen}} abtropfen lassen und in einer beschichteten Pfanne zehn Minuten rösten. Regelmäßig umrühren.',
ingredients: ['kichererbsen'],
timers: [{ id: 'roesten', label: 'Kichererbsen rösten', seconds: 600 }],
},
{
id: 'dressing',
title: 'Alles kommt zusammen',
text: '{{tahini}} mit {{zitrone}}, {{wasser}} und Salz verrühren. Gemüse und Kichererbsen anrichten und das Dressing darübergeben.',
ingredients: ['tahini', 'zitrone', 'wasser', 'salz'],
},
],
tips: 'Das Dressing separat aufbewahren, wenn die Bowl für morgen ist.',
storage: 'Gekühlt einen Tag haltbar. Salat und Dressing getrennt lagern.',
});
export const soup = recipeSchema.parse({
id: '10000000-0000-4000-8000-000000000003',
title: 'Wärmende Gemüsesuppe',
subtitle: 'Ein Löffel Gemütlichkeit.',
description:
'Saisongemüse, weiße Bohnen und aromatische Kräuter machen aus wenigen Zutaten ein wohliges Essen für die ganze Familie.',
image: '/images/soup.jpg',
categories: ['Alltagsküche'],
tags: ['Familienliebling', 'Meal Prep'],
servings: 4,
prepMinutes: 15,
cookMinutes: 30,
published: true,
ingredients: [
{ id: 'karotten', name: 'Karotten', amount: 300, unit: 'g' },
{ id: 'kartoffeln', name: 'Kartoffeln', amount: 400, unit: 'g' },
{ id: 'bohnen', name: 'Weiße Bohnen, gekocht', amount: 240, unit: 'g' },
{ id: 'bruehe', name: 'Gemüsebrühe', amount: 1, unit: 'l' },
{ id: 'zwiebel', name: 'Zwiebel', amount: 1, unit: 'Stück' },
{ id: 'oel', name: 'Olivenöl', amount: 1, unit: 'EL' },
{ id: 'kraeuter', name: 'Petersilie', amount: 0.5, unit: 'Bund', optional: true },
],
steps: [
{
id: 'schneiden',
title: 'Alles klein schneiden',
text: '{{karotten}}, {{kartoffeln}} und {{zwiebel}} schälen und würfeln.',
ingredients: ['karotten', 'kartoffeln', 'zwiebel'],
},
{
id: 'kochen',
title: 'Zeit für Wärme',
text: 'Zwiebel in {{oel}} glasig dünsten. Karotten und Kartoffeln dazugeben, mit {{bruehe}} aufgießen und zugedeckt etwa 25 Minuten sanft kochen, bis das Gemüse weich ist.',
ingredients: ['oel', 'bruehe'],
timers: [{ id: 'suppe', label: 'Gemüse weich kochen', seconds: 1500 }],
},
{
id: 'abschmecken',
title: 'Der letzte Schliff',
text: '{{bohnen}} dazugeben und fünf Minuten erwärmen. Abschmecken und mit {{kraeuter}} bestreuen.',
ingredients: ['bohnen', 'kraeuter'],
timers: [{ id: 'bohnen', label: 'Bohnen erwärmen', seconds: 300 }],
},
],
storage:
'Rasch abkühlen und im Kühlschrank maximal zwei Tage lagern. Portionsweise bis zu drei Monate einfrieren.',
});
export const examples = [pasta, salad, soup];
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { ingredientSchema } from '../../src/lib/recipe';
import {
positive,
portionFactor,
ingredientFactor,
scaledAmount,
formatAmount,
convert,
renderStep,
mergeShopping,
} from '../../src/lib/calculator';
import { pasta } from '../fixtures';
const chicken = ingredientSchema.parse({
id: 'chicken',
name: 'Hühnerbrust',
amount: 400,
unit: 'g',
});
describe('Precise scaling from immutable originals', () => {
it('scales 2 to 8 portions', () => {
const f = portionFactor(2, 8);
expect(f).toBe('4');
expect(scaledAmount(chicken, f)).toBe('1600');
});
it('scales all ingredients from 400 g to 800 g', () => {
const f = ingredientFactor(chicken, 800);
expect(f).toBe('2');
expect(scaledAmount({ ...chicken, amount: 200 }, f)).toBe('400');
expect(scaledAmount({ ...chicken, amount: 50 }, f)).toBe('100');
});
it('supports downscaling and comma decimals', () => {
expect(ingredientFactor(chicken, '0,2', 'kg')).toBe('0.5');
expect(portionFactor(2, '1,5')).toBe('0.75');
});
it('normalizes kg, liters and spoons without conflating mass with volume', () => {
expect(convert(1.5, 'kg', 'g').toString()).toBe('1500');
expect(convert(250, 'ml', 'l').toString()).toBe('0.25');
expect(convert(2, 'EL', 'TL').toString()).toBe('6');
expect(() => convert(2, 'g', 'ml')).toThrow();
});
it('scales optional ingredients and leaves unquantified ingredients unchanged', () => {
expect(scaledAmount({ ...chicken, optional: true }, 2)).toBe('800');
expect(scaledAmount({ ...chicken, amount: null }, 2)).toBeNull();
expect(() => ingredientFactor({ ...chicken, amount: null }, 3)).toThrow();
});
it.each([0, -1, 'NaN', 'Infinity', '', 'abc', 1000000001])('rejects invalid amounts: %s', (v) => {
expect(() => positive(v)).toThrow();
});
it('prevents division by zero', () => {
expect(() => ingredientFactor({ ...chicken, amount: 0 }, 1)).toThrow();
expect(() => portionFactor(0, 4)).toThrow();
});
it('rejects extreme scale factors before they reach the interface', () => {
expect(() => ingredientFactor({ ...chicken, amount: 0.000001 }, 1000000)).toThrow();
expect(() => portionFactor(0.000001, 1000000)).toThrow();
});
it('keeps exact quantities despite repeated rescaling', () => {
for (let n = 1; n <= 100; n++) {
expect(scaledAmount(chicken, ingredientFactor(chicken, n))).toBe(String(n));
}
expect(scaledAmount(chicken, '1')).toBe('400');
expect(scaledAmount({ ...chicken, amount: 0.1 }, 3)).toBe('0.3');
});
it('uses readable fractions and final display rounding', () => {
expect(formatAmount(0.5)).toBe('½');
expect(formatAmount(1.25)).toBe('1¼');
expect(formatAmount(2.75)).toBe('2¾');
expect(formatAmount(10.6666)).toBe('10,67');
expect(formatAmount(null)).toBe('');
});
it('updates only structured references in instructions', () => {
expect(renderStep('{{pasta}} für 10 Minuten kochen.', pasta, '2')).toBe(
'400 g Pasta für 10 Minuten kochen.',
);
});
it('merges compatible shopping quantities across recipes', () => {
const list = mergeShopping([], [chicken], '2');
const result = mergeShopping(list, [{ ...chicken, amount: 0.5, unit: 'kg' }], '1');
expect(result).toHaveLength(1);
expect(result[0].amount).toBe('1300');
expect(list[0].amount).toBe('800');
});
it('keeps incompatible shopping units and notes separate', () => {
const a = mergeShopping([], [chicken], '1');
const b = mergeShopping(
a,
[
{ ...chicken, unit: 'Stück' },
{ ...chicken, note: 'gekocht' },
],
'1',
);
expect(b).toHaveLength(3);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { startTimer, pauseTimer, remainingMs } from '../../src/lib/cooking';
describe('Timestamp-based kitchen timers', () => {
it('does not display an extra second when the last render predates the start', () => {
expect(remainingMs(startTimer({ remaining: 60000, endAt: null }, 1200), 1000)).toBe(60000);
});
it('starts with an absolute deadline, not ticking state', () => {
expect(startTimer({ remaining: 60000, endAt: null }, 1000).endAt).toBe(61000);
});
it('survives navigation, sleep and serialized progress', () => {
const timer = JSON.parse(JSON.stringify(startTimer({ remaining: 60000, endAt: null }, 1000)));
expect(remainingMs(timer, 41000)).toBe(20000);
expect(remainingMs(timer, 90000)).toBe(0);
});
it('pauses precisely and resumes without drift', () => {
const paused = pauseTimer(startTimer({ remaining: 60000, endAt: null }, 1000), 21000);
expect(remainingMs(paused, 90000)).toBe(40000);
expect(remainingMs(startTimer(paused, 90000), 100000)).toBe(30000);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { parseImport, exportRecipes } from '../../src/lib/import';
import { recipeSchema } from '../../src/lib/recipe';
import { examples, pasta } from '../fixtures';
describe('Versioned, safe, lossless recipe interchange', () => {
it.each(['json', 'xml'] as const)('roundtrips all fields in %s', (format) => {
expect(parseImport(exportRecipes(examples, format), format)).toEqual({
version: 1,
recipes: examples,
});
});
it('preserves XML escaped punctuation, unicode and literal ampersands', () => {
const r = {
...pasta,
title: 'Öl & Gemüse',
tips: 'Mit "Liebe" & Geduld. 1 < 2. &amp; bleibt Text.',
};
expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r);
});
it('rejects unknown versions and missing mandatory fields', () => {
expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow();
expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow();
});
it('rejects HTML payloads, invalid image paths and broken references', () => {
expect(() => recipeSchema.parse({ ...pasta, title: '<script>alert(1)</script>' })).toThrow();
expect(() => recipeSchema.parse({ ...pasta, image: '/media/../../etc/passwd' })).toThrow();
expect(() =>
recipeSchema.parse({ ...pasta, steps: [{ ...pasta.steps[0], text: '{{unknown}}' }] }),
).toThrow();
});
it('rejects duplicate ingredient, step and timer IDs', () => {
expect(() =>
recipeSchema.parse({ ...pasta, ingredients: [...pasta.ingredients, pasta.ingredients[0]] }),
).toThrow();
expect(() =>
recipeSchema.parse({ ...pasta, steps: [pasta.steps[1], pasta.steps[1]] }),
).toThrow();
});
it.each([
'<!DOCTYPE x [<!ENTITY a SYSTEM "file:///etc/passwd">]><x>&a;</x>',
'<!DOCTYPE x [<!ENTITY a "&a;&a;">]><x>&a;</x>',
'<hamham><broken></hamham>',
])('rejects unsafe or malformed XML', (raw) => {
expect(() => parseImport(raw, 'xml')).toThrow();
});
it('enforces byte limits before parsing', () => {
expect(() => parseImport('x'.repeat(2 * 1024 * 1024 + 1), 'json')).toThrow(/2 MB/);
});
});