CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 28s
CI / Build server (push) Successful in 37s
CI / Build client (push) Successful in 45s
CI / Playwright E2E tests (push) Successful in 2m15s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
import getStorage from '../storage';
|
|
import { resetMemoryStorage } from '../storage/memory';
|
|
import { getUserExportRows, computeMemberAmount, getUserExportFileName, buildCsv, buildJson, generateUserExport, isExportFormat, UserExportRow } from '../userExport';
|
|
import { ClientData, GroupState, LunchChoice, OrderGroup, PizzaDayState, WeekMenu } from '../../../types/gen/types.gen';
|
|
|
|
const storage = getStorage();
|
|
const USER = 'petr';
|
|
const OTHER = 'tomas';
|
|
|
|
// 2025-03-03 je pondělí, 2025-03-04 úterý, ...
|
|
const YEAR = 2025;
|
|
const MONTH = 3;
|
|
|
|
/** Uloží denní data pro slot oběd. */
|
|
async function saveLunch(isoDate: string, choices: ClientData["choices"], pizzaDay?: ClientData["pizzaDay"]) {
|
|
await storage.setData<Partial<ClientData>>(isoDate, { choices, pizzaDay });
|
|
}
|
|
|
|
/** Uloží data slotu extra s předanými skupinami. */
|
|
async function saveExtra(isoDate: string, groups: OrderGroup[]) {
|
|
await storage.setData<Partial<ClientData>>(`${isoDate}_extra`, { choices: {}, groups });
|
|
}
|
|
|
|
/** Uloží týdenní menu tak, aby daný den obsahoval předaná jídla. */
|
|
async function saveWeekMenu(menuKey: string, dayIndex: number, foodNames: string[]) {
|
|
const week: WeekMenu = [{}, {}, {}, {}, {}] as WeekMenu;
|
|
week[dayIndex] = {
|
|
TECHTOWER: {
|
|
lastUpdate: 0,
|
|
closed: false,
|
|
food: foodNames.map(name => ({ name, isSoup: false })),
|
|
},
|
|
};
|
|
await storage.setData(menuKey, week);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetMemoryStorage();
|
|
});
|
|
|
|
describe('getUserExportRows', () => {
|
|
test('vynechá dny bez záznamu i volbu "mám vlastní/neobědvám"', async () => {
|
|
await saveLunch('2025-03-03', { NEOBEDVAM: { [USER]: {} } });
|
|
await saveLunch('2025-03-04', { SPSE: { [USER]: {} } });
|
|
// den, kde má volbu jen jiný uživatel
|
|
await saveLunch('2025-03-05', { SPSE: { [OTHER]: {} } });
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ date: '2025-03-04', type: 'SPŠE' });
|
|
});
|
|
|
|
test('vyplní vybrané jídlo z uloženého menu i poznámku', async () => {
|
|
await saveWeekMenu('menu_2025_10', 0, ['Polévka', 'Svíčková', 'Guláš']);
|
|
await saveLunch('2025-03-03', {
|
|
TECHTOWER: { [USER]: { selectedFoods: [1, 2], note: 'bez knedlíku' } },
|
|
});
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
date: '2025-03-03',
|
|
dayOfWeek: 'pondělí',
|
|
type: 'TechTower',
|
|
food: 'Svíčková + Guláš',
|
|
note: 'bez knedlíku',
|
|
});
|
|
});
|
|
|
|
test('u volby podniku vyplní uživatelem zadanou cenu', async () => {
|
|
await saveLunch('2025-03-04', { SPSE: { [USER]: { price: 9500 } } });
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ type: 'SPŠE', amount: 9500 });
|
|
});
|
|
|
|
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
|
|
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
|
|
await saveExtra('2025-03-06', [{
|
|
id: 'g1',
|
|
name: 'KFC',
|
|
creatorLogin: OTHER,
|
|
state: GroupState.ORDERED,
|
|
orderedAt: '11:30',
|
|
shipping: 4000,
|
|
discountType: 'percent',
|
|
discountValue: 10,
|
|
members: {
|
|
[USER]: { amount: 20000, note: 'Twister menu', paid: true },
|
|
[OTHER]: { amount: 20000 },
|
|
},
|
|
}]);
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
date: '2025-03-06',
|
|
type: 'Budu objednávat',
|
|
food: 'Twister menu',
|
|
note: 'platím kartou',
|
|
store: 'KFC',
|
|
orderedBy: OTHER,
|
|
// 200 Kč + 20 Kč doprava (poloviny ze 40) - 20 Kč sleva (10 %)
|
|
amount: 20000 + 2000 - 2000,
|
|
});
|
|
});
|
|
|
|
test('objednávku bez volby oběda vypíše jako samostatný záznam', async () => {
|
|
await saveExtra('2025-03-07', [{
|
|
id: 'g2',
|
|
name: 'Bageterie',
|
|
creatorLogin: USER,
|
|
state: GroupState.OPEN,
|
|
members: { [USER]: { amount: 15000, surchargeText: 'sýr', surchargeAmount: 2000 } },
|
|
}]);
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
date: '2025-03-07',
|
|
type: 'Objednávka',
|
|
store: 'Bageterie',
|
|
orderedBy: USER,
|
|
food: 'příplatek: sýr',
|
|
amount: 17000,
|
|
});
|
|
});
|
|
|
|
test('u Pizza day vypíše objednané pizzy a celkovou cenu', async () => {
|
|
await saveLunch('2025-03-10', { PIZZA: { [USER]: {} } }, {
|
|
state: PizzaDayState.DELIVERED,
|
|
creator: OTHER,
|
|
orders: [{
|
|
customer: USER,
|
|
pizzaList: [{ varId: 1, name: 'Margherita', size: '32cm', price: 18000 }],
|
|
fee: { text: 'kuřecí maso', price: 3000 },
|
|
totalPrice: 21000,
|
|
note: 'bez oliv',
|
|
hasQr: true,
|
|
}],
|
|
} as ClientData["pizzaDay"]);
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
type: 'Pizza day',
|
|
food: 'Margherita (32cm) + příplatek: kuřecí maso',
|
|
note: 'bez oliv',
|
|
amount: 21000,
|
|
});
|
|
});
|
|
|
|
test('více skupin v jeden den vypíše jako samostatné řádky', async () => {
|
|
await saveLunch('2025-03-11', { OBJEDNAVAM: { [USER]: {} } });
|
|
await saveExtra('2025-03-11', [
|
|
{ id: 'a', name: 'KFC', creatorLogin: USER, state: GroupState.OPEN, members: { [USER]: { amount: 10000 } } },
|
|
{ id: 'b', name: 'Bageterie', creatorLogin: USER, state: GroupState.OPEN, members: { [USER]: { amount: 5000 } } },
|
|
]);
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(2);
|
|
// V rámci dne je nejdřív volba oběda (se sloučenou první skupinou), pak zbytek objednávek
|
|
expect(rows.map(r => r.type)).toEqual(['Budu objednávat', 'Objednávka']);
|
|
expect(rows.map(r => r.store)).toEqual(['KFC', 'Bageterie']);
|
|
});
|
|
|
|
test('nezahrne data mimo vybraný měsíc', async () => {
|
|
await saveLunch('2025-02-28', { SPSE: { [USER]: {} } });
|
|
await saveLunch('2025-03-03', { SPSE: { [USER]: {} } });
|
|
await saveLunch('2025-04-01', { SPSE: { [USER]: {} } });
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows.map(r => r.date)).toEqual(['2025-03-03']);
|
|
});
|
|
});
|
|
|
|
test('skupinu, kde si člen nic neobjednal a nemá poznámku, vynechá', async () => {
|
|
await saveLunch('2025-03-12', { OBJEDNAVAM: { [USER]: {} } });
|
|
await saveExtra('2025-03-12', [
|
|
{ id: 'g3', name: 'KFC', creatorLogin: USER, state: GroupState.ORDERED, members: { [USER]: {}, [OTHER]: { amount: 15000 } } },
|
|
{ id: 'g4', name: 'Namaste', creatorLogin: USER, state: GroupState.ORDERED, members: { [USER]: { amount: 22000, note: 'Chicken Tikka' } } },
|
|
]);
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
// Volba "budu objednávat" se sloučí až se skupinou, kde si uživatel reálně objednal
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ type: 'Budu objednávat', store: 'Namaste', food: 'Chicken Tikka', amount: 22000 });
|
|
});
|
|
|
|
test('člen skupiny, který si nic neobjednal, ale má poznámku, nemá vyplněnou částku', async () => {
|
|
await saveExtra('2025-03-12', [{
|
|
id: 'g3',
|
|
name: 'KFC',
|
|
creatorLogin: USER,
|
|
state: GroupState.ORDERED,
|
|
members: { [USER]: { note: 'beru si jen pití od kolegy' }, [OTHER]: { amount: 15000 } },
|
|
}]);
|
|
|
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
|
expect(rows).toHaveLength(1);
|
|
// Záznam je uveden kvůli poznámce, ale částka zůstává prázdná — není co platit
|
|
expect(rows[0].orderedBy).toBe(USER);
|
|
expect(rows[0].amount).toBeUndefined();
|
|
expect(buildCsv(rows).split('\r\n')[1].split(';')[7]).toBe('');
|
|
expect(buildJson(USER, YEAR, MONTH, rows).rows[0].amount).toBeUndefined();
|
|
});
|
|
|
|
describe('computeMemberAmount', () => {
|
|
const group = (overrides: Partial<OrderGroup>): OrderGroup => ({
|
|
id: 'g',
|
|
name: 'KFC',
|
|
creatorLogin: OTHER,
|
|
state: GroupState.OPEN,
|
|
members: {},
|
|
...overrides,
|
|
});
|
|
|
|
test('neaktivní člen (nic si neobjednal) platí nula', () => {
|
|
const g = group({ shipping: 4000, members: { [USER]: {}, [OTHER]: { amount: 10000 } } });
|
|
expect(computeMemberAmount(g, USER)).toBe(0);
|
|
});
|
|
|
|
test('pevná sleva se dělí mezi aktivní členy', () => {
|
|
const g = group({
|
|
discountType: 'fixed',
|
|
discountValue: 5000,
|
|
members: { [USER]: { amount: 10000 }, [OTHER]: { amount: 10000 } },
|
|
});
|
|
expect(computeMemberAmount(g, USER)).toBe(10000 - 2500);
|
|
});
|
|
|
|
test('poplatky se rozpočítají jen mezi aktivní členy', () => {
|
|
const g = group({
|
|
fees: 1000,
|
|
shipping: 5000,
|
|
tip: 0,
|
|
members: { [USER]: { amount: 10000 }, [OTHER]: {} },
|
|
});
|
|
expect(computeMemberAmount(g, USER)).toBe(16000);
|
|
});
|
|
});
|
|
|
|
test('getUserExportFileName očistí login a doplní měsíc s příponou dle formátu', () => {
|
|
expect(getUserExportFileName('domena\\petr', 2025, 3)).toBe('luncher-domena_petr-2025-03.xlsx');
|
|
expect(getUserExportFileName('petr', 2025, 3, 'csv')).toBe('luncher-petr-2025-03.csv');
|
|
expect(getUserExportFileName('petr', 2025, 12, 'json')).toBe('luncher-petr-2025-12.json');
|
|
});
|
|
|
|
test('isExportFormat propustí jen podporované formáty', () => {
|
|
expect(isExportFormat('xlsx')).toBe(true);
|
|
expect(isExportFormat('csv')).toBe(true);
|
|
expect(isExportFormat('json')).toBe(true);
|
|
expect(isExportFormat('pdf')).toBe(false);
|
|
expect(isExportFormat(undefined)).toBe(false);
|
|
});
|
|
|
|
describe('buildCsv', () => {
|
|
const rows: UserExportRow[] = [
|
|
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'TechTower', food: 'Svíčková', note: 'bez knedlíku' },
|
|
{
|
|
date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Budu objednávat', food: 'Twister; s "extra" sýrem',
|
|
store: 'KFC', orderedBy: 'tomas', amount: 20000,
|
|
},
|
|
];
|
|
|
|
test('začíná BOM a hlavičkou oddělenou středníkem', () => {
|
|
const csv = buildCsv(rows);
|
|
expect(csv.charCodeAt(0)).toBe(0xFEFF);
|
|
expect(csv.slice(1).split('\r\n')[0]).toBe('"Datum";"Den";"Typ";"Vybrané jídlo";"Poznámka";"Objednávka";"Objednával";"Částka"');
|
|
});
|
|
|
|
test('formátuje datum, částku s desetinnou čárkou a escapuje uvozovky i středníky', () => {
|
|
const lines = buildCsv(rows).split('\r\n');
|
|
expect(lines[1]).toBe('"03.03.2025";"pondělí";"TechTower";"Svíčková";"bez knedlíku";;;');
|
|
expect(lines[2]).toBe('"06.03.2025";"čtvrtek";"Budu objednávat";"Twister; s ""extra"" sýrem";;"KFC";"tomas";"200,00"');
|
|
});
|
|
|
|
test('u záznamu bez objednávky nechá sloupce objednávky prázdné', () => {
|
|
const csv = buildCsv([{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'Pizza day', amount: 21000 }]);
|
|
const cells = csv.split('\r\n')[1].split(';');
|
|
expect(cells[5]).toBe('');
|
|
expect(cells[6]).toBe('');
|
|
expect(cells[7]).toBe('"210,00"');
|
|
});
|
|
});
|
|
|
|
describe('buildJson', () => {
|
|
test('obsahuje metadata, součet v korunách a jen vyplněná pole', () => {
|
|
const rows: UserExportRow[] = [
|
|
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'SPŠE' },
|
|
{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Objednávka', store: 'KFC', orderedBy: 'tomas', amount: 22000 },
|
|
];
|
|
const json = buildJson('petr', 2025, 3, rows);
|
|
expect(json).toMatchObject({ login: 'petr', year: 2025, month: 3, rowCount: 2, totalAmount: 220 });
|
|
expect(json.rows[0]).toEqual({ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'SPŠE' });
|
|
expect(json.rows[1]).toEqual({
|
|
date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'Objednávka',
|
|
store: 'KFC', orderedBy: 'tomas', amount: 220,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('generateUserExport', () => {
|
|
beforeEach(async () => {
|
|
await storage.setData('2025-03-03', { choices: { SPSE: { [USER]: { note: 'polévka' } } } });
|
|
});
|
|
|
|
test('xlsx vrátí ZIP obsah se správným MIME a názvem', async () => {
|
|
const result = await generateUserExport(USER, YEAR, MONTH);
|
|
expect(result.content.subarray(0, 2).toString()).toBe('PK');
|
|
expect(result.mimeType).toContain('spreadsheetml');
|
|
expect(result.fileName).toBe('luncher-petr-2025-03.xlsx');
|
|
});
|
|
|
|
test('csv vrátí textový obsah s daty uživatele', async () => {
|
|
const result = await generateUserExport(USER, YEAR, MONTH, 'csv');
|
|
expect(result.mimeType).toBe('text/csv; charset=utf-8');
|
|
expect(result.fileName).toBe('luncher-petr-2025-03.csv');
|
|
expect(result.content.toString('utf-8')).toContain('"03.03.2025";"pondělí";"SPŠE"');
|
|
});
|
|
|
|
test('json vrátí parsovatelný obsah s metadaty', async () => {
|
|
const result = await generateUserExport(USER, YEAR, MONTH, 'json');
|
|
expect(result.mimeType).toBe('application/json; charset=utf-8');
|
|
expect(result.fileName).toBe('luncher-petr-2025-03.json');
|
|
const parsed = JSON.parse(result.content.toString('utf-8'));
|
|
expect(parsed).toMatchObject({ login: USER, year: YEAR, month: MONTH, rowCount: 1 });
|
|
expect(parsed.rows[0]).toMatchObject({ type: 'SPŠE', note: 'polévka' });
|
|
});
|
|
});
|