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:
co-authored by
Claude Fable 5.1
parent
c25e826766
commit
ea9387bea9
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user