Files
food-tracer/server/src/tests/luncherImport.test.ts
T

360 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ExcelJS from 'exceljs';
import { importLuncherExport } from "../luncherImport";
import { addMeal, getDay } from "../meals";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
/** Hlavička přehledu tak, jak ji generuje Luncher (server/src/userExport.ts). */
const HEADERS = ['Datum', 'Den', 'Typ', 'Vybrané jídlo', 'Poznámka', 'Objednávka', 'Objednával', 'Částka'];
/** Řádky používané napříč testy — pokrývají volbu podniku, pizzu i objednávku. */
const ROWS = [
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'Sladovnická', food: 'Svíčková na smetaně', note: '', store: '', orderedBy: '', amount: 159 },
{ date: '2025-03-04', dayOfWeek: 'úterý', type: 'Pizza day', food: 'Margherita (32cm)', note: 'bez oregana', store: '', orderedBy: '', amount: 215.5 },
{ date: '2025-03-05', dayOfWeek: 'středa', type: 'Budu objednávat', food: 'Kuřecí burger', note: '', store: 'Bolt Food', orderedBy: 'novak', amount: 249 },
];
/** Sestaví CSV přesně tak, jak ho Luncher exportuje — BOM, ';' a desetinná čárka. */
function buildLuncherCsv(): Buffer {
const escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
const lines = [HEADERS.map(escape).join(';')];
for (const row of ROWS) {
const [y, m, d] = row.date.split('-');
lines.push([
escape(`${d}.${m}.${y}`),
escape(row.dayOfWeek),
escape(row.type),
escape(row.food),
escape(row.note),
escape(row.store),
escape(row.orderedBy),
escape(row.amount.toFixed(2).replace('.', ',')),
].join(';'));
}
return Buffer.from(`${lines.join('\r\n')}\r\n`, 'utf-8');
}
/** Sestaví JSON přehled ve schématu UserExport. */
function buildLuncherJson(): Buffer {
return Buffer.from(JSON.stringify({
login: 'tester',
year: 2025,
month: 3,
rowCount: ROWS.length,
totalAmount: ROWS.reduce((sum, row) => sum + row.amount, 0),
rows: ROWS.map(row => ({
date: row.date,
dayOfWeek: row.dayOfWeek,
type: row.type,
...(row.food ? { food: row.food } : {}),
...(row.note ? { note: row.note } : {}),
...(row.store ? { store: row.store } : {}),
...(row.orderedBy ? { orderedBy: row.orderedBy } : {}),
amount: row.amount,
})),
}), 'utf-8');
}
/** Sestaví XLSX se dvěma listy — detailem a souhrnem — jako skutečný export. */
async function buildLuncherXlsx(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Přehled');
sheet.addRow(HEADERS);
for (const row of ROWS) {
const [y, m, d] = row.date.split('-').map(Number);
sheet.addRow([
new Date(Date.UTC(y, m - 1, d)),
row.dayOfWeek,
row.type,
row.food,
row.note,
row.store,
row.orderedBy,
row.amount,
]);
}
// Souhrnný list má vlastní sloupce a import ho musí ignorovat
const summary = workbook.addWorksheet('Souhrn');
summary.addRow(['Typ', 'Počet záznamů', 'Částka celkem']);
summary.addRow(['Celkem', ROWS.length, ROWS.reduce((sum, row) => sum + row.amount, 0)]);
return Buffer.from(await workbook.xlsx.writeBuffer());
}
beforeEach(() => resetMemoryStorage());
describe('import přehledu z Luncheru', () => {
it.each([
['CSV', 'luncher-tester-2025-03.csv', () => buildLuncherCsv()],
['JSON', 'luncher-tester-2025-03.json', () => buildLuncherJson()],
['XLSX', 'luncher-tester-2025-03.xlsx', () => buildLuncherXlsx()],
])('%s naimportuje všechny řádky do jejich dnů', async (_label, fileName, build) => {
const result = await importLuncherExport(LOGIN, fileName, await build());
expect(result.warnings).toEqual([]);
expect(result.rowCount).toBe(3);
expect(result.imported).toBe(3);
expect(result.skipped).toBe(0);
expect(result.days).toEqual(['2025-03-03', '2025-03-04', '2025-03-05']);
// 159 + 215,50 + 249 Kč v haléřích
expect(result.totalPrice).toBe(62350);
});
it('založí importované řádky jako oběd, pokud se neuvede jinak', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const day = await getDay(LOGIN, '2025-03-03');
expect(day.entries).toHaveLength(1);
expect(day.entries[0].mealType).toBe(MealType.OBED);
});
it('respektuje zvolený typ jídla', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), MealType.VECERE);
const day = await getDay(LOGIN, '2025-03-03');
expect(day.entries[0].mealType).toBe(MealType.VECERE);
});
it('mapuje jídlo, zdroj i cenu na záznam', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const [entry] = (await getDay(LOGIN, '2025-03-03')).entries;
expect(entry.name).toBe('Svíčková na smetaně');
expect(entry.source).toBe('Sladovnická');
expect(entry.price).toBe(15900);
expect(entry.importSource).toBe('luncher');
});
it('u objednávky bere jako zdroj obchod a objednávajícího přidá do poznámky', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const [entry] = (await getDay(LOGIN, '2025-03-05')).entries;
expect(entry.name).toBe('Kuřecí burger');
expect(entry.source).toBe('Bolt Food');
expect(entry.note).toBe('objednal(a): novak');
});
it('u volby bez jídla i poznámky použije jako název podnik', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'TechTower', amount: 145 }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.name).toBe('TechTower');
expect(entry.source).toBe('TechTower');
});
it('u "Budu objednávat" bez jídla vezme jako název poznámku', async () => {
// Skutečný tvar řádku z exportu: jídlo prázdné, obsah stojí v poznámce
const json = Buffer.from(JSON.stringify({
rows: [{
date: '2025-03-06', dayOfWeek: 'čtvrtek',
type: 'Budu objednávat', note: 'Chefie - Těstovinový salát',
}],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.name).toBe('Chefie - Těstovinový salát');
// Poznámka se stala názvem, nemá se opakovat
expect(entry.note).toBeUndefined();
// "Budu objednávat" není podnik, jako zdroj nedává smysl
expect(entry.source).toBeUndefined();
});
it('u "Rozhoduji se" vezme jako název poznámku a nenastaví zdroj', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-07', dayOfWeek: 'pátek', type: 'Rozhoduji se', note: 'asi pudu tower' }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-07')).entries;
expect(entry.name).toBe('asi pudu tower');
expect(entry.source).toBeUndefined();
});
it('když je vyplněné jídlo i poznámka, poznámka zůstane poznámkou', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{
date: '2025-03-08', dayOfWeek: 'sobota', type: 'Budu objednávat',
food: 'Chicken tikka massala', note: 'EVEREST',
store: 'Everest', orderedBy: 'Anděl Ondřej', amount: 215.54,
}],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-08')).entries;
expect(entry.name).toBe('Chicken tikka massala');
expect(entry.note).toBe('EVEREST | objednal(a): Anděl Ondřej');
expect(entry.source).toBe('Everest');
expect(entry.price).toBe(21554);
});
it('řádek bez částky uloží bez ceny', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'SPŠE', food: 'Guláš' }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.price).toBeUndefined();
expect((await getDay(LOGIN, '2025-03-06')).totalPrice).toBe(0);
});
it('opakovaný import stejného měsíce data nezduplikuje', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const second = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
expect(second.imported).toBe(0);
expect(second.skipped).toBe(3);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(1);
});
it('naimportuje i dva shodné záznamy v jednom dni', async () => {
const row = { date: '2025-03-07', dayOfWeek: 'pátek', type: 'Objednávka', food: 'Kebab', amount: 120 };
const json = Buffer.from(JSON.stringify({ rows: [row, row] }), 'utf-8');
const first = await importLuncherExport(LOGIN, 'export.json', json);
expect(first.imported).toBe(2);
// Ani při opakování se nesmí jeden z nich považovat za duplicitu toho druhého
const second = await importLuncherExport(LOGIN, 'export.json', json);
expect(second.imported).toBe(0);
expect(second.skipped).toBe(2);
expect((await getDay(LOGIN, '2025-03-07')).entries).toHaveLength(2);
});
it('dryRun vrátí náhled, ale nic neuloží', async () => {
const result = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), undefined, true);
expect(result.dryRun).toBe(true);
expect(result.imported).toBe(3);
expect(result.entries).toHaveLength(3);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(0);
});
it('poradí si s poznámkou obsahující oddělovač a uvozovky', async () => {
const csv = Buffer.from(
`"Datum";"Typ";"Vybrané jídlo";"Poznámka";"Částka"\r\n`
+ `"03.03.2025";"Sladovnická";"Svíčková";"bez knedlíku; navíc ""extra"" omáčka";"159,00"\r\n`,
'utf-8');
await importLuncherExport(LOGIN, 'export.csv', csv);
const [entry] = (await getDay(LOGIN, '2025-03-03')).entries;
expect(entry.note).toBe('bez knedlíku; navíc "extra" omáčka');
});
it('upozorní na řádek s nečitelným datem a ostatní naimportuje', async () => {
const json = Buffer.from(JSON.stringify({
rows: [
{ date: 'nesmysl', type: 'Sladovnická', food: 'Svíčková' },
{ date: '2025-03-03', type: 'Sladovnická', food: 'Guláš' },
],
}), 'utf-8');
const result = await importLuncherExport(LOGIN, 'export.json', json);
expect(result.imported).toBe(1);
expect(result.warnings).toHaveLength(1);
});
it('odmítne nepodporovaný formát souboru', async () => {
await expect(importLuncherExport(LOGIN, 'export.pdf', Buffer.from('cokoliv')))
.rejects.toThrow(/Nepodporovaný formát/);
});
it('odmítne soubor bez očekávaných sloupců', async () => {
const csv = Buffer.from('"Sloupec A";"Sloupec B"\r\n"1";"2"\r\n', 'utf-8');
await expect(importLuncherExport(LOGIN, 'export.csv', csv))
.rejects.toThrow(/nepodařilo najít očekávané sloupce/);
});
});
describe('doplnění energie a živin z historie při importu', () => {
const svickova = {
date: '2025-02-10', mealType: MealType.OBED, name: 'Svíčková na smetaně', source: 'Sladovnická',
weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8, fat: 9.5 },
};
/** Naimportovaný záznam daného dne. */
const importedOn = async (date: string) => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
return (await getDay(LOGIN, date)).entries.find(entry => entry.importSource === 'luncher')!;
};
it('převezme energii, živiny i gramáž jídla stejného názvu', async () => {
await addMeal(LOGIN, svickova);
const entry = await importedOn('2025-03-03');
expect(entry).toMatchObject({
weight: 400, caloriesPer100g: 150, calories: 600, nutrientsPer100g: { protein: 8, fat: 9.5 },
});
// Cena zůstane z importu
expect(entry.price).toBe(15900);
});
it('u stejného zdroje převezme sazbu za 100 g a gramáž dopočte z nové ceny', async () => {
await addMeal(LOGIN, {
date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Bolt Food',
price: 20000, pricePer100g: 5000, caloriesPer100g: 250,
});
const entry = await importedOn('2025-03-05');
// 249 Kč za 50 Kč/100 g = 498 g, × 250 kcal/100 g
expect(entry).toMatchObject({ pricePer100g: 5000, weight: 498, calories: 1245 });
});
it('z jiného zdroje sazbu nepřebírá a použije gramáž porce', async () => {
await addMeal(LOGIN, {
date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Jinde',
price: 20000, pricePer100g: 5000, caloriesPer100g: 250,
});
const entry = await importedOn('2025-03-05');
expect(entry.pricePer100g).toBeUndefined();
expect(entry).toMatchObject({ weight: 400, calories: 1000 });
});
it('má přednost jídlo ze stejného zdroje, i když je starší', async () => {
await addMeal(LOGIN, svickova);
await addMeal(LOGIN, { ...svickova, date: '2025-02-20', source: 'Jinde', caloriesPer100g: 200 });
expect((await importedOn('2025-03-03')).caloriesPer100g).toBe(150);
});
it('jídlo stejného názvu bez kalorií vzorem není, vezme se starší s kaloriemi', async () => {
await addMeal(LOGIN, svickova);
await addMeal(LOGIN, { date: '2025-02-20', mealType: MealType.OBED, name: 'Svíčková na smetaně', price: 15900 });
expect((await importedOn('2025-03-03')).calories).toBe(600);
});
it('bez historie zůstane jídlo bez kalorií', async () => {
const entry = await importedOn('2025-03-03');
expect(entry.calories).toBeUndefined();
expect(entry).not.toHaveProperty('weight');
});
it('doplněné hodnoty ukáže už náhled', async () => {
await addMeal(LOGIN, svickova);
const result = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), undefined, true);
expect(result.entries.find(entry => entry.date === '2025-03-03')?.calories).toBe(600);
});
});