feat: založení základní stránky příjem/výdej + import a statistiky

This commit is contained in:
Ondřej Anděl committed 2026-09-07 14:58:49 +02:00
commit a5711f3e1e
98 files changed
+15505

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
process.env.CALORIE_PROVIDER = 'none';
import {
addActivity, deleteActivity, deriveActivityCalories, getActivityDay, updateActivity,
} from "../activities";
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
import { buildEnergyBalance, getDayOverview } from "../dayOverview";
import { addMeal } from "../meals";
import { saveBasalCalories } from "../settings";
import { resetMemoryStorage } from "../storage/memory";
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
const DATE = '2025-03-03';
/** Přibližná sazba chůze: 4 kcal na 100 kroků, tedy 400 kcal za 10 000 kroků. */
const STEP_RATE = 4;
function activity(overrides: Record<string, unknown> = {}) {
return {
date: DATE,
name: 'Chůze',
unit: ActivityUnit.KROKY,
quantity: 10000,
caloriesPer100Units: STEP_RATE,
...overrides,
};
}
beforeEach(() => resetMemoryStorage());
describe('evidence pohybu', () => {
it('dopočte spálené kalorie z počtu kroků', () => {
const result = deriveActivityCalories({
date: DATE, name: 'Chůze', unit: ActivityUnit.KROKY,
quantity: 10000, caloriesPer100Units: STEP_RATE,
});
expect(result.calories).toBe(400);
});
it('bez sazby použije ručně zadané kalorie', () => {
const result = deriveActivityCalories({
date: DATE, name: 'Plavání', unit: ActivityUnit.MINUTY,
quantity: 30, calories: 250,
});
expect(result.calories).toBe(250);
});
it('přidá aktivitu a spočítá součet dne', async () => {
await addActivity(LOGIN, activity());
await addActivity(LOGIN, activity({
name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50,
}));
const day = await getActivityDay(LOGIN, DATE);
expect(day.entries).toHaveLength(2);
expect(day.totalCalories).toBe(430);
});
it('upraví aktivitu a přepočítá kalorie', async () => {
await addActivity(LOGIN, activity());
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await updateActivity(LOGIN, entry.id, activity({ quantity: 5000 }));
const day = await getActivityDay(LOGIN, DATE);
expect(day.entries[0].quantity).toBe(5000);
expect(day.totalCalories).toBe(200);
});
it('při změně data přesune aktivitu do jiného dne', async () => {
await addActivity(LOGIN, activity());
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await updateActivity(LOGIN, entry.id, activity({ date: '2025-03-10' }));
expect((await getActivityDay(LOGIN, DATE)).entries).toHaveLength(0);
expect((await getActivityDay(LOGIN, '2025-03-10')).entries).toHaveLength(1);
});
it('smaže aktivitu', async () => {
await addActivity(LOGIN, activity());
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await deleteActivity(LOGIN, entry.id, DATE);
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(0);
});
it('odmítne neplatný vstup', async () => {
await expect(addActivity(LOGIN, activity({ name: ' ' }))).rejects.toThrow(/název aktivity/);
await expect(addActivity(LOGIN, activity({ unit: 'SKOKY' }))).rejects.toThrow(/jednotka/);
await expect(addActivity(LOGIN, activity({ quantity: undefined }))).rejects.toThrow(/množství/);
await expect(addActivity(LOGIN, activity({ quantity: -5 }))).rejects.toThrow(/záporné/);
});
it('pohyb je oddělený mezi uživateli', async () => {
await addActivity(LOGIN, activity());
expect((await getActivityDay('nekdo-jiny', DATE)).entries).toHaveLength(0);
});
});
describe('šablony tréninků', () => {
const items = [
{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50 },
{ name: 'Běh', unit: ActivityUnit.MINUTY, quantity: 20, caloriesPer100Units: 1000 },
];
it('uloží šablonu a spočítá odhad kalorií', async () => {
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('Workout day 1');
// 60 kliků × 0,5 kcal + 20 minut × 10 kcal
expect(templates[0].estimatedCalories).toBe(230);
});
it('úprava podle id šablonu přepíše, nezaloží druhou', async () => {
const [created] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
const templates = await saveWorkoutTemplate(LOGIN, created.id, 'Workout day 1 (těžší)', items);
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('Workout day 1 (těžší)');
});
it('použití šablony založí do dne všechny její položky', async () => {
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
await applyWorkoutTemplate(LOGIN, template.id, DATE);
const day = await getActivityDay(LOGIN, DATE);
expect(day.entries.map(entry => entry.name)).toEqual(['Kliky', 'Běh']);
expect(day.totalCalories).toBe(230);
expect(day.entries[0].templateId).toBe(template.id);
});
it('položky ze šablony jsou samostatné — úprava neovlivní šablonu', async () => {
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
await applyWorkoutTemplate(LOGIN, template.id, DATE);
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await updateActivity(LOGIN, entry.id, activity({
name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 100, caloriesPer100Units: 50,
}));
expect((await getWorkoutTemplates(LOGIN))[0].items[0].quantity).toBe(60);
});
it('smaže šablonu', async () => {
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
expect(await deleteWorkoutTemplate(LOGIN, template.id)).toEqual([]);
});
it('odmítne šablonu bez názvu nebo bez položek', async () => {
await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název šablony/);
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Prázdná', [])).rejects.toThrow(/alespoň jednu položku/);
});
it('hlásí chybu u neexistující šablony', async () => {
await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
});
it('šablony jsou oddělené mezi uživateli', async () => {
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
});
});
describe('energetická bilance dne', () => {
it('bez klidového výdeje porovná jen jídlo proti pohybu', () => {
const energy = buildEnergyBalance(2000, 400);
expect(energy.totalBurn).toBe(400);
expect(energy.balance).toBe(1600);
expect(energy.hasBasal).toBe(false);
});
it('s klidovým výdejem spočítá skutečný deficit', () => {
const energy = buildEnergyBalance(1800, 400, 1600);
expect(energy.totalBurn).toBe(2000);
expect(energy.balance).toBe(-200);
expect(energy.hasBasal).toBe(true);
});
it('přebytek vyjde kladně', () => {
const energy = buildEnergyBalance(2500, 200, 1600);
expect(energy.balance).toBe(700);
});
it('přehled dne spojí jídlo, pohyb i bilanci', async () => {
await saveBasalCalories(LOGIN, 1600);
await addMeal(LOGIN, {
date: DATE, mealType: MealType.OBED, name: 'Gyros',
weight: 400, caloriesPer100g: 172, price: 17600,
});
await addActivity(LOGIN, activity());
const overview = await getDayOverview(LOGIN, DATE);
expect(overview.meals.totalCalories).toBe(688);
expect(overview.activities.totalCalories).toBe(400);
expect(overview.energy.intake).toBe(688);
expect(overview.energy.totalBurn).toBe(2000);
expect(overview.energy.balance).toBe(-1312);
expect(overview.energy.hasBasal).toBe(true);
});
it('prázdný den vrátí nuly', async () => {
const overview = await getDayOverview(LOGIN, DATE);
expect(overview.energy.intake).toBe(0);
expect(overview.energy.balance).toBe(0);
expect(overview.energy.hasBasal).toBe(false);
});
it('klidový výdej jde uložit i odstranit', async () => {
expect((await saveBasalCalories(LOGIN, 1600)).basalCalories).toBe(1600);
expect((await saveBasalCalories(LOGIN, 0)).basalCalories).toBeUndefined();
await expect(saveBasalCalories(LOGIN, 99999)).rejects.toThrow(/mimo rozsah/);
});
});
+200
View File
@@ -0,0 +1,200 @@
// Externí poskytovatel se v testech nevolá — ověřujeme vlastní logiku, ne cizí službu
process.env.CALORIE_PROVIDER = 'none';
import { addMeal, deriveAmounts, getDay, updateMeal } from "../meals";
import { searchCalories } from "../calories";
import { getSettings, getSourceRate, saveSourceRate } from "../settings";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
/** Sazba TechTower: 44 Kč za 100 g, v haléřích. */
const TOWER_RATE = 4400;
function meal(overrides: Record<string, unknown> = {}) {
return { date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros', ...overrides };
}
beforeEach(() => resetMemoryStorage());
describe('dopočet gramáže a kalorií', () => {
it('dopočte gramáž z ceny a sazby za 100 g', () => {
// 176 Kč při 44 Kč/100 g = 400 g
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, pricePer100g: TOWER_RATE,
});
expect(result.weight).toBe(400);
});
it('ručně zadaná gramáž má přednost před dopočtem z ceny', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, pricePer100g: TOWER_RATE, weight: 350,
});
expect(result.weight).toBe(350);
});
it('dopočte kalorie z gramáže a energie na 100 g', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
weight: 400, caloriesPer100g: 172,
});
expect(result.calories).toBe(688);
});
it('spojí oba dopočty — z ceny na gramáž a z gramáže na kalorie', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, pricePer100g: TOWER_RATE, caloriesPer100g: 172,
});
expect(result.weight).toBe(400);
expect(result.calories).toBe(688);
});
it('bez sazby nechá gramáž i kalorie na ručně zadaných hodnotách', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, calories: 500,
});
expect(result.weight).toBeUndefined();
expect(result.calories).toBe(500);
});
it('různé položky téhož dne mohou mít různou sazbu', async () => {
// Hlavní jídlo za 44 Kč/100 g a salát s vlastním cenováním
await addMeal(LOGIN, meal({ name: 'Gyros', price: 17600, pricePer100g: TOWER_RATE, caloriesPer100g: 172 }));
const day = await addMeal(LOGIN, meal({ name: 'Salát', price: 6000, pricePer100g: 3000, caloriesPer100g: 40 }));
const gyros = day.entries.find(e => e.name === 'Gyros')!;
const salat = day.entries.find(e => e.name === 'Salát')!;
expect(gyros.weight).toBe(400);
expect(gyros.calories).toBe(688);
expect(salat.weight).toBe(200);
expect(salat.calories).toBe(80);
expect(day.totalCalories).toBe(768);
expect(day.totalPrice).toBe(23600);
});
it('úprava umí gramáž i sazbu přepsat a vymazat', async () => {
const created = await addMeal(LOGIN, meal({ price: 17600, pricePer100g: TOWER_RATE, caloriesPer100g: 172 }));
const id = created.entries[0].id;
const day = await updateMeal(LOGIN, id, meal({ price: 17600, weight: 500, caloriesPer100g: 172 }));
expect(day.entries[0].weight).toBe(500);
expect(day.entries[0].pricePer100g).toBeUndefined();
expect(day.entries[0].calories).toBe(860);
});
});
describe('knihovna energetických hodnot', () => {
it('zapamatuje si energii na 100 g a příště ji nabídne', async () => {
await addMeal(LOGIN, meal({ name: 'Kuřecí gyros', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories(LOGIN, 'kuřecí gyros');
expect(result.suggestions).toEqual([
{ name: 'Kuřecí gyros', caloriesPer100g: 172, origin: 'library' },
]);
});
it('najde i při odlišné diakritice a velikosti písmen', async () => {
await addMeal(LOGIN, meal({ name: 'Kuřecí gyros', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories(LOGIN, 'KURECI GYROS');
expect(result.suggestions[0].caloriesPer100g).toBe(172);
});
it('nabídne i částečnou shodu názvu', async () => {
await addMeal(LOGIN, meal({ name: 'Kuřecí gyros s hranolkami', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories(LOGIN, 'gyros');
expect(result.suggestions[0].name).toBe('Kuřecí gyros s hranolkami');
});
it('jídlo bez energie na 100 g si do knihovny neuloží', async () => {
await addMeal(LOGIN, meal({ name: 'Něco', calories: 500 }));
const result = await searchCalories(LOGIN, 'Něco');
expect(result.suggestions).toEqual([]);
});
it('novější zadání přepíše dřívější hodnotu', async () => {
await addMeal(LOGIN, meal({ name: 'Gyros', caloriesPer100g: 172, weight: 300 }));
await addMeal(LOGIN, meal({ name: 'Gyros', caloriesPer100g: 180, weight: 300 }));
const result = await searchCalories(LOGIN, 'Gyros');
expect(result.suggestions[0].caloriesPer100g).toBe(180);
});
it('knihovna je oddělená mezi uživateli', async () => {
await addMeal(LOGIN, meal({ name: 'Gyros', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories('nekdo-jiny', 'Gyros');
expect(result.suggestions).toEqual([]);
});
it('vrátí odkaz na tabulku potravin na KalorickéTabulky.cz', async () => {
const result = await searchCalories(LOGIN, 'kuřecí gyros');
// Předvyplnit hledání nejde — jejich vyhledávání je v JS a URL parametry ignoruje
expect(result.externalSearchUrl).toBe('https://www.kaloricketabulky.cz/tabulka-potravin');
});
it('odmítne prázdný dotaz', async () => {
await expect(searchCalories(LOGIN, ' ')).rejects.toThrow(/hledaný název/);
});
});
describe('sazby za 100 g u zdrojů', () => {
it('uloží a vrátí sazbu podniku', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
expect(await getSourceRate(LOGIN, 'TechTower')).toBe(TOWER_RATE);
expect((await getSettings(LOGIN)).sourceRates).toEqual([
{ source: 'TechTower', pricePer100g: TOWER_RATE },
]);
});
it('najde sazbu bez ohledu na velikost písmen', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
expect(await getSourceRate(LOGIN, 'techtower')).toBe(TOWER_RATE);
});
it('opakované uložení sazbu přepíše, nezaloží druhou', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
const settings = await saveSourceRate(LOGIN, 'TechTower', 4800);
expect(settings.sourceRates).toHaveLength(1);
expect(settings.sourceRates[0].pricePer100g).toBe(4800);
});
it('nulová sazba ji odstraní', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
const settings = await saveSourceRate(LOGIN, 'TechTower', 0);
expect(settings.sourceRates).toEqual([]);
});
it('odmítne prázdný zdroj a nesmyslnou sazbu', async () => {
await expect(saveSourceRate(LOGIN, '', TOWER_RATE)).rejects.toThrow(/název zdroje/);
await expect(saveSourceRate(LOGIN, 'TechTower', -1)).rejects.toThrow(/mimo rozsah/);
});
it('sazby jsou oddělené mezi uživateli', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
expect(await getSourceRate('nekdo-jiny', 'TechTower')).toBeUndefined();
});
});
+280
View File
@@ -0,0 +1,280 @@
import ExcelJS from 'exceljs';
import { importLuncherExport } from "../luncherImport";
import { 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/);
});
});
+176
View File
@@ -0,0 +1,176 @@
import { addMeal, deleteMeal, getDay, getRange, updateMeal } from "../meals";
import { getFoodStats } from "../statsService";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
/** Zkratka pro založení jídla s rozumnými výchozími hodnotami. */
function meal(overrides: Record<string, unknown> = {}) {
return {
date: '2025-03-03',
mealType: MealType.OBED,
name: 'Svíčková',
price: 15900,
...overrides,
};
}
beforeEach(() => resetMemoryStorage());
describe('evidence jídel', () => {
it('přidá jídlo a spočítá součty dne', async () => {
await addMeal(LOGIN, meal({ name: 'Rohlík', mealType: MealType.SNIDANE, price: 500, calories: 130 }));
const day = await addMeal(LOGIN, meal({ price: 15900, calories: 850 }));
expect(day.entries).toHaveLength(2);
expect(day.totalPrice).toBe(16400);
expect(day.totalCalories).toBe(980);
});
it('řadí jídla dne dle chodu, ne dle pořadí zadání', async () => {
await addMeal(LOGIN, meal({ name: 'Večeře', mealType: MealType.VECERE }));
await addMeal(LOGIN, meal({ name: 'Snídaně', mealType: MealType.SNIDANE }));
const day = await addMeal(LOGIN, meal({ name: 'Oběd', mealType: MealType.OBED }));
expect(day.entries.map(entry => entry.name)).toEqual(['Snídaně', 'Oběd', 'Večeře']);
});
it('do součtu ceny nezapočítává jídla bez ceny', async () => {
await addMeal(LOGIN, meal({ price: 15900 }));
const day = await addMeal(LOGIN, meal({ name: 'Jablko', price: undefined }));
expect(day.entries).toHaveLength(2);
expect(day.totalPrice).toBe(15900);
});
it('upraví existující jídlo', async () => {
const created = await addMeal(LOGIN, meal());
const id = created.entries[0].id;
const day = await updateMeal(LOGIN, id, meal({ name: 'Guláš', price: 12000 }));
expect(day.entries).toHaveLength(1);
expect(day.entries[0].name).toBe('Guláš');
expect(day.entries[0].price).toBe(12000);
expect(day.entries[0].updatedAt).toBeDefined();
});
it('umí u jídla vymazat cenu i poznámku', async () => {
const created = await addMeal(LOGIN, meal({ price: 15900, note: 'dobré' }));
const day = await updateMeal(LOGIN, created.entries[0].id, meal({ price: undefined, note: undefined }));
expect(day.entries[0].price).toBeUndefined();
expect(day.entries[0].note).toBeUndefined();
expect(day.totalPrice).toBe(0);
});
it('při změně data přesune jídlo do jiného dne', async () => {
const created = await addMeal(LOGIN, meal());
const day = await updateMeal(LOGIN, created.entries[0].id, meal({ date: '2025-03-10' }));
expect(day.date).toBe('2025-03-10');
expect(day.entries).toHaveLength(1);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(0);
});
it('smaže jídlo', async () => {
const created = await addMeal(LOGIN, meal());
const day = await deleteMeal(LOGIN, created.entries[0].id, '2025-03-03');
expect(day.entries).toHaveLength(0);
expect(day.totalPrice).toBe(0);
});
it('hlásí chybu u neexistujícího záznamu', async () => {
await expect(deleteMeal(LOGIN, 'neexistuje', '2025-03-03')).rejects.toThrow(/nebyl nalezen/);
await expect(updateMeal(LOGIN, 'neexistuje', meal())).rejects.toThrow(/nebyl nalezen/);
});
it('odmítne jídlo bez názvu nebo s neplatným datem', async () => {
await expect(addMeal(LOGIN, meal({ name: ' ' }))).rejects.toThrow(/název jídla/);
await expect(addMeal(LOGIN, meal({ date: '3.3.2025' }))).rejects.toThrow(/platné datum/);
await expect(addMeal(LOGIN, meal({ date: '2025-02-31' }))).rejects.toThrow(/platné datum/);
await expect(addMeal(LOGIN, meal({ mealType: 'BRUNCH' }))).rejects.toThrow(/typ jídla/);
await expect(addMeal(LOGIN, meal({ price: -5 }))).rejects.toThrow(/záporné/);
});
it('data jednoho uživatele nevidí druhý', async () => {
await addMeal(LOGIN, meal());
expect((await getDay('nekdo-jiny', '2025-03-03')).entries).toHaveLength(0);
});
it('vrátí jen dny s alespoň jedním záznamem', async () => {
await addMeal(LOGIN, meal({ date: '2025-03-03' }));
await addMeal(LOGIN, meal({ date: '2025-03-07' }));
const days = await getRange(LOGIN, '2025-03-01', '2025-03-31');
expect(days.map(day => day.date)).toEqual(['2025-03-03', '2025-03-07']);
});
});
describe('statistiky', () => {
beforeEach(async () => {
await addMeal(LOGIN, meal({ date: '2025-03-03', name: 'Svíčková', source: 'Sladovnická', price: 15900, calories: 850 }));
await addMeal(LOGIN, meal({ date: '2025-03-03', name: 'Rohlík', mealType: MealType.SNIDANE, source: 'Doma', price: 500 }));
await addMeal(LOGIN, meal({ date: '2025-04-10', name: 'Guláš', source: 'Sladovnická', price: 14000, calories: 700 }));
await addMeal(LOGIN, meal({ date: '2024-12-24', name: 'Kapr', source: 'Doma', price: 30000 }));
});
it('spočítá celkovou útratu a kalorie za období', async () => {
const stats = await getFoodStats(LOGIN, '2025-01-01', '2025-12-31');
expect(stats.totalPrice).toBe(30400);
expect(stats.totalCalories).toBe(1550);
expect(stats.entryCount).toBe(3);
expect(stats.dayCount).toBe(2);
});
it('rozpadne útratu po dnech, měsících a letech', async () => {
const stats = await getFoodStats(LOGIN, '2024-01-01', '2025-12-31');
expect(stats.byDay.map(total => total.period)).toEqual(['2024-12-24', '2025-03-03', '2025-04-10']);
expect(stats.byMonth).toEqual([
{ period: '2024-12', price: 30000, calories: 0, count: 1 },
{ period: '2025-03', price: 16400, calories: 850, count: 2 },
{ period: '2025-04', price: 14000, calories: 700, count: 1 },
]);
expect(stats.byYear).toEqual([
{ period: '2024', price: 30000, calories: 0, count: 1 },
{ period: '2025', price: 30400, calories: 1550, count: 3 },
]);
});
it('rozpadne útratu dle typu jídla a zdroje, sestupně', async () => {
const stats = await getFoodStats(LOGIN, '2025-01-01', '2025-12-31');
expect(stats.byMealType).toEqual([
{ key: MealType.OBED, price: 29900, calories: 1550, count: 2 },
{ key: MealType.SNIDANE, price: 500, calories: 0, count: 1 },
]);
expect(stats.bySource[0]).toEqual({ key: 'Sladovnická', price: 29900, calories: 1550, count: 2 });
});
it('období bez záznamů vrátí nuly', async () => {
const stats = await getFoodStats(LOGIN, '2023-01-01', '2023-12-31');
expect(stats.totalPrice).toBe(0);
expect(stats.entryCount).toBe(0);
expect(stats.byDay).toEqual([]);
});
it('jídla bez zdroje spadnou do skupiny Neuvedeno', async () => {
await addMeal(LOGIN, meal({ date: '2025-05-01', name: 'Něco', source: undefined, price: 100 }));
const stats = await getFoodStats(LOGIN, '2025-05-01', '2025-05-31');
expect(stats.bySource).toEqual([{ key: 'Neuvedeno', price: 100, calories: 0, count: 1 }]);
});
});
+4
View File
@@ -0,0 +1,4 @@
// Testy běží vždy nad in-memory úložištěm a s pevným klíčem, ať nezávisí na .env
process.env.STORAGE = 'memory';
process.env.JWT_SECRET = 'testovaci-klic-ktery-ma-aspon-32-znaku-delky';
process.env.NODE_ENV = 'test';