feat: export historie jídel uživatele
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Notify (push) Successful in 3s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Notify (push) Successful in 3s
This commit is contained in:
1 parent
065ccaf38a
commit
eeb1630391
13 files changed
+1513
-8
No files matched your search
@@ -52,6 +52,64 @@ test('GET /stats s budoucím datem vrátí 400', async () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats/export vrátí XLSX soubor', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3 })
|
||||
.responseType('blob')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('spreadsheetml');
|
||||
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.xlsx');
|
||||
// ZIP signatura XLSX souboru
|
||||
expect(res.body.slice(0, 2).toString()).toBe('PK');
|
||||
});
|
||||
|
||||
test('GET /stats/export?format=csv vrátí CSV', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3, format: 'csv' })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/csv');
|
||||
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.csv');
|
||||
expect(res.text).toContain('"Datum";"Den";"Typ"');
|
||||
});
|
||||
|
||||
test('GET /stats/export?format=json vrátí JSON s metadaty', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3, format: 'json' })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.headers['content-disposition']).toContain('luncher-testuser-2025-03.json');
|
||||
expect(res.body).toMatchObject({ login: 'testuser', year: 2025, month: 3, rowCount: 0, rows: [] });
|
||||
});
|
||||
|
||||
test('GET /stats/export s neznámým formátem vrátí 400', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 3, format: 'pdf' })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats/export s neplatným měsícem vrátí 400', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.query({ year: 2025, month: 13 })
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats/export bez parametrů vrátí 400', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats/export')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /stats bez tokenu vrátí chybu', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/stats')
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
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 "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('člen skupiny, který si nic neobjednal, nemá vyplněnou částku', async () => {
|
||||
await saveExtra('2025-03-12', [{
|
||||
id: 'g3',
|
||||
name: 'KFC',
|
||||
creatorLogin: USER,
|
||||
state: GroupState.ORDERED,
|
||||
members: { [USER]: {}, [OTHER]: { amount: 15000 } },
|
||||
}]);
|
||||
|
||||
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||
expect(rows).toHaveLength(1);
|
||||
// Objednávající je uveden, 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' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user