feat: založení základní stránky příjem/výdej + import a statistiky
This commit is contained in:
commit
a5711f3e1e
98 files changed
+15505
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user