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
+184
View File
@@ -0,0 +1,184 @@
/**
* Nastaveni z repozitare (src/data/store/seedFile.ts).
*
* Bezi v rezimu souboru nad docasnou slozkou, aby slo pred startem podstrcit
* neprazdne uloziste (snapshot `ticketType.json`) a vedle nej seed soubor.
* Pak se spusti stejny bootstrap jako v `src/index.ts` a overi se, co ktere
* uloziste dostalo. `DATA_DIR` a `SEED_FILE` se ctou pri importu konfigurace,
* proto dynamicke importy.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'csbot-seed-'));
const dataDir = path.join(dir, 'data');
const seedPath = path.join(dir, 'records.json');
process.env.DATA_DIR = dataDir;
process.env.SEED_FILE = seedPath;
type Tenants = typeof import('../../src/data/tenants.js');
type TicketTypes = typeof import('../../src/data/ticketTypes.js');
type Widgets = typeof import('../../src/data/customWidgets.js');
type Stores = typeof import('../../src/data/store/index.js');
type SeedFile = typeof import('../../src/data/store/seedFile.js');
let tenants: Tenants;
let ticketTypes: TicketTypes;
let widgets: Widgets;
let stores: Stores;
let seedFile: SeedFile;
/** Co start zalogoval pres `console.warn`. */
let warnings: string[] = [];
const now = '2026-09-09T08:00:00.000Z';
function snapshot(kind: string, items: unknown[]): void {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(
path.join(dataDir, `${kind}.json`),
JSON.stringify({ version: 1, savedAt: now, items }, null, 2),
'utf8',
);
}
function ticketType(id: string, key: string) {
return {
id,
tenantId: 'tnt_seed',
key,
name: key,
icon: 'Tag',
statuses: [],
fields: [],
createdAt: now,
updatedAt: now,
};
}
beforeAll(async () => {
// Typy ticketu uz v ulozisti jsou: soubor je nesmi prepsat.
snapshot('ticketType', [ticketType('tt_existing', 'existing')]);
fs.writeFileSync(
seedPath,
JSON.stringify({
exportedAt: now,
kinds: {
tenant: [
{
id: 'tnt_seed',
tenantId: null,
name: 'Firma ze souboru',
note: '',
enabled: true,
intakeToken: 'seed-intake-token-0123456789',
helpdeskProviderId: null,
createdAt: now,
updatedAt: now,
},
],
ticketType: [ticketType('tt_fromfile', 'fromfile')],
// Prazdny seznam znamena "zadne", ne "doplnte vychozi z kodu".
customWidget: [],
// Provozni data se ze souboru nenacitaji.
ticket: [{ id: 'TK-1', tenantId: 'tnt_seed', subject: 'x' }],
neznamy: [{ id: 'x' }],
},
}),
'utf8',
);
const bootstrap = await import('../../src/data/bootstrap.js');
tenants = await import('../../src/data/tenants.js');
ticketTypes = await import('../../src/data/ticketTypes.js');
widgets = await import('../../src/data/customWidgets.js');
stores = await import('../../src/data/store/index.js');
seedFile = await import('../../src/data/store/seedFile.js');
const mode = await bootstrap.bootstrapData({ databaseReady: false });
// Volani se berou hned po startu: vitest mock mezi testy cisti.
warnings = vi.mocked(console.warn).mock.calls.map((call) => String(call[0]));
expect(mode).toBe('file');
await stores.flushStores();
});
afterAll(async () => {
await stores?.flushStores();
fs.rmSync(dir, { recursive: true, force: true });
});
describe('seed ze souboru pri startu', () => {
test('prazdne uloziste dostane zaznamy ze souboru misto seedu z kodu', () => {
const ids = tenants.listTenants().map((tenant) => tenant.id);
expect(ids).toEqual(['tnt_seed']);
expect(tenants.findByIntakeToken('seed-intake-token-0123456789')?.name).toBe(
'Firma ze souboru',
);
});
test('neprazdne uloziste si necha svoje zaznamy', () => {
expect(ticketTypes.findTicketType('tt_existing')).toBeDefined();
expect(ticketTypes.findTicketType('tt_fromfile')).toBeUndefined();
});
test('druh v souboru vyhrava, i kdyz je prazdny', async () => {
expect(widgets.seedCustomWidgets().length).toBeGreaterThan(0);
expect(await widgets.customWidgetStore.listAll()).toEqual([]);
});
test('druh mimo konfiguraci se ignoruje a zaloguje', async () => {
expect(warnings.some((line) => line.includes('druh ticket se nenacita'))).toBe(true);
expect(warnings.some((line) => line.includes('druh neznamy se nenacita'))).toBe(true);
expect(await stores.storeByKind('ticket')?.listAll()).toEqual([]);
});
test('soubor se cte jednou a uloziste dostava kopii', () => {
const first = seedFile.seedFromFile<{ id: string; tenantId: null; createdAt: string; updatedAt: string }>(
'tenant',
)?.();
const second = seedFile.seedFromFile<{ id: string; tenantId: null; createdAt: string; updatedAt: string }>(
'tenant',
)?.();
expect(first).toEqual(second);
expect(first?.[0]).not.toBe(second?.[0]);
});
});
describe('seedFromFile bez pouzitelneho souboru', () => {
const fallback = () => [{ id: 'tnt_code', tenantId: null, createdAt: now, updatedAt: now }];
test('chybejici soubor je ticho a jede se z kodu', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const seed = seedFile.seedFromFile('tenant', fallback, path.join(dir, 'neni.json'));
expect(seed?.()).toEqual(fallback());
expect(errors).not.toHaveBeenCalled();
errors.mockRestore();
});
test('rozbity JSON se zaloguje a jede se z kodu', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const file = path.join(dir, 'rozbity.json');
fs.writeFileSync(file, '{ "kinds": ', 'utf8');
const seed = seedFile.seedFromFile('tenant', fallback, file);
expect(seed?.()).toEqual(fallback());
expect(errors).toHaveBeenCalledWith(expect.stringContaining('neni platny JSON'), expect.anything());
errors.mockRestore();
});
test('zaznam bez id je spatny tvar a jede se z kodu', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const file = path.join(dir, 'bez-id.json');
fs.writeFileSync(file, JSON.stringify({ kinds: { tenant: [{ name: 'x' }] } }), 'utf8');
expect(seedFile.loadSeedFile(file)).toBeNull();
expect(seedFile.seedFromFile('tenant', fallback, file)?.()).toEqual(fallback());
expect(errors).toHaveBeenCalledWith(expect.stringContaining('kinds.tenant.0.id'));
errors.mockRestore();
});
test('bez souboru a bez seedu z kodu neni co nasypat', () => {
expect(seedFile.seedFromFile('tenantScript', undefined, path.join(dir, 'neni.json'))).toBeUndefined();
});
});