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,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