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