feat(core): add secure recipe APIs, precise scaling and versioned imports
This commit is contained in:
commit
5d71d32e20
31 files changed
+6293
No files matched your search
@@ -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];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. & 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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user