360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
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);
|
||
});
|
||
});
|