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 { 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); }); });