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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user