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
+136
View File
@@ -0,0 +1,136 @@
/**
* Prilohy ticketu pres HTTP (src/routes/dashboard/attachments.ts): nahrani
* jako resitel s ticket.comment, seznam, stazeni, smazani, cizi firma 404,
* prilis velky soubor 400.
*/
import request from 'supertest';
import { beforeAll, describe, expect, test } from 'vitest';
import type { Express } from 'express';
import { createApp } from '../../src/app.js';
import { MAX_ATTACHMENT_BYTES } from '../../src/data/attachments.js';
import { bootstrapData } from '../../src/data/bootstrap.js';
import { createTicket, type Ticket } from '../../src/data/ticketStore.js';
let app: Express;
let agentToken: string;
let foreignToken: string;
let ticket: Ticket;
const payload = Buffer.from('obsah prilohy s diakritikou: příloha');
async function login(email: string): Promise<string> {
const response = await request(app).post('/api/auth/login').send({ email, password: 'demo1234' });
expect(response.status).toBe(200);
return response.body.token as string;
}
function base(): string {
return `/api/dashboard/tickets/${ticket.id}/attachments`;
}
beforeAll(async () => {
await bootstrapData({ databaseReady: false });
app = createApp();
// Karel je v Automii agent (ma ticket.comment), Ondrej je jen v LogiTransu.
agentToken = await login('karel.vomacka@automia.cz');
foreignToken = await login('ondrej.kadlec@logitrans.cz');
ticket = createTicket({
tenantId: 'tnt_automia',
subject: 'Ticket pro prilohy',
channel: 'portal',
priority: 'normal',
});
});
describe('prilohy ticketu', () => {
let attachmentId: string;
test('nahrani vrati 201 a jen nove pridane prilohy', async () => {
const response = await request(app)
.post(base())
.set('Authorization', `Bearer ${agentToken}`)
.send({
files: [{ name: 'zadání.txt', mime: 'text/plain', content: payload.toString('base64') }],
});
expect(response.status).toBe(201);
expect(response.body.items).toHaveLength(1);
expect(response.body.items[0]).toMatchObject({
ticketId: ticket.id,
name: 'zadání.txt',
mime: 'text/plain',
size: payload.length,
uploadedBy: 'usr_2',
});
expect(response.body.items[0].content).toBeUndefined();
attachmentId = response.body.items[0].id as string;
});
test('seznam vrati prilohu bez obsahu', async () => {
const response = await request(app).get(base()).set('Authorization', `Bearer ${agentToken}`);
expect(response.status).toBe(200);
expect(response.body.items).toHaveLength(1);
expect(response.body.items[0].id).toBe(attachmentId);
expect(response.body.items[0].content).toBeUndefined();
});
test('stazeni vrati stejne bajty a hlavicky', async () => {
const response = await request(app)
.get(`${base()}/${attachmentId}/content`)
.set('Authorization', `Bearer ${agentToken}`)
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
});
expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('text/plain');
expect(response.headers['content-length']).toBe(String(payload.length));
expect(response.headers['content-disposition']).toBe(
`attachment; filename*=UTF-8''${encodeURIComponent('zadání.txt')}`,
);
expect(Buffer.compare(response.body as Buffer, payload)).toBe(0);
});
test('cizi firma dostane 404 na seznam, nahrani i stazeni', async () => {
const auth = `Bearer ${foreignToken}`;
expect((await request(app).get(base()).set('Authorization', auth)).status).toBe(404);
expect(
(
await request(app)
.post(base())
.set('Authorization', auth)
.send({ files: [{ name: 'x.txt', content: 'YWJj' }] })
).status,
).toBe(404);
expect(
(await request(app).get(`${base()}/${attachmentId}/content`).set('Authorization', auth))
.status,
).toBe(404);
});
test('soubor nad limit je 400', async () => {
const response = await request(app)
.post(base())
.set('Authorization', `Bearer ${agentToken}`)
.send({
files: [
{ name: 'velky.bin', content: Buffer.alloc(MAX_ATTACHMENT_BYTES + 1).toString('base64') },
],
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('validation_error');
});
test('smazani vrati 204 a priloha zmizi', async () => {
const auth = `Bearer ${agentToken}`;
expect(
(await request(app).delete(`${base()}/${attachmentId}`).set('Authorization', auth)).status,
).toBe(204);
const list = await request(app).get(base()).set('Authorization', auth);
expect(list.body.items).toHaveLength(0);
expect(
(await request(app).delete(`${base()}/${attachmentId}`).set('Authorization', auth)).status,
).toBe(404);
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Kontaktni formular (src/routes/contact.ts): s provozovatelem portalu vznika
* ticket s prilohou, bez nej jen 202, limit pokusu zustava.
*
* Limit je na adresu a supertest vola vzdy z te same, proto test pocita
* odeslane pokusy a na konci overi, ze sesty je 429.
*/
import request from 'supertest';
import { beforeAll, describe, expect, test } from 'vitest';
import type { Express } from 'express';
import { createApp } from '../../src/app.js';
import { listAttachments } from '../../src/data/attachments.js';
import { bootstrapData } from '../../src/data/bootstrap.js';
import { refreshTenants, tenantStore } from '../../src/data/tenants.js';
import { listTickets } from '../../src/data/ticketStore.js';
let app: Express;
/** Kolik pokusu uz slo z teto adresy. Limit je 5 za hodinu. */
let attempts = 0;
const CONTACT_MAX_PER_WINDOW = 5;
const form = {
name: 'Petra Klientová',
email: 'petra@example.com',
company: 'Klient s.r.o.',
phone: '+420 777 000 111',
topic: 'integrace',
message: 'Potřebujeme napojit e-shop na iDoklad, prosím o nabídku.',
};
async function setOperator(portalOperator: boolean): Promise<void> {
await tenantStore.update(
'tnt_automia',
{ portalOperator },
{ tenantIds: [], includeGlobal: true },
);
await refreshTenants();
}
function send(body: Record<string, unknown>): request.Test {
attempts += 1;
return request(app).post('/api/contact').send(body);
}
function ticketsOfAutomia() {
return listTickets({ tenantIds: ['tnt_automia'], visibility: { kind: 'all' } });
}
beforeAll(async () => {
await bootstrapData({ databaseReady: false });
app = createApp();
});
describe('POST /api/contact', () => {
test('s provozovatelem zalozi ticket z formulare vcetne prilohy', async () => {
await setOperator(true);
const content = Buffer.from('ukazkovy obsah').toString('base64');
const response = await send({
...form,
attachments: [{ name: 'zadani.txt', mime: 'text/plain', content }],
});
expect(response.status).toBe(202);
expect(response.body.ok).toBe(true);
const [ticket] = ticketsOfAutomia();
expect(ticket).toBeDefined();
expect(ticket!.channel).toBe('form');
expect(ticket!.subject).toBe('Poptávka: integrace');
expect(ticket!.tags).toEqual(['Poptávka', 'integrace']);
expect(ticket!.externalSource).toBe('web-form');
expect(ticket!.assignee).toBeNull();
expect(ticket!.customer).toEqual({
id: null,
company: 'Klient s.r.o.',
contact: 'Petra Klientová',
reply: 'petra@example.com',
});
const body = JSON.parse(ticket!.body) as Record<string, unknown>;
expect(body).toMatchObject({ ...form });
expect(typeof body.receivedAt).toBe('string');
const attachments = await listAttachments(ticket!.id, ['tnt_automia']);
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
name: 'zadani.txt',
mime: 'text/plain',
uploadedBy: null,
});
});
test('neplatna priloha je 400 a ticket nevznikne', async () => {
const before = ticketsOfAutomia().length;
const response = await send({
...form,
attachments: [{ name: 'x.txt', content: 'to neni base64!' }],
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('validation_error');
expect(ticketsOfAutomia()).toHaveLength(before);
});
test('bez provozovatele je 202 a zadny ticket', async () => {
await setOperator(false);
const before = ticketsOfAutomia().length;
const response = await send(form);
expect(response.status).toBe(202);
expect(ticketsOfAutomia()).toHaveLength(before);
});
test('limit pokusu z jedne adresy zustava', async () => {
while (attempts < CONTACT_MAX_PER_WINDOW) {
expect((await send(form)).status).toBe(202);
}
const blocked = await send(form);
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBeDefined();
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* Verejne udaje provozovatele (src/routes/public.ts): s provozovatelem jsou
* pole vyplnena, bez nej same null a porad 200.
*/
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 { refreshTenants, tenantStore } from '../../src/data/tenants.js';
let app: Express;
beforeAll(async () => {
await bootstrapData({ databaseReady: false });
app = createApp();
});
describe('GET /api/public/brand', () => {
test('bez provozovatele jsou vsechna pole null', async () => {
const response = await request(app).get('/api/public/brand');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('public, max-age=60');
expect(response.body).toEqual({
name: null,
legalName: null,
ico: null,
dic: null,
address: null,
legalForm: null,
email: null,
phone: null,
website: null,
});
});
test('s provozovatelem vraci jeho udaje', async () => {
await tenantStore.update(
'tnt_automia',
{
portalOperator: true,
ico: '12345678',
dic: 'CZ12345678',
address: 'Náměstí Míru 12, Brno',
legalForm: 'Společnost s ručením omezeným',
contactEmail: 'info@automia.cz',
contactPhone: '+420 777 123 456',
website: 'https://www.automia.cz',
},
{ tenantIds: [], includeGlobal: true },
);
await refreshTenants();
const response = await request(app).get('/api/public/brand');
expect(response.status).toBe(200);
expect(response.body).toEqual({
name: 'Automia',
legalName: 'Automia',
ico: '12345678',
dic: 'CZ12345678',
address: 'Náměstí Míru 12, Brno',
legalForm: 'Společnost s ručením omezeným',
email: 'info@automia.cz',
phone: '+420 777 123 456',
website: 'https://www.automia.cz',
});
});
test('spravce platformy prepne provozovatele a puvodnimu priznak zmizi', async () => {
const login = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@automia.cz', password: 'demo1234' });
const response = await request(app)
.patch('/api/dashboard/settings/tenants/tnt_nordis')
.set('Authorization', `Bearer ${login.body.token}`)
.send({ portalOperator: true, contactEmail: '' });
expect(response.status).toBe(200);
expect(response.body.portalOperator).toBe(true);
expect(response.body.contactEmail).toBeNull();
const automia = await tenantStore.getRaw('tnt_automia');
expect(automia?.portalOperator).toBe(false);
const brand = await request(app).get('/api/public/brand');
expect(brand.body.name).toBe('Nordis a.s.');
});
});