Poptavka z webu je ticket, prilohy, udaje provozovatele, kolacovy graf

Provozovatel portalu: firma s priznakem portalOperator (jen jedna, zapnuti
odebere ostatnim) a novymi poli contactEmail, contactPhone, website vedle
ico, dic, adresy a pravni formy. Verejny GET /api/public/brand vraci jeji
udaje a web je bere pres useBrand() na kontaktu, v paticce, O nas,
prihlaseni i v titulku; brand.ts je jen zaloha.

Poptavka z webu zaklada u provozovatele ticket kanalu form: predmet
"Poptavka: tema", telo JSON s poli formulare, tag Poptavka plus tema,
zakaznik z formulare, poznamka v logu. Bez provozovatele se jen zaloguje.

Prilohy ticketu: formular az 3 soubory po 5 MB, ticket az 10; nahrani,
seznam, stazeni a smazani (pravo ticket.comment, strop viditelnosti,
poznamky v logu, audit). Soubor jde v JSON jako Base64 a lezi v beznem
ulozisti, bez nove zavislosti; strop tela jen na techto cestach.

Vlastni widget s kreslenim Graf umi i pocet ticketu se seskupenim jako
kolac (PieChart.tsx, ciste SVG, osm barev z tokenu, zbytek jako ostatni).

OpenAPI rozdelene na mensi soubory (102 cest, 28 schemat overeno shodnych),
28 novych testu (135 celkem), dokumentace aktualizovana.
This commit is contained in:
JiriUhlir
2026-09-09 19:35:00 +02:00
parent 22dda2d139
commit c25e826766
72 changed files with 3978 additions and 1118 deletions
+147
View File
@@ -0,0 +1,147 @@
/**
* Prilohy ticketu (src/data/attachments.ts): limity, ocisteni nazvu, base64,
* seznam bez obsahu a filtr na firmu. Uloziste v pameti.
*/
import { beforeAll, describe, expect, test } from 'vitest';
import {
addAttachments,
attachmentStore,
checkUploads,
getAttachment,
listAttachments,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENTS_PER_TICKET,
removeAttachment,
sanitizeName,
} from '../../src/data/attachments.js';
import { initStores } from '../../src/data/store/index.js';
import { createTicket, getTicket, initTickets, type Ticket } from '../../src/data/ticketStore.js';
import { refreshUsers, seedUsers, userStore } from '../../src/data/users.js';
let ticket: Ticket;
function upload(name: string, bytes: number, mime?: string) {
return { name, mime, content: Buffer.alloc(bytes, 1).toString('base64') };
}
beforeAll(async () => {
initStores({ databaseReady: false });
await userStore.init(seedUsers);
await refreshUsers();
await attachmentStore.init();
await initTickets();
ticket = createTicket({
tenantId: 'tnt_automia',
subject: 'Ticket s prilohami',
channel: 'portal',
priority: 'normal',
});
});
describe('checkUploads', () => {
test('soubor nad limit velikosti neprojde', () => {
const result = checkUploads([upload('velky.bin', MAX_ATTACHMENT_BYTES + 1)], 0);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.message).toContain('velky.bin');
});
test('soubor presne na limitu projde', () => {
expect(checkUploads([upload('hranice.bin', MAX_ATTACHMENT_BYTES)], 0).ok).toBe(true);
});
test('pocet priloh na ticket ma strop', () => {
const result = checkUploads([upload('dalsi.txt', 10)], MAX_ATTACHMENTS_PER_TICKET);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.message).toContain(String(MAX_ATTACHMENTS_PER_TICKET));
});
test('neplatny base64 a prazdny obsah se odmitnou', () => {
expect(checkUploads([{ name: 'a.txt', content: 'to neni base64!' }], 0).ok).toBe(false);
expect(checkUploads([{ name: 'a.txt', content: 'abc' }], 0).ok).toBe(false);
expect(checkUploads([{ name: 'a.txt', content: '' }], 0).ok).toBe(false);
});
test('neznamy typ obsahu spadne na octet-stream', () => {
const result = checkUploads([upload('a.txt', 3, 'nesmysl bez lomitka')], 0);
expect(result.ok).toBe(true);
if (result.ok) expect(result.files[0]?.mime).toBe('application/octet-stream');
});
});
describe('sanitizeName', () => {
test('zahodi cestu a ridici znaky', () => {
expect(sanitizeName('../../etc/passwd')).toBe('passwd');
expect(sanitizeName('C:\\Users\\x\\zprava.pdf')).toBe('zprava.pdf');
expect(sanitizeName('a\r\nb\u0000c.txt')).toBe('abc.txt');
});
test('prazdny nazev dostane nahradu a dlouhy se zkrati', () => {
expect(sanitizeName('/')).toBe('priloha');
expect(sanitizeName('..')).toBe('priloha');
expect(sanitizeName('x'.repeat(500)).length).toBe(200);
});
});
describe('addAttachments a listAttachments', () => {
test('ulozi prilohy, seznam je bez obsahu a ticket dostane radek do logu', async () => {
const result = await addAttachments(
ticket,
[upload('zadani.pdf', 1500, 'application/pdf'), upload('foto.jpg', 20, 'image/jpeg')],
'usr_1',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items).toHaveLength(2);
expect(result.items[0]).toMatchObject({
ticketId: ticket.id,
tenantId: 'tnt_automia',
name: 'zadani.pdf',
mime: 'application/pdf',
size: 1500,
uploadedBy: 'usr_1',
});
expect('content' in (result.items[0] as object)).toBe(false);
const items = await listAttachments(ticket.id, ['tnt_automia']);
expect(items).toHaveLength(2);
for (const item of items) expect('content' in (item as object)).toBe(false);
const detail = getTicket(ticket.id, ['tnt_automia']);
const labels = detail?.trace.map((entry) => entry.label) ?? [];
expect(labels).toContain('Příloha: zadani.pdf (1.5 kB)');
});
test('cizi firma nevidi nic a nedostane obsah', async () => {
expect(await listAttachments(ticket.id, ['tnt_nordis'])).toEqual([]);
const [first] = await listAttachments(ticket.id, ['tnt_automia']);
expect(await getAttachment(first!.id, ticket.id, ['tnt_nordis'])).toBeUndefined();
expect(await removeAttachment(first!.id, ticket.id, ['tnt_nordis'])).toBe(false);
});
test('obsah se vraci jen pres getAttachment a odpovida ulozenemu', async () => {
const [first] = await listAttachments(ticket.id, ['tnt_automia']);
const full = await getAttachment(first!.id, ticket.id, ['tnt_automia']);
expect(full?.content).toBe(Buffer.alloc(1500, 1).toString('base64'));
// Jiny ticket se stejnym ID prilohy je taky "neexistuje".
expect(await getAttachment(first!.id, 'TK-0', ['tnt_automia'])).toBeUndefined();
});
test('davka nad strop poctu se neulozi ani castecne', async () => {
const before = (await listAttachments(ticket.id, ['tnt_automia'])).length;
const many = Array.from({ length: MAX_ATTACHMENTS_PER_TICKET }, (_, i) =>
upload(`f${i}.txt`, 4),
);
const result = await addAttachments(ticket, many, 'usr_1');
expect(result.ok).toBe(false);
expect(await listAttachments(ticket.id, ['tnt_automia'])).toHaveLength(before);
});
test('odebrani prilohy zapise poznamku do logu', async () => {
const [first] = await listAttachments(ticket.id, ['tnt_automia']);
expect(await removeAttachment(first!.id, ticket.id, ['tnt_automia'])).toBe(true);
expect(await listAttachments(ticket.id, ['tnt_automia'])).toHaveLength(1);
const labels = getTicket(ticket.id, ['tnt_automia'])?.trace.map((entry) => entry.label) ?? [];
expect(labels).toContain('Příloha odebrána: zadani.pdf');
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'vitest';
import { validateWidget, type CustomWidget } from '../../src/data/customWidgets.js';
/** Widget se zakladem, ktery validace pousti; testy prepisuji jen to, o co jde. */
function widget(overrides: Partial<CustomWidget>): CustomWidget {
const now = '2026-09-09T00:00:00.000Z';
return {
id: 'cw_test',
tenantId: 'tnt_automia',
ownerId: null,
name: 'Test',
description: '',
render: 'chart',
size: 'half',
source: { kind: 'ticketSeries', filter: {}, bucket: 'day' },
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe('validateWidget: graf', () => {
test('casova rada projde', () => {
expect(validateWidget(widget({}))).toEqual([]);
});
test('pocet ticketu se seskupenim projde jako kolac', () => {
const result = validateWidget(
widget({ source: { kind: 'ticketCount', filter: { closed: false }, groupBy: 'status' } }),
);
expect(result).toEqual([]);
});
test('pocet ticketu bez seskupeni je pro graf chyba', () => {
const result = validateWidget(widget({ source: { kind: 'ticketCount', filter: {} } }));
expect(result.some((problem) => problem.includes('Graf'))).toBe(true);
});
test('seznam ticketu se jako graf nekresli', () => {
const result = validateWidget(
widget({ source: { kind: 'ticketList', filter: {}, limit: 5 } }),
);
expect(result.some((problem) => problem.includes('Graf'))).toBe(true);
});
test('graf ve tretine sirky neprojde', () => {
const result = validateWidget(widget({ size: 'third' }));
expect(result.some((problem) => problem.includes('Šířka'))).toBe(true);
});
});