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

+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/);
});
});