Seed z repozitare, zatezove testy, worker bez spanku, dialogy bez rozmazani

Nastaveni prezije nasazeni: seed/records.json se pri prazdnem ulozisti
nacte misto ukazkovych dat (zive uloziste se nikdy neprepisuje). Soubor
nese soucasny stav produkce (firmy s provozovatelem, role, typ ticketu,
akce, widgety, skupiny, rozlozeni). Novy GET /api/admin/export a skript
npm run seed:export pro dalsi exporty, Dockerfile slozku kopiruje.

Vykonnostni testy (npm run test:perf) nad 200 firmami a 10 000 tickety
a zatezovy skript (npm run load) proti bezici instanci vcetne davky
udalosti na webhook. Mereni odhalilo strop workeru: po obsazeni vsech
mist spal sekundu, takze fronta odbavila nejvys 4 behy za sekundu. Ted
ceka na prvni dokonceny beh: 500 udalosti za 1,3 s (395 behu/s). Strop
posluchacu streamu zvednut na 2 000.

Dialogy: prekryv modalu a menu v portalu bez backdrop-blur, tecka Zive
pulzuje jen pri navazovani spojeni - rozmazani cele obrazovky pod trvalou
animaci sekalo video vedle portalu. Bublina udalosti drzi 0,5 s.

Dokumentace 14, 19, 20, 22, 04, 01, 03, 15 a 99 aktualizovana.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 20:41:42 +02:00
co-authored by Claude Fable 5.1
parent c25e826766
commit ea9387bea9
44 changed files with 3512 additions and 28 deletions
+256
View File
@@ -0,0 +1,256 @@
/**
* Runtime pod zatezi: vykonavani stromu (src/runtime/executor.ts) a fronta
* behu (src/runtime/queue.ts).
*
* Strom: podminka a smycka pres 200 polozek se ctyrmi vnitrnimi kroky, tedy
* 802 vykonanych kroku na beh, 100 behu. Vnitrni kroky, skripty a konektory
* jsou atrapy jako v tests/runtime/executor.test.ts - meri se executor,
* ne cizi sluzba.
*
* Fronta: 5 000 behu ve 200 firmach, worker bez prace je bere po ctyrech
* (`CONCURRENCY` v worker.ts) a hned oznacuje za hotove. Meri se propustnost
* `claimBatch` + `markDone`, tedy strop fronty bez cizi sluzby.
*/
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import type { StepContext, StepOutcome } from '../../src/runtime/builtinSteps.js';
import type { FlowStep } from '../../src/data/automationStore.js';
vi.mock('../../src/runtime/builtinSteps.js', () => ({ findBuiltinStep: vi.fn() }));
vi.mock('../../src/runtime/scripts/lookup.js', () => ({ scriptIdFor: vi.fn() }));
vi.mock('../../src/runtime/scripts/runner.js', () => ({ runScript: vi.fn() }));
vi.mock('../../src/data/connectorStore.js', () => ({
getConnector: vi.fn(),
defaultConnectorFor: vi.fn(),
}));
import { findBuiltinStep } from '../../src/runtime/builtinSteps.js';
import { initStores } from '../../src/data/store/index.js';
import { runFlow, type RunOptions } from '../../src/runtime/executor.js';
import {
claimBatch,
enqueue,
initQueue,
markDone,
queueStats,
trimQueue,
type QueueItem,
} from '../../src/runtime/queue.js';
import { perfTenantId, TENANT_COUNT } from './fixtures.js';
import { measure, perSecond, report } from './measure.js';
type Handler = (
inputs: Record<string, string>,
context: StepContext,
) => Promise<StepOutcome> | StepOutcome;
const LOOP_ITEMS = 200;
const STEPS_PER_ITEM = 4;
const FLOW_RUNS = 100;
/** Radek podminky, radek smycky a ctyri kroky za kazdou polozku. */
const STEPS_PER_RUN = 2 + LOOP_ITEMS * STEPS_PER_ITEM;
const QUEUE_RUNS = 5_000;
/** Kolik behu si worker bere naraz, viz `CONCURRENCY` v src/runtime/worker.ts. */
const WORKER_CONCURRENCY = 4;
/** Kolik zarazeni s klicem proti dvojimu zarazeni se meri zvlast. */
const DEDUPE_ENQUEUES = 1_000;
/**
* Rozpocty v ms (median ze tri behu), 4x az 5x nad merenim z 2026-09-09:
* strom 242, zarazeni 217, vyprazdneni 474, klic proti dvojimu zarazeni 30.
*/
const BUDGET_FLOW_MS = 1_000;
const BUDGET_ENQUEUE_MS = 1_000;
const BUDGET_DRAIN_MS = 2_000;
const BUDGET_DEDUPE_MS = 150;
const handlers: Record<string, Handler> = {
ok: (inputs) => ({ ok: true, summary: 'hotovo', outputs: { echo: inputs.value ?? '' } }),
};
function options(): RunOptions {
return { tenantId: perfTenantId(0), ticketId: null, idempotencyKey: null };
}
function action(id: string): FlowStep {
return {
id,
kind: 'action',
serviceId: 'test',
operationId: 'ok',
// Dve sablony na krok, aby se meril i prevod {{parametru}}.
inputs: { value: '{{item.n}}', note: 'polozka {{index}} z {{count}}' },
};
}
function tree(): FlowStep[] {
return [
{
id: 'guard',
kind: 'condition',
rules: [{ fieldId: 'items', operator: 'isNotEmpty' }],
yes: [
{
id: 'loop',
kind: 'foreach',
path: 'items',
steps: Array.from({ length: STEPS_PER_ITEM }, (_, index) => action(`step${index}`)),
},
],
no: [],
},
];
}
beforeEach(() => {
vi.mocked(findBuiltinStep).mockReset();
vi.mocked(findBuiltinStep).mockImplementation((serviceId, operationId) =>
serviceId === 'test' ? handlers[operationId] : undefined,
);
});
describe('runFlow', () => {
test('100 behu stromu s podminkou a smyckou pres 200 polozek', async () => {
const steps = tree();
const items = Array.from({ length: LOOP_ITEMS }, (_, n) => ({ n }));
let executed = 0;
const ms = await measure(async () => {
executed = 0;
for (let run = 0; run < FLOW_RUNS; run += 1) {
const result = await runFlow(steps, { items, count: LOOP_ITEMS }, options());
if (!result.ok) throw new Error(result.error ?? 'beh selhal');
executed += result.steps.length;
}
});
report(
`runFlow ${FLOW_RUNS}x (${STEPS_PER_RUN} kroku na beh)`,
ms,
BUDGET_FLOW_MS,
`${perSecond(FLOW_RUNS * STEPS_PER_RUN, ms)} kroku/s`,
);
expect(executed).toBe(FLOW_RUNS * STEPS_PER_RUN);
expect(ms).toBeLessThan(BUDGET_FLOW_MS);
});
});
describe('fronta behu', () => {
beforeAll(async () => {
initStores({ databaseReady: false });
await initQueue();
});
/** Vyprazdni frontu od hotovych behu, aby dalsi beh mereni zacinal od nuly. */
async function clearDone(): Promise<void> {
await trimQueue(0, 0);
}
async function fill(count: number): Promise<number> {
let queued = 0;
for (let index = 0; index < count; index += 1) {
const item = await enqueue({
tenantId: perfTenantId(index % TENANT_COUNT),
automationId: `auto_${index % 10}`,
trigger: 'webhook',
payload: { sequence: index, body: { orderId: `ORD-${index}` } },
});
if (item) queued += 1;
}
return queued;
}
/** Worker bez prace: bere po ctyrech a hned oznacuje za hotove. */
async function drain(): Promise<number> {
const active = new Set<string>();
let done = 0;
for (;;) {
const claimed: QueueItem[] = await claimBatch(WORKER_CONCURRENCY, active);
if (claimed.length === 0) break;
for (const item of claimed) {
active.add(item.id);
await markDone(item);
active.delete(item.id);
done += 1;
}
}
return done;
}
test('zarazeni 5 000 behu', async () => {
let queued = 0;
const ms = await measure(
async () => {
queued = await fill(QUEUE_RUNS);
},
{
// Kazdy beh zacina s prazdnou frontou: predchozi se vyprazdni a uklidi.
beforeEach: async () => {
await drain();
await clearDone();
},
},
);
report(`enqueue ${QUEUE_RUNS}x`, ms, BUDGET_ENQUEUE_MS, `${perSecond(QUEUE_RUNS, ms)} behu/s`);
expect(queued).toBe(QUEUE_RUNS);
expect(ms).toBeLessThan(BUDGET_ENQUEUE_MS);
// Po mereni fronta ceka: dalsi test ji vyprazdni.
expect(queueStats().pending).toBe(QUEUE_RUNS);
});
test('vyprazdneni 5 000 behu workerem bez prace', async () => {
let done = 0;
const ms = await measure(
async () => {
done = await drain();
},
{
beforeEach: async () => {
await clearDone();
if (queueStats().pending < QUEUE_RUNS) await fill(QUEUE_RUNS - queueStats().pending);
},
},
);
report(
`claimBatch+markDone ${QUEUE_RUNS}x po ${WORKER_CONCURRENCY}`,
ms,
BUDGET_DRAIN_MS,
`${perSecond(QUEUE_RUNS, ms)} behu/s`,
);
expect(done).toBe(QUEUE_RUNS);
expect(queueStats().pending).toBe(0);
expect(ms).toBeLessThan(BUDGET_DRAIN_MS);
});
test('klic proti dvojimu zarazeni nad plnou frontou', async () => {
await clearDone();
await fill(QUEUE_RUNS);
let queued = 0;
let skipped = 0;
const ms = await measure(async () => {
queued = 0;
skipped = 0;
for (let index = 0; index < DEDUPE_ENQUEUES; index += 1) {
const item = await enqueue({
tenantId: perfTenantId(index % TENANT_COUNT),
automationId: 'auto_dedupe',
trigger: 'ticket.updated',
ticketId: `TK-${index % 100}`,
// Kazdy desaty klic je novy, ostatni uz ve fronte cekaji.
dedupeKey: `auto_dedupe:TK-${index % 100}`,
});
if (item) queued += 1;
else skipped += 1;
}
});
report(
`enqueue s dedupeKey ${DEDUPE_ENQUEUES}x nad ${QUEUE_RUNS} cekajicimi`,
ms,
BUDGET_DEDUPE_MS,
`${queued} zarazeno, ${skipped} prevzato`,
);
expect(queued + skipped).toBe(DEDUPE_ENQUEUES);
expect(ms).toBeLessThan(BUDGET_DEDUPE_MS);
await drain();
await clearDone();
});
});
+280
View File
@@ -0,0 +1,280 @@
/**
* Synteticka data pro vykonove testy: 200 firem, 1 000 uctu, 50 vlastnich
* roli a 10 000 ticketu.
*
* Data jsou **deterministicka** (vlastni generator nahodnych cisel se
* semenem), aby dva behy merily totez a rozdil v case byl rozdil v kodu,
* ne v datech. Vsechno jde pres verejne funkce ulozist, stejne jako
* v provozu - zadne sahani do vnitrniho stavu.
*
* Firmy a ucty se **vytvareji po jednom** (`create`), ne pres `init(seed)`:
* seed ze souboru v repu (`seed/records.json`) ma pred seedem z kodu
* prednost a firmy z nej by tise nahradily tech dve ste testovacich.
*/
import { listPeople, groupStore, refreshGroups, type Person } from '../../src/data/people.js';
import { refreshRoles, roleStore, systemRoles, type Role } from '../../src/data/permissions.js';
import { initStores, nowIso } from '../../src/data/store/index.js';
import { featuresStore, refreshFeatures, seedFeatures } from '../../src/data/tenantFeatures.js';
import { refreshTenants, tenantStore, type Tenant } from '../../src/data/tenants.js';
import {
createTicket,
defaultStatuses,
intakeEvent,
updateTicketStatus,
type TicketChannel,
type TicketPriority,
} from '../../src/data/ticketStore.js';
import { refreshUsers, userStore, type StoredUser } from '../../src/data/users.js';
import type { Membership } from '../../src/types.js';
/** Cilovy provoz z documentation/19-kapacita-200-firem.md. */
export const TENANT_COUNT = 200;
/** Pet uctu na firmu; kazdy ma 1 az 3 clenstvi, takze lidi na firmu vychazi vic. */
export const USER_COUNT = 1_000;
export const CUSTOM_ROLE_COUNT = 50;
export const TICKET_COUNT = 10_000;
/** Nejvic clenstvi jednoho uctu. Externista ve trech firmach je bezny pripad. */
export const MAX_MEMBERSHIPS = 3;
/** Kolik ticketu vznika prijmem udalosti (s externim ID), zbytek rucne. */
const INTAKE_SHARE = 0.3;
/** Kolik ticketu je vyrizenych, aby statistiky resitelu mely co pocitat. */
const CLOSED_SHARE = 0.3;
/** Kolik ticketu nikdo nema u sebe. */
const UNASSIGNED_SHARE = 0.25;
/** Kazdy paty ucet je spravce firmy, kazdy treti ma vlastni roli (kdyz firma nejakou ma). */
const ADMIN_EVERY = 5;
const CUSTOM_ROLE_EVERY = 3;
const CHANNELS: TicketChannel[] = ['whatsapp', 'email', 'voice', 'form', 'portal'];
const PRIORITIES: TicketPriority[] = ['low', 'normal', 'high', 'critical'];
const TAGS = ['vip', 'faktura', 'reklamace', 'expres'];
/** Stav vyrizeneho ticketu: posledni z vychozi nabidky. */
const CLOSED_STATUS = defaultStatuses[defaultStatuses.length - 1] ?? 'Vyřešeno';
/**
* Generator nahodnych cisel se semenem (mulberry32). `Math.random` se nastavit
* neda a test s jinymi daty pri kazdem behu by nemel s cim srovnavat.
*/
export function rng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = Math.imul(state ^ (state >>> 15), 1 | state);
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function pick<T>(random: () => number, items: readonly T[]): T {
const item = items[Math.floor(random() * items.length)];
if (item === undefined) throw new Error('vyber z prazdneho seznamu');
return item;
}
export function perfTenantId(index: number): string {
return `tnt_perf_${index}`;
}
export function perfUserId(index: number): string {
return `usr_perf_${index}`;
}
export function perfRoleId(tenantIndex: number): string {
return `role_perf_${tenantIndex}`;
}
export function buildTenant(index: number): Tenant {
const timestamp = nowIso();
return {
id: perfTenantId(index),
tenantId: null,
name: `Firma ${index}`,
note: '',
enabled: true,
intakeToken: `perf-intake-token-${index}-0123456789abcdef`,
helpdeskProviderId: null,
createdAt: timestamp,
updatedAt: timestamp,
};
}
/** Vlastni role firmy: cte tickety a komentuje, nic vic. */
export function buildRole(tenantIndex: number): Role {
const timestamp = nowIso();
return {
id: perfRoleId(tenantIndex),
tenantId: perfTenantId(tenantIndex),
key: perfRoleId(tenantIndex),
name: `Vlastni role ${tenantIndex}`,
description: '',
permissions: ['ticket.view', 'ticket.comment', 'ticket.assign.self'],
system: false,
createdAt: timestamp,
updatedAt: timestamp,
};
}
/**
* Ucet s 1 az 3 clenstvimi v ruznych firmach.
*
* Prvni firma je `index % TENANT_COUNT`, aby mela kazda firma aspon pet lidi.
* Dalsi clenstvi jsou nahodna, jako u externistu.
*/
export function buildUser(index: number, random: () => number): StoredUser {
const timestamp = nowIso();
const home = index % TENANT_COUNT;
const tenantIndexes = new Set<number>([home]);
const wanted = 1 + Math.floor(random() * MAX_MEMBERSHIPS);
while (tenantIndexes.size < wanted) tenantIndexes.add(Math.floor(random() * TENANT_COUNT));
const memberships: Membership[] = [...tenantIndexes].map((tenantIndex) => {
let roleIds = ['agent'];
if (index % ADMIN_EVERY === 0) roleIds = ['admin'];
else if (index % CUSTOM_ROLE_EVERY === 0 && tenantIndex < CUSTOM_ROLE_COUNT) {
roleIds = [perfRoleId(tenantIndex)];
}
return { tenantId: perfTenantId(tenantIndex), roleIds, capacity: 8 };
});
return {
id: perfUserId(index),
tenantId: null,
email: `user${index}@perf.example`,
// Heslo se v techto testech nikdy neoveruje; hashovat tisic hesel by trvalo minuty.
passwordHash: '',
name: `Ucet ${index}`,
platformAdmin: false,
memberships,
enabled: true,
createdAt: timestamp,
updatedAt: timestamp,
};
}
export interface Directory {
tenants: Tenant[];
users: StoredUser[];
roles: Role[];
}
/**
* Naplni firmy, ucty, role, nastaveni firem a skupiny a obnovi kopie v pameti.
*
* `initialised: true` znamena, ze uloziste uz nekdo nastartoval (typicky
* `bootstrapData`) a `init` se volat nesmi - v rezimu pameti by druhy `init`
* zahodil, co tam je.
*/
export async function seedDirectory(options: { initialised?: boolean } = {}): Promise<Directory> {
const random = rng(7);
if (!options.initialised) {
initStores({ databaseReady: false });
await tenantStore.init();
await userStore.init();
await roleStore.init(systemRoles);
await featuresStore.init(seedFeatures);
await groupStore.init();
}
const tenants = Array.from({ length: TENANT_COUNT }, (_, index) => buildTenant(index));
for (const tenant of tenants) await tenantStore.create(tenant);
const roles = Array.from({ length: CUSTOM_ROLE_COUNT }, (_, index) => buildRole(index));
for (const role of roles) await roleStore.create(role);
const users = Array.from({ length: USER_COUNT }, (_, index) => buildUser(index, random));
for (const user of users) await userStore.create(user);
await Promise.all([
refreshTenants(),
refreshUsers(),
refreshRoles(),
refreshFeatures(),
refreshGroups(),
]);
return { tenants, users, roles };
}
export interface TicketDataset {
/** Kolik ticketu ma ktera firma. Pro kontrolu, ze seznam vraci vsechno. */
countByTenant: Map<string, number>;
/** Externi ID ticketu vzniklych prijmem udalosti, s firmou. */
external: Array<{ tenantId: string; externalId: string }>;
}
/**
* Zalozi `count` ticketu rovnomerne pres firmy.
*
* Cast vznika prijmem udalosti (`intakeEvent`, s externim ID), zbytek rucne
* (`createTicket`). Cast se rovnou vyridi, aby mely statistiky resitelu
* co pocitat. Resitel se bere z lidi te firmy, aby `toTicket` nehlasil
* neexistujiciho resitele.
*/
export function seedTickets(
random: () => number,
count = TICKET_COUNT,
tenantCount = TENANT_COUNT,
): TicketDataset {
const peopleByTenant = new Map<string, Person[]>();
const countByTenant = new Map<string, number>();
const external: TicketDataset['external'] = [];
for (let index = 0; index < count; index += 1) {
const tenantId = perfTenantId(index % tenantCount);
let people = peopleByTenant.get(tenantId);
if (!people) {
people = listPeople([tenantId]);
peopleByTenant.set(tenantId, people);
}
const assigneeId =
random() < UNASSIGNED_SHARE || people.length === 0 ? null : pick(random, people).id;
const tags = TAGS.filter(() => random() < 0.3);
const priority = pick(random, PRIORITIES);
const channel = pick(random, CHANNELS);
const status = pick(random, defaultStatuses);
let ticketId: string;
if (random() < INTAKE_SHARE) {
const externalId = `ext-${index}`;
const result = intakeEvent({
tenantId,
externalId,
externalSource: 'eshop',
type: 'order.created',
label: `Objednávka ${index}`,
payload: { orderNumber: index, total: Math.round(random() * 10_000) },
apply: {
channel,
priority,
company: `Zákazník ${index % 97}`,
assigneeId: assigneeId ?? undefined,
},
create: { status, tags },
});
ticketId = result.ticket.id;
external.push({ tenantId, externalId });
} else {
const ticket = createTicket({
tenantId,
subject: `Požadavek ${index}`,
body: `Text požadavku ${index}`,
channel,
priority,
status,
assigneeId,
tags,
customer: { id: null, company: `Zákazník ${index % 97}`, contact: '', reply: '' },
trace: [{ kind: 'note', label: 'Založeno v testu', status: 'info' }],
});
ticketId = ticket.id;
}
if (random() < CLOSED_SHARE) updateTicketStatus(ticketId, CLOSED_STATUS, [tenantId], true);
countByTenant.set(tenantId, (countByTenant.get(tenantId) ?? 0) + 1);
}
return { countByTenant, external };
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Mereni pro vykonove testy (tests/perf).
*
* Kazdy scenar se meri vickrat a bere se **median**: prvni beh ohreje JIT
* a jeden odlehly beh (GC, jiny proces na stroji) median nezkazi. Rozpocet
* kazdeho scenare je 3x az 5x nad casem namerenym na vyvojovem stroji,
* aby test nepadal na pomalejsim CI, ale porad chytil radovy propad -
* treba kdyz se z O(n) stane O(n^2).
*
* `console.info` je v testech umlceny (tests/setup.ts), proto se namerena
* cisla pisou primo na stdout. Bez nich by test jen rekl "proslo", a to
* na otazku "jak rychle" neodpovida.
*/
import { performance } from 'node:perf_hooks';
/** Kolikrat se scenar opakuje. Tri behy staci na median, vic uz jen prodluzuje suite. */
export const RUNS = 3;
/** Prevod milisekund na sekundy pro pocitani propustnosti. */
const MS_PER_SECOND = 1_000;
export function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
const upper = sorted[middle];
const lower = sorted[middle - 1];
if (upper === undefined) return 0;
if (sorted.length % 2 === 1 || lower === undefined) return upper;
return (lower + upper) / 2;
}
/**
* Zmeri praci `runs`-krat a vrati median v milisekundach.
*
* `beforeEach` bezi pred kazdym behem a **nemeri se**: priprava dat (napr.
* vraceni fronty do vychoziho stavu) nema do vysledku co mluvit.
*/
export async function measure(
work: () => unknown | Promise<unknown>,
options: { runs?: number; beforeEach?: () => unknown | Promise<unknown> } = {},
): Promise<number> {
const runs = options.runs ?? RUNS;
const times: number[] = [];
for (let index = 0; index < runs; index += 1) {
if (options.beforeEach) await options.beforeEach();
const start = performance.now();
await work();
times.push(performance.now() - start);
}
return median(times);
}
/** Kolik operaci za sekundu to je. Pro propustnost (udalosti/s, behy/s). */
export function perSecond(count: number, ms: number): number {
if (ms <= 0) return 0;
return Math.round((count / ms) * MS_PER_SECOND);
}
/** Zapise vysledek scenare na stdout. Jeden radek, aby se dal grepovat. */
export function report(scenario: string, medianMs: number, budgetMs: number, note = ''): void {
const suffix = note === '' ? '' : `, ${note}`;
process.stdout.write(
`[perf] ${scenario}: ${medianMs.toFixed(1)} ms (rozpocet ${budgetMs} ms)${suffix}\n`,
);
}
/**
* Necha dobehnout mikroulohy a I/O callbacky.
*
* Uloziste ticketu zapisuje **jednou za tik** (`queueMicrotask` v persist.ts).
* Kdyby se merilo hned po priprave dat, zapis 10 000 ticketu by se pricetl
* k prvnimu scenari. Proto se pred merenim pocka na dalsi kolo smycky.
*/
export function settle(): Promise<void> {
return new Promise((resolve) => {
setImmediate(resolve);
});
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Prava a pristup pod zatezi (src/data/permissions.ts, src/data/access.ts):
* 200 firem, 1 000 uctu s 1 az 3 clenstvimi, 50 vlastnich roli.
*
* `permissionsOf` ma cache na 5 sekund za dvojici ucet a firma, proto se
* meri zvlast studeny pruchod (po `invalidatePermissions`) a tepla cesta
* (100 000 volani, jak to vypada v provozu). `accessFor` se pocita jednou
* za request a cache nema.
*/
import { beforeAll, describe, expect, test } from 'vitest';
import { accessFor } from '../../src/data/access.js';
import { hasPermission, invalidatePermissions, permissionsOf } from '../../src/data/permissions.js';
import type { User } from '../../src/types.js';
import {
CUSTOM_ROLE_COUNT,
perfRoleId,
seedDirectory,
TENANT_COUNT,
USER_COUNT,
} from './fixtures.js';
import { measure, perSecond, report } from './measure.js';
const PERMISSION_CALLS = 100_000;
const ACCESS_CALLS = 10_000;
/** Spravce platformy: vidi vsechny firmy, `accessFor` je pro nej nejdrazsi. */
const ADMIN_ACCESS_CALLS = 1_000;
/**
* Rozpocty v ms (median ze tri behu), 4x az 5x nad merenim z 2026-09-09:
* studene 12, tepla cesta 27, accessFor 49, spravce platformy 92.
*/
const BUDGET_COLD_MS = 60;
const BUDGET_WARM_MS = 120;
const BUDGET_ACCESS_MS = 250;
const BUDGET_ADMIN_ACCESS_MS = 400;
let users: User[] = [];
let membershipCount = 0;
beforeAll(async () => {
const directory = await seedDirectory();
users = directory.users.map((stored) => ({
id: stored.id,
email: stored.email,
passwordHash: stored.passwordHash,
name: stored.name,
platformAdmin: stored.platformAdmin,
memberships: stored.memberships,
}));
membershipCount = users.reduce((sum, user) => sum + user.memberships.length, 0);
expect(users).toHaveLength(USER_COUNT);
expect(directory.tenants).toHaveLength(TENANT_COUNT);
expect(directory.roles).toHaveLength(CUSTOM_ROLE_COUNT);
});
function userAt(index: number): User {
const user = users[index % users.length];
if (!user) throw new Error('chybi ucet');
return user;
}
function tenantOf(user: User, index: number): string {
const membership = user.memberships[index % user.memberships.length];
if (!membership) throw new Error('ucet bez clenstvi');
return membership.tenantId;
}
describe('permissionsOf', () => {
test('studeny pruchod: kazde clenstvi jednou po zahozeni cache', async () => {
let granted = 0;
const ms = await measure(
() => {
granted = 0;
for (const user of users) {
for (const membership of user.memberships) {
granted += permissionsOf(user, membership.tenantId).size;
}
}
},
{ beforeEach: invalidatePermissions },
);
report(
`permissionsOf studene ${membershipCount}x`,
ms,
BUDGET_COLD_MS,
`${perSecond(membershipCount, ms)} vypoctu/s`,
);
expect(granted).toBeGreaterThan(0);
expect(ms).toBeLessThan(BUDGET_COLD_MS);
});
test('tepla cesta: 100 000 volani hasPermission', async () => {
invalidatePermissions();
let allowed = 0;
const ms = await measure(() => {
allowed = 0;
for (let index = 0; index < PERMISSION_CALLS; index += 1) {
const user = userAt(index);
if (hasPermission(user, 'ticket.view', tenantOf(user, index))) allowed += 1;
}
});
report(
`hasPermission ${PERMISSION_CALLS}x`,
ms,
BUDGET_WARM_MS,
`${perSecond(PERMISSION_CALLS, ms)} volani/s`,
);
// Kazda role v datech ma ticket.view, takze plati vsude.
expect(allowed).toBe(PERMISSION_CALLS);
expect(ms).toBeLessThan(BUDGET_WARM_MS);
});
test('role cizi firmy v clenstvi nic nepridava', () => {
const user: User = {
id: 'usr_perf_cizi',
email: 'cizi@perf.example',
passwordHash: '',
name: 'Cizi',
platformAdmin: false,
memberships: [{ tenantId: 'tnt_perf_60', roleIds: [perfRoleId(0)] }],
};
expect(permissionsOf(user, 'tnt_perf_60').size).toBe(0);
});
});
describe('accessFor', () => {
test('10 000 vypoctu pristupu beznych uctu', async () => {
let tenants = 0;
const ms = await measure(() => {
tenants = 0;
for (let index = 0; index < ACCESS_CALLS; index += 1) {
const user = userAt(index);
tenants += accessFor(user, tenantOf(user, index)).tenants.length;
}
});
report(
`accessFor ${ACCESS_CALLS}x`,
ms,
BUDGET_ACCESS_MS,
`${perSecond(ACCESS_CALLS, ms)} volani/s`,
);
expect(tenants).toBeGreaterThanOrEqual(ACCESS_CALLS);
expect(ms).toBeLessThan(BUDGET_ACCESS_MS);
});
test('spravce platformy: pristup pres 200 firem', async () => {
const admin: User = {
id: 'usr_perf_admin',
email: 'admin@perf.example',
passwordHash: '',
name: 'Admin',
platformAdmin: true,
memberships: [],
};
let tenants = 0;
const ms = await measure(() => {
tenants = 0;
for (let index = 0; index < ADMIN_ACCESS_CALLS; index += 1) {
tenants += accessFor(admin, `tnt_perf_${index % TENANT_COUNT}`).tenants.length;
}
});
report(
`accessFor spravce platformy ${ADMIN_ACCESS_CALLS}x`,
ms,
BUDGET_ADMIN_ACCESS_MS,
`${(ms / ADMIN_ACCESS_CALLS).toFixed(2)} ms na volani`,
);
// Aspon 200 firem na volani; seed ze souboru v repu muze pridat dalsi.
expect(tenants).toBeGreaterThanOrEqual(ADMIN_ACCESS_CALLS * TENANT_COUNT);
expect(ms).toBeLessThan(BUDGET_ADMIN_ACCESS_MS);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Sbernice udalosti pod zatezi (src/events/bus.ts): 500 pripojenych
* posluchacu (simulovani klienti SSE), 1 000 udalosti.
*
* Kazdy posluchac filtruje po firme stejne jako `routes/stream.ts`: udalost
* dostane kazdy, ale zapise si jen svoji. Cena za klienta tak musi rust
* s poctem udalosti, ne s poctem klientu - to se overuje srovnanim 100
* a 500 posluchacu.
*
* `emitter.setMaxListeners(200)` v bus.ts znamena, ze Node pri 500
* posluchacich vypise varovani o moznem uniku pameti. Je to jen varovani,
* doruceni funguje; viz documentation/19-kapacita-200-firem.md.
*/
import { describe, expect, test } from 'vitest';
import { listenerCount, publish, subscribe, type DashboardEvent } from '../../src/events/bus.js';
import { perfTenantId, TENANT_COUNT } from './fixtures.js';
import { measure, perSecond, report } from './measure.js';
const CLIENTS = 500;
const CLIENTS_SMALL = 100;
const EVENTS = 1_000;
/** Kazda firma dostane stejne udalosti, aby slo pocet doruceni overit presne. */
const EVENTS_PER_TENANT = EVENTS / TENANT_COUNT;
/** Rozpocty, 5x nad merenim z 2026-09-09: 12 ms, 0,02 us na doruceni. */
const BUDGET_PUBLISH_MS = 60;
/** Kolik mikrosekund smi stat jedno doruceni jednomu klientovi. */
const BUDGET_PER_DELIVERY_US = 0.1;
/**
* O kolik smi cena na klienta vyrust mezi 100 a 500 klienty. Pri O(udalosti)
* je pomer kolem 1, tolerance kryje sum mereni.
*/
const MAX_PER_CLIENT_GROWTH = 3;
const MICROSECONDS_PER_MS = 1_000;
interface Client {
tenantId: string;
received: number;
unsubscribe: () => void;
}
/** Klient SSE: firma z pripojeni, filtr jako ve `stream.ts`, pocitadlo doruceni. */
function connect(index: number): Client {
const tenantId = perfTenantId(index % TENANT_COUNT);
const allowed = new Set([tenantId]);
const client: Client = { tenantId, received: 0, unsubscribe: () => undefined };
const visible = (event: DashboardEvent): boolean => {
const forUser = event.payload?.userId;
if (typeof forUser === 'string') return false;
if (event.tenantId === null) return true;
return allowed.has(event.tenantId);
};
client.unsubscribe = subscribe((event) => {
if (!visible(event)) return;
// Serializace jako pri zapisu do odpovedi, jinak by klient byl zadarmo.
JSON.stringify(event);
client.received += 1;
});
return client;
}
function publishAll(): void {
for (let index = 0; index < EVENTS; index += 1) {
publish(
'ticket.updated',
`Ticket TK-${index} se zmenil`,
{ ticketId: `TK-${index}`, status: 'V řešení' },
perfTenantId(index % TENANT_COUNT),
);
}
}
async function measureWith(clientCount: number): Promise<{ ms: number; clients: Client[] }> {
const clients = Array.from({ length: clientCount }, (_, index) => connect(index));
expect(listenerCount()).toBe(clientCount);
const ms = await measure(publishAll, {
beforeEach: () => {
for (const client of clients) client.received = 0;
},
});
for (const client of clients) client.unsubscribe();
expect(listenerCount()).toBe(0);
return { ms, clients };
}
describe('publish na sbernici', () => {
test(`${CLIENTS} posluchacu, ${EVENTS} udalosti`, async () => {
const { ms, clients } = await measureWith(CLIENTS);
const deliveries = CLIENTS * EVENTS;
const perDeliveryUs = (ms / deliveries) * MICROSECONDS_PER_MS;
report(
`publish ${EVENTS}x pro ${CLIENTS} klientu`,
ms,
BUDGET_PUBLISH_MS,
`${perSecond(EVENTS, ms)} udalosti/s, ${perDeliveryUs.toFixed(2)} us na doruceni`,
);
// Kazdy klient dostal presne udalosti sve firmy, nic navic.
expect(clients.every((client) => client.received === EVENTS_PER_TENANT)).toBe(true);
expect(ms).toBeLessThan(BUDGET_PUBLISH_MS);
expect(perDeliveryUs).toBeLessThan(BUDGET_PER_DELIVERY_US);
});
test('cena na klienta neroste s poctem klientu', async () => {
const small = await measureWith(CLIENTS_SMALL);
const large = await measureWith(CLIENTS);
const perClientSmallUs = (small.ms / CLIENTS_SMALL) * MICROSECONDS_PER_MS;
const perClientLargeUs = (large.ms / CLIENTS) * MICROSECONDS_PER_MS;
process.stdout.write(
`[perf] publish ${EVENTS}x na klienta: ${perClientSmallUs.toFixed(1)} us pri ${CLIENTS_SMALL} ` +
`klientech, ${perClientLargeUs.toFixed(1)} us pri ${CLIENTS} (smi nejvys ${MAX_PER_CLIENT_GROWTH}x vic)\n`,
);
expect(perClientLargeUs).toBeLessThan(perClientSmallUs * MAX_PER_CLIENT_GROWTH);
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* Uloziste ticketu pod zatezi (src/data/tickets): 10 000 ticketu ve 200
* firmach, seznam s filtrem a strankovanim, vytizeni, vykon resitelu,
* prijem opakovanych udalosti a hledani podle externiho ID.
*
* Rezim pameti, viz tests/setup.ts. Rozpocty jsou 3x az 5x nad casem
* namerenym na vyvojovem stroji, viz tests/perf/measure.ts.
*/
import { performance } from 'node:perf_hooks';
import { beforeAll, describe, expect, test } from 'vitest';
import type { Visibility } from '../../src/data/access.js';
import { listPeople } from '../../src/data/people.js';
import {
findByExternalId,
getAgentStats,
getWorkload,
intakeEvent,
listTickets,
} from '../../src/data/ticketStore.js';
import { paginate } from '../../src/routes/dashboard/shared.js';
import {
perfTenantId,
rng,
seedDirectory,
seedTickets,
TICKET_COUNT,
type TicketDataset,
} from './fixtures.js';
import { measure, perSecond, report, settle } from './measure.js';
const ALL = { kind: 'all' } as const;
/** Firma, nad kterou se meri. Ma 50 ticketu jako kazda jina. */
const TENANT = perfTenantId(0);
/** Kolik volani se meri naraz, aby cas nebyl v desetinach milisekundy. */
const CALLS = 100;
/** Stranka seznamu jako v portalu. */
const PAGE = { limit: 50, offset: 0 };
/** Okno statistiky resitelu, stejne jako u widgetu vykonu. */
const STATS_WINDOW_MS = 30 * 86_400_000;
/** Kolik udalosti se posle na existujici tickety. */
const INTAKE_EVENTS = 1_000;
const LOOKUPS = 10_000;
/**
* Rozpocty v ms (median ze tri behu), 5x nad merenim z 2026-09-09:
* seznam 18, s filtrem 15, vytizeni 21, vykon 24, prijem 13, hledani 13.
*/
const BUDGET_LIST_MS = 100;
const BUDGET_LIST_FILTERED_MS = 100;
const BUDGET_WORKLOAD_MS = 100;
const BUDGET_AGENT_STATS_MS = 120;
const BUDGET_INTAKE_MS = 80;
const BUDGET_LOOKUP_MS = 60;
let dataset: TicketDataset;
let seedMs = 0;
beforeAll(async () => {
await seedDirectory();
const start = performance.now();
dataset = seedTickets(rng(11));
await settle();
seedMs = performance.now() - start;
process.stdout.write(
`[perf] priprava: ${TICKET_COUNT} ticketu za ${seedMs.toFixed(0)} ms ` +
`(${perSecond(TICKET_COUNT, seedMs)} ticketu/s vcetne zapisu do uloziste)\n`,
);
});
describe('listTickets nad 10 000 tickety', () => {
test('cely seznam jedne firmy', async () => {
const expected = dataset.countByTenant.get(TENANT) ?? 0;
let size = 0;
const ms = await measure(() => {
for (let index = 0; index < CALLS; index += 1) {
size = listTickets({ tenantIds: [TENANT], visibility: ALL }).length;
}
});
report(`listTickets ${CALLS}x`, ms, BUDGET_LIST_MS, `${(ms / CALLS).toFixed(2)} ms na volani`);
expect(size).toBe(expected);
expect(ms).toBeLessThan(BUDGET_LIST_MS);
});
test('filtr na stav, kanal a resitele plus strankovani', async () => {
const person = listPeople([TENANT])[0];
const visibility: Visibility = {
kind: 'scoped',
personIds: person ? [person.id] : [],
groupIds: [],
};
let page: unknown[] = [];
const ms = await measure(() => {
for (let index = 0; index < CALLS; index += 1) {
const items = listTickets({
tenantIds: [TENANT],
visibility,
status: 'Nový',
channel: 'email',
assignee: 'unassigned',
});
page = paginate(items, PAGE);
}
});
report(`listTickets s filtrem ${CALLS}x`, ms, BUDGET_LIST_FILTERED_MS);
expect(page.length).toBeLessThanOrEqual(PAGE.limit);
expect(ms).toBeLessThan(BUDGET_LIST_FILTERED_MS);
});
});
describe('statistiky', () => {
test('getWorkload', async () => {
const people = listPeople([TENANT]);
let rows = 0;
const ms = await measure(() => {
for (let index = 0; index < CALLS; index += 1) {
rows = getWorkload(people, [TENANT], ALL).rows.length;
}
});
report(`getWorkload ${CALLS}x`, ms, BUDGET_WORKLOAD_MS);
expect(rows).toBe(people.length);
expect(ms).toBeLessThan(BUDGET_WORKLOAD_MS);
});
test('getAgentStats za 30 dni', async () => {
const people = listPeople([TENANT]);
const since = Date.now() - STATS_WINDOW_MS;
let resolved = 0;
const ms = await measure(() => {
for (let index = 0; index < CALLS; index += 1) {
resolved = getAgentStats(people, [TENANT], since, ALL).reduce(
(sum, row) => sum + row.resolved,
0,
);
}
});
report(`getAgentStats ${CALLS}x`, ms, BUDGET_AGENT_STATS_MS, `${resolved} vyresenych`);
expect(ms).toBeLessThan(BUDGET_AGENT_STATS_MS);
});
});
describe('prijem udalosti', () => {
test('1 000 udalosti na existujici tickety, polovina je opakovani', async () => {
let repeated = 0;
let appended = 0;
let round = 0;
const ms = await measure(() => {
round += 1;
repeated = 0;
appended = 0;
// Kazda udalost se posle dvakrat za sebou: poprve se navesi, podruhe se
// pozna jako opakovani a jen se pricte k pocitadlu.
for (let index = 0; index < INTAKE_EVENTS; index += 1) {
const target = dataset.external[Math.floor(index / 2) % dataset.external.length];
if (!target) throw new Error('chybi ticket s externim ID');
const result = intakeEvent({
tenantId: target.tenantId,
externalId: target.externalId,
type: 'order.paid',
payload: { round, sequence: Math.floor(index / 2) },
apply: { contact: `Kontakt ${index}` },
});
if (result.repeated) repeated += 1;
else appended += 1;
expect(result.created).toBe(false);
}
});
report(
`intakeEvent ${INTAKE_EVENTS}x`,
ms,
BUDGET_INTAKE_MS,
`${perSecond(INTAKE_EVENTS, ms)} udalosti/s, ${repeated} opakovanych, ${appended} novych`,
);
expect(repeated).toBe(INTAKE_EVENTS / 2);
expect(ms).toBeLessThan(BUDGET_INTAKE_MS);
});
test('findByExternalId je hledani v indexu, ne v poli', async () => {
let found = 0;
const ms = await measure(() => {
found = 0;
for (let index = 0; index < LOOKUPS; index += 1) {
const target = dataset.external[index % dataset.external.length];
if (!target) throw new Error('chybi ticket s externim ID');
if (findByExternalId(target.tenantId, target.externalId)) found += 1;
// Cizi firma se stejnym ID nesmi nic najit.
const foreign = target.tenantId === TENANT ? perfTenantId(1) : TENANT;
if (findByExternalId(foreign, target.externalId)) found += 1_000_000;
}
});
report(`findByExternalId ${LOOKUPS}x`, ms, BUDGET_LOOKUP_MS);
expect(found).toBe(LOOKUPS);
expect(ms).toBeLessThan(BUDGET_LOOKUP_MS);
});
});
describe('hranice firmy', () => {
test('seznam jine firmy neobsahuje tickety merene firmy', () => {
const other = listTickets({ tenantIds: [perfTenantId(1)], visibility: ALL });
expect(other).toHaveLength(dataset.countByTenant.get(perfTenantId(1)) ?? 0);
expect(other.every((ticket) => ticket.tenantId === perfTenantId(1))).toBe(true);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Data widgetu pod zatezi (src/routes/widgetData.ts): prehled s 24 dlazdicemi
* nad 10 000 tickety ve 200 firmach.
*
* Vypocet zdroju (`computeSource`) neni exportovany, meri se tedy cely
* request `POST /api/dashboard/widget-data` pres supertest, vcetne
* autorizace a prav - presne to, co dostane prohlizec. Pro srovnani se meri
* i vychozi rozlozeni, ktere si na serveru pocita jen dve dlazdice.
*/
import request from 'supertest';
import { beforeAll, describe, expect, test } from 'vitest';
import type { Express } from 'express';
import { createApp } from '../../src/app.js';
import { bootstrapData } from '../../src/data/bootstrap.js';
import {
customWidgetStore,
refreshCustomWidgets,
type CustomWidget,
type WidgetSource,
} from '../../src/data/customWidgets.js';
import { nowIso } from '../../src/data/store/index.js';
import type { WidgetResult } from '../../src/shared/widgets.js';
import { perfTenantId, rng, seedDirectory, seedTickets, TICKET_COUNT } from './fixtures.js';
import { measure, report, settle } from './measure.js';
/** Firma, jejiz prehled se meri. */
const TENANT = perfTenantId(0);
/** Strop dlazdic na jeden request, viz `requestSchema` ve widgetData.ts. */
const MAX_WIDGETS = 24;
/** Vestavene dlazdice, ktere si data pocitaji na serveru (Overview.tsx, COMPUTED_BUILTINS). */
const COMPUTED_BUILTINS = ['panel.agents', 'list.myTickets', 'list.unassigned'];
/** Co posila vychozi rozlozeni: jen dve dlazdice se zdrojem na serveru. */
const DEFAULT_LAYOUT_IDS = ['list.myTickets', 'list.unassigned'];
const DEMO_ADMIN = { email: 'admin@automia.cz', password: 'demo1234' };
/** Rozpocty v ms (median ze tri behu), 5x nad merenim z 2026-09-09: 6 a 3 ms. */
const BUDGET_DASHBOARD_MS = 30;
const BUDGET_DEFAULT_MS = 15;
/** Zdroje vlastnich dlazdic: seskupene pocty, seznamy, rady, vytizeni a vykon. */
function customSources(): WidgetSource[] {
return [
{ kind: 'ticketCount', filter: {} },
{ kind: 'ticketCount', filter: { closed: false } },
{ kind: 'ticketCount', filter: { period: 'month' }, groupBy: 'status' },
{ kind: 'ticketCount', filter: { closed: false }, groupBy: 'assignee' },
{ kind: 'ticketCount', filter: {}, groupBy: 'channel' },
{ kind: 'ticketCount', filter: {}, groupBy: 'tag' },
{ kind: 'ticketCount', filter: {}, groupBy: 'type' },
{ kind: 'ticketCount', filter: {}, groupBy: 'group' },
{ kind: 'ticketCount', filter: { tags: ['vip'], closed: false } },
{ kind: 'ticketCount', filter: { channel: ['email', 'voice'] }, groupBy: 'status' },
{ kind: 'ticketCount', filter: { period: '7d' }, groupBy: 'assignee' },
{ kind: 'ticketCount', filter: { assignee: ['unassigned'] } },
{ kind: 'ticketList', filter: { closed: false }, limit: 8 },
{ kind: 'ticketList', filter: { tags: ['reklamace'] }, limit: 8 },
{ kind: 'ticketList', filter: { channel: ['voice'], closed: false }, limit: 5 },
{ kind: 'ticketSeries', filter: {}, bucket: 'day' },
{ kind: 'ticketSeries', filter: { closed: true }, bucket: 'week' },
{ kind: 'ticketSeries', filter: { channel: ['email'] }, bucket: 'day' },
{ kind: 'workload' },
{ kind: 'agentStats', period: '7d' },
{ kind: 'agentStats', period: 'all' },
];
}
function widget(index: number, source: WidgetSource): CustomWidget {
const timestamp = nowIso();
return {
id: `cw_perf_${index}`,
tenantId: TENANT,
ownerId: null,
name: `Dlazdice ${index}`,
description: '',
render:
source.kind === 'ticketList' ? 'list' : source.kind === 'ticketSeries' ? 'chart' : 'table',
source,
size: 'half',
createdAt: timestamp,
updatedAt: timestamp,
};
}
let app: Express;
let token = '';
let widgetIds: string[] = [];
beforeAll(async () => {
const mode = await bootstrapData({ databaseReady: false });
expect(mode).toBe('memory');
// Uloziste uz nastartoval bootstrap, tady se jen pridavaji zaznamy.
await seedDirectory({ initialised: true });
seedTickets(rng(23));
await settle();
const custom = customSources().map((source, index) => widget(index, source));
for (const item of custom) await customWidgetStore.create(item);
await refreshCustomWidgets();
widgetIds = [...COMPUTED_BUILTINS, ...custom.map((item) => item.id)];
expect(widgetIds).toHaveLength(MAX_WIDGETS);
app = createApp();
const login = await request(app).post('/api/auth/login').send(DEMO_ADMIN);
expect(login.status).toBe(200);
token = login.body.token as string;
});
async function widgetData(ids: string[]): Promise<WidgetResult[]> {
const response = await request(app)
.post(`/api/dashboard/widget-data?tenantId=${TENANT}`)
.set('Authorization', `Bearer ${token}`)
.send({ widgetIds: ids });
expect(response.status).toBe(200);
return response.body.items as WidgetResult[];
}
describe('POST /api/dashboard/widget-data', () => {
test(`prehled s ${MAX_WIDGETS} dlazdicemi nad ${TICKET_COUNT} tickety`, async () => {
let items: WidgetResult[] = [];
const ms = await measure(async () => {
items = await widgetData(widgetIds);
});
report(`widget-data ${MAX_WIDGETS} dlazdic`, ms, BUDGET_DASHBOARD_MS);
expect(items).toHaveLength(MAX_WIDGETS);
const failed = items.filter((item) => !item.ok);
expect(failed).toEqual([]);
expect(ms).toBeLessThan(BUDGET_DASHBOARD_MS);
});
test('vychozi rozlozeni: dve dlazdice se zdrojem na serveru', async () => {
let items: WidgetResult[] = [];
const ms = await measure(async () => {
items = await widgetData(DEFAULT_LAYOUT_IDS);
});
report('widget-data vychozi rozlozeni', ms, BUDGET_DEFAULT_MS);
expect(items.every((item) => item.ok)).toBe(true);
expect(ms).toBeLessThan(BUDGET_DEFAULT_MS);
});
test('dlazdice cizi firmy se nevrati', async () => {
const response = await request(app)
.post(`/api/dashboard/widget-data?tenantId=${perfTenantId(1)}`)
.set('Authorization', `Bearer ${token}`)
.send({ widgetIds: ['cw_perf_0'] });
expect(response.status).toBe(200);
const [item] = response.body.items as WidgetResult[];
expect(item?.ok).toBe(false);
});
});