Struktura podle zasad: rozdeleni souboru, lint, testy, prisny TypeScript

Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.

Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.

Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.

Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.

Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
This commit is contained in:
JiriUhlir
2026-09-09 15:11:02 +02:00
parent 42b3da8303
commit 22dda2d139
312 changed files with 21085 additions and 15319 deletions
+220
View File
@@ -0,0 +1,220 @@
/**
* Co uzivatel vidi a smi (src/data/access.ts): pohledy, resitel, strop
* viditelnosti. Uloziste v pameti, vychozi sada firem, roli a vlastni skupiny.
*/
import { beforeAll, describe, expect, test } from 'vitest';
import { accessFor, isDenied, resolveScope, visibilityFor } from '../../src/data/access.js';
import { groupStore, refreshGroups } from '../../src/data/people.js';
import { refreshRoles, roleStore, systemRoles } 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, seedTenants, tenantStore } from '../../src/data/tenants.js';
import { refreshUsers, seedUsers, userStore } from '../../src/data/users.js';
import type { User } from '../../src/types.js';
function user(overrides: Partial<User>): User {
return {
id: 'usr_x',
email: 'x@example.cz',
passwordHash: '',
name: 'X',
platformAdmin: false,
memberships: [],
...overrides,
};
}
beforeAll(async () => {
initStores({ databaseReady: false });
await tenantStore.init(seedTenants);
await userStore.init(seedUsers);
await roleStore.init(systemRoles);
await featuresStore.init(seedFeatures);
await groupStore.init(() => {
const timestamp = nowIso();
return [
{
id: 'grp_sklad',
tenantId: 'tnt_automia',
name: 'Sklad',
members: [
{ personId: 'usr_vedouci', seesAll: true },
{ personId: 'usr_skladnik', seesAll: false },
],
createdAt: timestamp,
updatedAt: timestamp,
},
{
id: 'grp_ucetni',
tenantId: 'tnt_automia',
name: 'Ucetni',
members: [{ personId: 'usr_skladnik', seesAll: false }],
createdAt: timestamp,
updatedAt: timestamp,
},
];
});
await Promise.all([refreshTenants(), refreshUsers(), refreshRoles(), refreshFeatures(), refreshGroups()]);
});
describe('accessFor', () => {
test('clen firmy ma pohledy tenant a mine a je resitelem pod ID uctu', () => {
const member = user({
id: 'usr_clen',
memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'] }],
});
const access = accessFor(member);
expect(access.scopes).toEqual(['tenant', 'mine']);
expect(access.defaultTenantId).toBe('tnt_nordis');
expect(access.personId).toBe('usr_clen');
expect(access.tenants.map((tenant) => tenant.id)).toEqual(['tnt_nordis']);
expect(access.roleNames).toEqual(['Řešitel']);
expect(access.platformAdmin).toBe(false);
});
test('spravce platformy bez clenstvi ma pohled all, ale neni resitel', () => {
const admin = user({ id: 'usr_platforma', platformAdmin: true });
const access = accessFor(admin, 'tnt_nordis');
expect(access.scopes).toEqual(['all', 'tenant']);
expect(access.personId).toBeNull();
expect(access.tenants.length).toBe(3);
expect(access.permissions).toContain('tenant.manage');
});
test('ucet bez clenstvi nema zadny pohled ani firmu', () => {
const nobody = user({ id: 'usr_nikdo' });
const access = accessFor(nobody);
expect(access.scopes).toEqual([]);
expect(access.defaultTenantId).toBeNull();
expect(access.personId).toBeNull();
expect(access.permissions).toEqual([]);
});
test('prava se pocitaji za vybranou firmu, ne za soucet clenstvi', () => {
const external = user({
id: 'usr_externista',
memberships: [
{ tenantId: 'tnt_automia', roleIds: ['agent'] },
{ tenantId: 'tnt_nordis', roleIds: ['admin'] },
],
});
expect(accessFor(external, 'tnt_nordis').canAssignOthers).toBe(true);
expect(accessFor(external, 'tnt_automia').canAssignOthers).toBe(false);
expect(accessFor(external, 'tnt_automia').roleNames).toEqual(['Řešitel']);
});
test('clenstvi v nezname firme se nepocita', () => {
const member = user({
id: 'usr_ghost',
memberships: [{ tenantId: 'tnt_neexistuje', roleIds: ['admin'] }],
});
const access = accessFor(member);
expect(access.tenants).toEqual([]);
expect(access.scopes).toEqual([]);
});
});
describe('visibilityFor', () => {
test('spravce platformy vidi vse, clen bez firmy nic', () => {
expect(visibilityFor(user({ platformAdmin: true }), 'tnt_automia')).toEqual({ kind: 'all' });
expect(visibilityFor(user({ id: 'usr_a' }), null)).toEqual({
kind: 'scoped',
personIds: [],
groupIds: [],
});
});
test('seesAllTenant na clenstvi otevira celou firmu', () => {
const boss = user({
id: 'usr_boss',
memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], seesAllTenant: true }],
});
expect(visibilityFor(boss, 'tnt_automia').kind).toBe('all');
});
test('starsi zaznam bez priznaku: kdo smi prehazovat, vidi vse, resitel jen sebe', () => {
const admin = user({ id: 'usr_old_admin', memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'] }] });
const agent = user({ id: 'usr_old_agent', memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'] }] });
expect(visibilityFor(admin, 'tnt_automia').kind).toBe('all');
expect(visibilityFor(agent, 'tnt_automia')).toEqual({
kind: 'scoped',
personIds: ['usr_old_agent'],
groupIds: [],
});
});
test('vedouci sekce vidi frontu skupiny a jeji lidi, radovy clen jen sebe', () => {
const lead = user({
id: 'usr_vedouci',
memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], seesAllTenant: false }],
});
const worker = user({
id: 'usr_skladnik',
memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], seesAllTenant: false }],
});
const leadView = visibilityFor(lead, 'tnt_automia');
expect(leadView.kind).toBe('scoped');
if (leadView.kind === 'scoped') {
expect(leadView.groupIds).toEqual(['grp_sklad']);
expect([...leadView.personIds].sort()).toEqual(['usr_skladnik', 'usr_vedouci']);
}
// Skladnik je ve dvou skupinach, ale ani jednu nevede.
expect(visibilityFor(worker, 'tnt_automia')).toEqual({
kind: 'scoped',
personIds: ['usr_skladnik'],
groupIds: [],
});
const access = accessFor(lead, 'tnt_automia');
expect(access.seesOthers).toBe(true);
expect(access.visibleGroups.map((group) => group.id)).toEqual(['grp_sklad']);
});
test('seesAllTenant false vitezi nad pravem prehazovat', () => {
const limited = user({
id: 'usr_limited',
memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'], seesAllTenant: false }],
});
expect(visibilityFor(limited, 'tnt_automia').kind).toBe('scoped');
});
});
describe('resolveScope', () => {
const member = user({ id: 'usr_scope', memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'] }] });
test('pohled all je jen pro spravce platformy', () => {
const denied = resolveScope(member, { scope: 'all' });
expect(isDenied(denied)).toBe(true);
if (isDenied(denied)) expect(denied.status).toBe(403);
});
test('cizi firma je 404, ne tiche prepnuti', () => {
const denied = resolveScope(member, { scope: 'tenant', tenantId: 'tnt_automia' });
expect(isDenied(denied)).toBe(true);
if (isDenied(denied)) expect(denied.status).toBe(404);
});
test('vlastni firma vraci strop a resitele', () => {
const resolved = resolveScope(member, {});
expect(isDenied(resolved)).toBe(false);
if (!isDenied(resolved)) {
expect(resolved.scope).toBe('tenant');
expect(resolved.tenantIds).toEqual(['tnt_nordis']);
expect(resolved.personId).toBe('usr_scope');
expect(resolved.visibility).toEqual({ kind: 'scoped', personIds: ['usr_scope'], groupIds: [] });
}
});
test('neznamy pohled se odmitne', () => {
const denied = resolveScope(member, { scope: 'vsechno' });
expect(isDenied(denied) && denied.error).toBe('unknown_scope');
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* Migrace starych resitelu na ucty (src/data/migratePeople.ts).
*
* Stare zaznamy zije jen v soukromem ulozisti `person`, do ktereho se
* v rezimu pameti zvenku nedostane. Test proto bezi v rezimu souboru
* nad docasnou slozkou: pred startem do ni zapise snapshoty (resitele,
* tickety, skupiny, automatizace), pak spusti stejny bootstrap jako
* `src/index.ts` a po nem se podiva, co zustalo.
*
* `DATA_DIR` se nastavuje pred importem modulu, proto dynamicke importy.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'csbot-migrace-'));
process.env.DATA_DIR = dir;
type Users = typeof import('../../src/data/users.js');
type Tickets = typeof import('../../src/data/ticketStore.js');
type People = typeof import('../../src/data/people.js');
type Automations = typeof import('../../src/data/automationStore.js');
type Stores = typeof import('../../src/data/store/index.js');
let users: Users;
let ticketStore: Tickets;
let people: People;
let automations: Automations;
let stores: Stores;
const now = '2026-09-01T10:00:00.000Z';
function snapshot(kind: string, items: unknown[]): void {
fs.writeFileSync(
path.join(dir, `${kind}.json`),
JSON.stringify({ version: 1, savedAt: now, items }, null, 2),
'utf8',
);
}
function ticket(id: string, tenantId: string, assigneeId: string | null, resolvedById: string | null = null) {
return {
id,
tenantId,
subject: `Ticket ${id}`,
body: '',
sourceRef: null,
channel: 'email',
customer: { id: null, company: '', contact: '', reply: '' },
status: 'Nový',
priority: 'normal',
assigneeId,
resolvedById,
createdAt: now,
updatedAt: now,
trace: [],
events: [],
};
}
beforeAll(async () => {
snapshot('person', [
// Novy clovek: ucet neexistuje, zalozi se.
{ id: 'ppl_1', tenantId: 'tnt_nordis', name: 'Pavel Nový', email: 'Pavel.Novy@nordis.cz', role: 'Sklad', capacity: 4, externalIds: ['ext-7'], createdAt: now, updatedAt: now },
// Existujici ucet (usr_2) bez clenstvi v LogiTrans: clenstvi pribude.
{ id: 'ppl_2', tenantId: 'tnt_logitrans', name: 'Karel Vomáčka', email: 'karel.vomacka@automia.cz', role: 'Externista', capacity: 3, createdAt: now, updatedAt: now },
// Bez e-mailu: ucet s nahradni adresou, aby tickety neprisly o resitele.
{ id: 'ppl_3', tenantId: 'tnt_nordis', name: 'Bez Mailu', email: '', enabled: false, createdAt: now, updatedAt: now },
]);
snapshot('ticket', [
ticket('TK-1', 'tnt_nordis', 'ppl_1'),
ticket('TK-2', 'tnt_logitrans', null, 'ppl_2'),
ticket('TK-3', 'tnt_nordis', 'usr_bartos'),
]);
snapshot('personGroup', [
{ id: 'grp_a', tenantId: 'tnt_nordis', name: 'Sklad', members: [{ personId: 'ppl_1', seesAll: true }, { personId: 'usr_bartos', seesAll: false }], createdAt: now, updatedAt: now },
{ id: 'grp_b', tenantId: 'tnt_nordis', name: 'Stara', personIds: ['ppl_1', 'ppl_3'], createdAt: now, updatedAt: now },
]);
snapshot('automation', [
{
id: 'AUT-01',
tenantId: 'tnt_nordis',
name: 'Prirazeni',
kind: 'workflow',
enabled: false,
runsToday: 0,
successRate: 100,
avgDurationMs: 0,
lastRunAt: now,
flow: {
trigger: null,
steps: [
{ id: 'st_1', kind: 'action', serviceId: 'ticket', operationId: 'assign', inputs: { assigneeId: 'ppl_1', note: 'ppl_1x' } },
],
},
createdAt: now,
updatedAt: now,
},
]);
const bootstrap = await import('../../src/data/bootstrap.js');
users = await import('../../src/data/users.js');
ticketStore = await import('../../src/data/ticketStore.js');
people = await import('../../src/data/people.js');
automations = await import('../../src/data/automationStore.js');
stores = await import('../../src/data/store/index.js');
const mode = await bootstrap.bootstrapData({ databaseReady: false });
expect(mode).toBe('file');
await stores.flushStores();
});
afterAll(async () => {
await stores?.flushStores();
fs.rmSync(dir, { recursive: true, force: true });
});
describe('migratePeople', () => {
test('novy resitel dostane ucet s clenstvim, popiskem, kapacitou a externim ID', () => {
const account = users.findStoredUserByEmail('pavel.novy@nordis.cz');
expect(account).toBeDefined();
expect(account!.id).toMatch(/^usr_/);
expect(account!.enabled).toBe(true);
expect(account!.platformAdmin).toBe(false);
expect(account!.memberships).toEqual([
{ tenantId: 'tnt_nordis', roleIds: ['role_agent'], role: 'Sklad', capacity: 4, externalIds: ['ext-7'] },
]);
expect(people.findPersonByExternalId('ext-7', ['tnt_nordis'])?.id).toBe(account!.id);
});
test('existujici ucet dostane jen nove clenstvi, stara zustavaji', () => {
const karel = users.findStoredUser('usr_2')!;
expect(karel.memberships.map((m) => m.tenantId).sort()).toEqual(['tnt_automia', 'tnt_logitrans', 'tnt_nordis']);
const added = karel.memberships.find((m) => m.tenantId === 'tnt_logitrans')!;
expect(added).toMatchObject({ roleIds: ['role_agent'], role: 'Externista', capacity: 3, externalIds: [] });
expect(karel.memberships.find((m) => m.tenantId === 'tnt_automia')?.roleIds).toEqual(['agent']);
});
test('resitel bez e-mailu dostane nahradni adresu a vypnuty ucet', () => {
const account = users.findStoredUserByEmail('ppl_3@placeholder.cz');
expect(account).toBeDefined();
expect(account!.enabled).toBe(false);
expect(account!.memberships[0]?.tenantId).toBe('tnt_nordis');
});
test('tickety odkazuji na ucty, cizi resitele zustavaji', () => {
const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!;
expect(ticketStore.findTicket('TK-1', ['tnt_nordis'])?.assignee?.id).toBe(pavel.id);
expect(ticketStore.findTicket('TK-2', ['tnt_logitrans'])?.resolvedById).toBe('usr_2');
expect(ticketStore.findTicket('TK-3', ['tnt_nordis'])?.assignee?.id).toBe('usr_bartos');
});
test('skupiny v nove i stare podobe maji preznacene cleny', () => {
const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!;
const bezMailu = users.findStoredUserByEmail('ppl_3@placeholder.cz')!;
expect(people.findGroup('grp_a')?.members).toEqual([
{ personId: pavel.id, seesAll: true },
{ personId: 'usr_bartos', seesAll: false },
]);
expect(people.findGroup('grp_b')?.personIds).toEqual([pavel.id, bezMailu.id]);
});
test('strom automatizace ma nove ID, podretezec se nesaha', () => {
const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!;
const detail = automations.getAutomation('AUT-01', ['tnt_nordis'])!;
const step = detail.flow.steps[0];
expect(step?.kind).toBe('action');
if (step?.kind === 'action') {
expect(step.inputs?.assigneeId).toBe(pavel.id);
expect(step.inputs?.note).toBe('ppl_1x');
}
});
test('stare zaznamy jsou po migraci smazane a zapsane na disk', () => {
const file = JSON.parse(fs.readFileSync(path.join(dir, 'person.json'), 'utf8')) as { items: unknown[] };
expect(file.items).toEqual([]);
const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!;
const tickets = fs.readFileSync(path.join(dir, 'ticket.json'), 'utf8');
expect(tickets).toContain(pavel.id);
expect(tickets).not.toContain('"ppl_1"');
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Prava za firmu (src/data/permissions.ts).
*
* Rezim pameti: `DATA_DIR` je prazdny (tests/setup.ts), takze `roleStore`
* drzi zaznamy jen v procesu a kazdy soubor testu ma vlastni kopii.
*/
import { beforeAll, describe, expect, test } from 'vitest';
import {
hasPermission,
invalidatePermissions,
permissionsOf,
refreshRoles,
roleStore,
syncSystemRoles,
systemRoles,
type Role,
} from '../../src/data/permissions.js';
import { initStores, nowIso } from '../../src/data/store/index.js';
import type { User } from '../../src/types.js';
function user(overrides: Partial<User>): User {
return {
id: 'usr_test',
email: 'test@example.cz',
passwordHash: '',
name: 'Test',
platformAdmin: false,
memberships: [],
...overrides,
};
}
/** Vlastni role jedne firmy. */
function customRole(tenantId: string, id: string, permissions: string[]): Role {
const timestamp = nowIso();
return {
id,
tenantId,
key: id,
name: `Role ${id}`,
description: '',
permissions,
system: false,
createdAt: timestamp,
updatedAt: timestamp,
};
}
beforeAll(async () => {
initStores({ databaseReady: false });
await roleStore.init(systemRoles);
await roleStore.create(customRole('tnt_a', 'role_a_reader', ['ticket.view', 'ticket.comment']));
await roleStore.create(customRole('tnt_b', 'role_b_boss', ['ticket.assign.others', 'user.manage']));
await refreshRoles();
});
describe('permissionsOf', () => {
test('clen dvou firem ma v kazde jen prava sve role', () => {
const member = user({
memberships: [
{ tenantId: 'tnt_a', roleIds: ['agent'] },
{ tenantId: 'tnt_b', roleIds: ['role_admin'] },
],
});
expect(hasPermission(member, 'user.manage', 'tnt_b')).toBe(true);
expect(hasPermission(member, 'user.manage', 'tnt_a')).toBe(false);
expect(hasPermission(member, 'ticket.view', 'tnt_a')).toBe(true);
// Spravce firmy nema platformni prava ani ve sve firme.
expect(hasPermission(member, 'tenant.manage', 'tnt_b')).toBe(false);
});
test('spravce platformy ma vsechno bez ohledu na firmu', () => {
const admin = user({ platformAdmin: true });
expect(hasPermission(admin, 'tenant.manage', 'tnt_a')).toBe(true);
expect(hasPermission(admin, 'ticket.view', null)).toBe(true);
expect(hasPermission(admin, 'user.manage', 'tnt_neexistuje')).toBe(true);
});
test('neznama firma nebo zadna firma znamena zadna prava', () => {
const member = user({ memberships: [{ tenantId: 'tnt_a', roleIds: ['admin'] }] });
expect(permissionsOf(member, 'tnt_neexistuje').size).toBe(0);
expect(permissionsOf(member, null).size).toBe(0);
});
test('role cizi firmy v clenstvi se ignoruje', () => {
const member = user({
id: 'usr_cizi_role',
memberships: [{ tenantId: 'tnt_a', roleIds: ['role_b_boss', 'role_a_reader'] }],
});
const owned = permissionsOf(member, 'tnt_a');
expect(owned.has('ticket.view')).toBe(true);
expect(owned.has('ticket.assign.others')).toBe(false);
expect(owned.has('user.manage')).toBe(false);
});
test('odkaz na roli smi byt ID i klic systemove role', () => {
const byKey = user({ id: 'usr_key', memberships: [{ tenantId: 'tnt_a', roleIds: ['agent'] }] });
const byId = user({ id: 'usr_id', memberships: [{ tenantId: 'tnt_a', roleIds: ['role_agent'] }] });
expect([...permissionsOf(byKey, 'tnt_a')].sort()).toEqual([...permissionsOf(byId, 'tnt_a')].sort());
});
test('neznama role a nezname pravo se preskoci, prihlaseni nespadne', async () => {
await roleStore.create(customRole('tnt_a', 'role_a_stale', ['ticket.view', 'zmizele.pravo']));
await refreshRoles();
const member = user({
id: 'usr_stale',
memberships: [{ tenantId: 'tnt_a', roleIds: ['role_a_stale', 'role_smazana'] }],
});
expect([...permissionsOf(member, 'tnt_a')]).toEqual(['ticket.view']);
});
});
describe('syncSystemRoles', () => {
test('srovna prava systemove role s kodem a vlastni role necha byt', async () => {
const admin = systemRoles().find((role) => role.id === 'role_admin')!;
// Instalace, kde spravci firmy chybi nove pravo a nekdo roli prejmenoval.
await roleStore.update(
'role_admin',
{ name: 'Vedeni', permissions: admin.permissions.filter((p) => p !== 'helpdesk.view') },
{ tenantIds: [], includeGlobal: true },
);
await refreshRoles();
invalidatePermissions();
await syncSystemRoles();
const stored = new Map((await roleStore.listAll()).map((role) => [role.id, role]));
expect([...stored.get('role_admin')!.permissions].sort()).toEqual([...admin.permissions].sort());
// Nazev se neprepisuje, jen prava.
expect(stored.get('role_admin')!.name).toBe('Vedeni');
// Vlastni role firem zustavaji, jak byly.
expect(stored.get('role_a_reader')!.permissions).toEqual(['ticket.view', 'ticket.comment']);
expect(stored.get('role_b_boss')!.permissions).toEqual(['ticket.assign.others', 'user.manage']);
});
test('stejna prava znamenaji zadny zapis', async () => {
const before = (await roleStore.listAll()).find((role) => role.id === 'role_agent')!.updatedAt;
await syncSystemRoles();
const after = (await roleStore.listAll()).find((role) => role.id === 'role_agent')!.updatedAt;
expect(after).toBe(before);
});
});
+201
View File
@@ -0,0 +1,201 @@
/**
* Uloziste ticketu (src/data/tickets): prijem udalosti, filtr na firmu
* a zmena stavu. Uloziste v pameti, ucty z vychozi sady.
*/
import { beforeAll, describe, expect, test } from 'vitest';
import { initStores } from '../../src/data/store/index.js';
import {
assignTicket,
createTicket,
defaultStatuses,
findByExternalId,
getTicket,
initTickets,
intakeEvent,
listTickets,
updateTicketStatus,
} from '../../src/data/ticketStore.js';
import { refreshUsers, seedUsers, userStore } from '../../src/data/users.js';
const ALL = { kind: 'all' } as const;
beforeAll(async () => {
initStores({ databaseReady: false });
await userStore.init(seedUsers);
await refreshUsers();
await initTickets();
});
describe('intakeEvent', () => {
test('prvni udalost zaklada ticket s vychozim stavem', () => {
const result = intakeEvent({
tenantId: 'tnt_automia',
externalId: 'ORD-1',
type: 'order.created',
label: 'Objednávka vznikla',
payload: { total: 100 },
apply: { company: 'Firma s.r.o.', channel: 'email' },
});
expect(result.created).toBe(true);
expect(result.repeated).toBe(false);
expect(result.ticket.tenantId).toBe('tnt_automia');
expect(result.ticket.status).toBe(defaultStatuses[0]);
expect(result.ticket.closed).toBe(false);
expect(result.ticket.subject).toBe('Objednávka vznikla');
expect(result.ticket.customer.company).toBe('Firma s.r.o.');
expect(getTicket(result.ticket.id, ['tnt_automia'])?.events).toHaveLength(1);
});
test('dalsi udalost se stejnym externim ID ve stejne firme se navesi na ticket', () => {
const first = findByExternalId('tnt_automia', 'ORD-1')!;
const result = intakeEvent({
tenantId: 'tnt_automia',
externalId: 'ORD-1',
type: 'order.paid',
payload: { paid: true },
// Prazdna hodnota nemaze, co uz na ticketu je.
apply: { company: '', contact: 'Jan Novák' },
addTags: ['zaplaceno'],
});
expect(result.created).toBe(false);
expect(result.repeated).toBe(false);
expect(result.ticket.id).toBe(first.id);
expect(result.ticket.customer.company).toBe('Firma s.r.o.');
expect(result.ticket.customer.contact).toBe('Jan Novák');
expect(result.ticket.tags).toEqual(['zaplaceno']);
expect(getTicket(first.id, ['tnt_automia'])?.events).toHaveLength(2);
});
test('presne stejna udalost jako posledne se jen pricte a ticket se nemeni', () => {
const before = findByExternalId('tnt_automia', 'ORD-1')!;
const result = intakeEvent({
tenantId: 'tnt_automia',
externalId: 'ORD-1',
type: 'order.paid',
payload: { paid: true },
});
expect(result.repeated).toBe(true);
expect(result.created).toBe(false);
expect(result.event.repeats).toBe(2);
expect(result.ticket.updatedAt).toBe(before.updatedAt);
expect(getTicket(before.id, ['tnt_automia'])?.events).toHaveLength(2);
});
test('stejne externi ID v jine firme je jiny ticket', () => {
const result = intakeEvent({ tenantId: 'tnt_nordis', externalId: 'ORD-1', type: 'order.created' });
expect(result.created).toBe(true);
expect(result.ticket.id).not.toBe(findByExternalId('tnt_automia', 'ORD-1')!.id);
expect(findByExternalId('tnt_nordis', 'ORD-1')?.id).toBe(result.ticket.id);
});
test('bez externiho ID vznika pokazde novy ticket', () => {
const a = intakeEvent({ tenantId: 'tnt_automia', externalId: null, type: 'form.sent' });
const b = intakeEvent({ tenantId: 'tnt_automia', externalId: null, type: 'form.sent' });
expect(a.created).toBe(true);
expect(b.created).toBe(true);
expect(a.ticket.id).not.toBe(b.ticket.id);
});
test('stav zadany pri zalozeni se pouzije a neznamy resitel se zahodi', () => {
const result = intakeEvent({
tenantId: 'tnt_automia',
externalId: 'CALL-9',
type: 'call.started',
create: { status: 'in-progress' },
apply: { assigneeId: 'usr_neexistuje' },
});
expect(result.ticket.status).toBe('in-progress');
expect(result.ticket.assignee).toBeNull();
});
});
describe('listTickets', () => {
test('filtr na firmu je povinny a cizi firma nic nedostane', () => {
const automia = listTickets({ tenantIds: ['tnt_automia'], visibility: ALL });
expect(automia.length).toBeGreaterThan(0);
expect(automia.every((ticket) => ticket.tenantId === 'tnt_automia')).toBe(true);
expect(listTickets({ tenantIds: ['tnt_logitrans'], visibility: ALL })).toEqual([]);
expect(listTickets({ tenantIds: [], visibility: ALL })).toEqual([]);
const nordis = listTickets({ tenantIds: ['tnt_nordis'], visibility: ALL });
expect(nordis.map((ticket) => ticket.externalId)).toEqual(['ORD-1']);
});
test('strop viditelnosti schova cizi prirazene tickety, nezarazene vidi kazdy', () => {
const mine = createTicket({ tenantId: 'tnt_automia', subject: 'moje', channel: 'portal', priority: 'normal', assigneeId: 'usr_3' });
const theirs = createTicket({ tenantId: 'tnt_automia', subject: 'cizi', channel: 'portal', priority: 'normal', assigneeId: 'usr_2' });
const queue = createTicket({ tenantId: 'tnt_automia', subject: 'fronta', channel: 'portal', priority: 'normal' });
const visible = listTickets({
tenantIds: ['tnt_automia'],
visibility: { kind: 'scoped', personIds: ['usr_3'], groupIds: [] },
}).map((ticket) => ticket.id);
expect(visible).toContain(mine.id);
expect(visible).toContain(queue.id);
expect(visible).not.toContain(theirs.id);
expect(getTicket(theirs.id, ['tnt_automia'], [], { kind: 'scoped', personIds: ['usr_3'], groupIds: [] })).toBeUndefined();
});
test('detail cizi firmy je nedostupny', () => {
const ticket = findByExternalId('tnt_automia', 'ORD-1')!;
expect(getTicket(ticket.id, ['tnt_nordis'])).toBeUndefined();
expect(getTicket(ticket.id, ['tnt_automia'])?.id).toBe(ticket.id);
});
});
describe('updateTicketStatus', () => {
test('vyrizeni nastavi closed, resolvedAt a resitele, ktery ho mel u sebe', () => {
const ticket = createTicket({ tenantId: 'tnt_automia', subject: 'stav', channel: 'email', priority: 'high' });
expect(assignTicket(ticket.id, 'usr_2', ['tnt_automia'])?.assignee?.id).toBe('usr_2');
// Text stavu sam o sobe nic nevyrizuje, o tom rozhoduje priznak.
const renamed = updateTicketStatus(ticket.id, defaultStatuses[3], ['tnt_automia']);
expect(renamed?.status).toBe(defaultStatuses[3]);
expect(renamed?.closed).toBe(false);
expect(renamed?.resolvedAt).toBeNull();
const closed = updateTicketStatus(ticket.id, defaultStatuses[3], ['tnt_automia'], true)!;
expect(closed.closed).toBe(true);
expect(closed.resolvedAt).not.toBeNull();
expect(closed.resolvedById).toBe('usr_2');
expect(closed.firstResponseAt).not.toBeNull();
const reopened = updateTicketStatus(ticket.id, defaultStatuses[1], ['tnt_automia'], false)!;
expect(reopened.closed).toBe(false);
expect(reopened.resolvedAt).toBeNull();
expect(reopened.resolvedById).toBeNull();
expect(reopened.reopenCount).toBe(1);
});
test('vyrizene tickety jdou v seznamu dolu', () => {
const done = createTicket({ tenantId: 'tnt_automia', subject: 'hotovo', channel: 'email', priority: 'low' });
updateTicketStatus(done.id, defaultStatuses[3], ['tnt_automia'], true);
const list = listTickets({ tenantIds: ['tnt_automia'], visibility: ALL });
const firstClosed = list.findIndex((ticket) => ticket.closed);
const lastOpen = list.map((ticket) => ticket.closed).lastIndexOf(false);
expect(firstClosed).toBeGreaterThan(-1);
expect(lastOpen).toBeLessThan(firstClosed);
});
test('cizi firma stav nezmeni a ticket zustane, jak byl', () => {
const ticket = findByExternalId('tnt_automia', 'ORD-1')!;
expect(updateTicketStatus(ticket.id, 'hacked', ['tnt_nordis'], true)).toBeUndefined();
const after = findByExternalId('tnt_automia', 'ORD-1')!;
expect(after.status).toBe(ticket.status);
expect(after.closed).toBe(false);
});
test('resitel z jine firmy se nepriradi', () => {
const ticket = findByExternalId('tnt_nordis', 'ORD-1')!;
// usr_3 je jen v Automii.
expect(assignTicket(ticket.id, 'usr_3', ['tnt_nordis'])).toBeUndefined();
expect(assignTicket(ticket.id, 'usr_bartos', ['tnt_nordis'])?.assignee?.id).toBe('usr_bartos');
});
});