Support-Tickets: Modul, Oberfläche, Discord-Benachrichtigung

This commit is contained in:
Kundencenter 2026-09-27 08:36:18 +02:00
parent fd34d121b1
commit ca5b48ab47
12 changed files with 340 additions and 5 deletions

View file

@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
import '../src/core/config.js';
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_messages', 'tickets', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
beforeAll(async () => {
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });

View file

@ -0,0 +1,57 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
describe('Support-Tickets', () => {
it('Kunde eröffnet, Personal antwortet, interne Notiz bleibt verborgen, Zuweisung, Schließen, Mandantentrennung', async () => {
const admin = await staff('tix-admin@example.com', 'admin'); const support = await staff('tix-support@example.com', 'support'); const accounting = await staff('tix-acc@example.com', 'accounting');
const A = await customer(admin, 'Firma A', 'tix-a@example.com'); const B = await customer(admin, 'Firma B', 'tix-b@example.com');
const create = await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Server langsam', body: 'Seit heute Morgen sehr langsam.' });
expect(create.statusCode).toBe(200); const id = create.json().id;
// Kunde kann keine hohe Priorität setzen und nicht für einen fremden Kunden
expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Test', body: 'y', priority: 'urgent' })).statusCode).toBe(403);
expect((await call(app, A.client, 'POST', '/tickets', { orgId: B.org, subject: 'Test', body: 'y' })).statusCode).toBe(404);
expect((await call(app, accounting, 'PATCH', `/tickets/${id}`, { priority: 'high' })).statusCode).toBe(403); // Buchhaltung nur lesend
const get1 = (await call(app, support, 'GET', `/tickets/${id}`)).json(); expect(get1.status).toBe('pending_staff'); expect(get1.messages).toHaveLength(1);
expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Interne Notiz: Ticket klingt nach Netzwerkproblem', internalNote: true })).statusCode).toBe(200);
expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Wir prüfen das und melden uns.' })).statusCode).toBe(200);
expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { assignedTo: null })).statusCode).toBe(200);
// Kunde sieht die interne Notiz nicht, aber die Antwort; Status ist jetzt "wartet auf Kunde"
const seenByCustomer = (await call(app, A.client, 'GET', `/tickets/${id}`)).json();
expect(seenByCustomer.status).toBe('pending_customer'); expect(seenByCustomer.messages).toHaveLength(2);
expect(seenByCustomer.messages.some((m: any) => m.body.includes('Interne Notiz'))).toBe(false);
expect((await call(app, B.client, 'GET', `/tickets/${id}`)).statusCode).toBe(404); // fremder Kunde
// Kunde antwortet erneut → wartet auf Personal
await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'Danke, bitte kurzfristig.' });
expect((await call(app, support, 'GET', `/tickets/${id}`)).json().status).toBe('pending_staff');
// Zuweisen, lösen, schließen
expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { status: 'resolved' })).statusCode).toBe(200);
expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(200);
expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(409); // schon geschlossen
expect((await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'noch was' })).statusCode).toBe(409);
// Listen: Kunde sieht nur eigene, Personal sieht alle offenen
const openB = (await call(app, admin, 'POST', '/tickets', { orgId: B.org, subject: 'Frage', body: 'Wie funktioniert X?' })).json();
expect((await call(app, B.client, 'GET', '/tickets')).json()).toHaveLength(1);
const staffOpen = (await call(app, support, 'GET', '/tickets?status=open')).json(); expect(staffOpen.map((t: any) => t.id)).toContain(openB.id); expect(staffOpen.map((t: any) => t.id)).not.toContain(id);
expect((await call(app, support, 'GET', '/tickets?status=closed')).json().map((t: any) => t.id)).toEqual([id]);
});
});