| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- import { after, before, beforeEach, test } from 'node:test';
- import assert from 'node:assert/strict';
- import { prepareTestDatabase } from '../helpers/testServer.js';
- import { applyTestConfig } from '../helpers/testConfig.js';
- import type { ChatwootClient } from '../../src/clients/chatwootClient.js';
- const dbUrl = prepareTestDatabase('test-ticket-logic');
- const { createTicket, hasLocalTicket } = await import('../../src/domain/ticketService.js');
- const { db, disconnectDb } = await import('../../src/store/db.js');
- /** Chatwoot double that records writes instead of performing them. */
- class FakeChatwoot {
- labels: string[] = [];
- attributeWrites: Record<string, unknown>[] = [];
- unassignCalls = 0;
- statusCalls: string[] = [];
- unassignSucceeds = true;
- statusSucceeds = true;
- getCalls = 0;
- conversation: Record<string, unknown> = { id: 500, labels: [], custom_attributes: {} };
- async getConversation(): Promise<Record<string, unknown>> {
- this.getCalls++;
- return this.conversation;
- }
- async addLabel(_id: number, label: string): Promise<void> {
- if (!this.labels.includes(label)) this.labels.push(label);
- }
- async setCustomAttributes(_id: number, attrs: Record<string, unknown>): Promise<void> {
- this.attributeWrites.push(attrs);
- const existing = (this.conversation.custom_attributes ?? {}) as Record<string, unknown>;
- this.conversation.custom_attributes = { ...existing, ...attrs };
- }
- async unassignConversation(): Promise<boolean> {
- this.unassignCalls++;
- return this.unassignSucceeds;
- }
- async setConversationStatus(_id: number, status: 'open' | 'resolved' | 'pending'): Promise<boolean> {
- this.statusCalls.push(status);
- return this.statusSucceeds;
- }
- async sendOutgoingMessage(): Promise<unknown> {
- return {};
- }
- }
- const as = (c: FakeChatwoot) => c as unknown as ChatwootClient;
- before(() => applyTestConfig({ DATABASE_URL: dbUrl }));
- beforeEach(async () => {
- await db().auditEvent.deleteMany({});
- await db().ticket.deleteMany({});
- });
- after(async () => {
- await disconnectDb();
- });
- test('creating a ticket sets ticket_number, handoff and the ticket label', async () => {
- const cw = new FakeChatwoot();
- const result = await createTicket(500, 'klient prosi o człowieka', as(cw));
- assert.equal(result.status, 'created');
- assert.match(result.ticketNumber, /^EKS-\d{8}-500$/);
- assert.ok(cw.labels.includes('ticket'));
- const written = cw.attributeWrites[0] as Record<string, unknown>;
- assert.equal(written.ticket_number, result.ticketNumber);
- assert.equal(written.handoff, true);
- assert.equal(written.handoff_reason, 'klient prosi o człowieka');
- });
- test('a repeated new_ticket returns the same number and creates no second row', async () => {
- const cw = new FakeChatwoot();
- const first = await createTicket(500, 'raz', as(cw));
- const second = await createTicket(500, 'dwa', as(cw));
- assert.equal(second.ticketNumber, first.ticketNumber);
- assert.equal(second.status, 'existing');
- assert.equal(await db().ticket.count({ where: { conversationId: 500 } }), 1);
- });
- test('concurrent new_ticket calls resolve to one ticket without throwing', async () => {
- const cw = new FakeChatwoot();
- const results = await Promise.all([
- createTicket(500, 'a', as(cw)),
- createTicket(500, 'b', as(cw)),
- createTicket(500, 'c', as(cw)),
- ]);
- const numbers = new Set(results.map((r) => r.ticketNumber));
- assert.equal(numbers.size, 1, 'all callers must see the same ticket number');
- assert.equal(results.filter((r) => r.status === 'created').length, 1, 'exactly one creator');
- assert.equal(await db().ticket.count({ where: { conversationId: 500 } }), 1);
- });
- test('an existing Chatwoot ticket_number is adopted, not overwritten', async () => {
- const cw = new FakeChatwoot();
- cw.conversation = {
- id: 500,
- labels: [],
- custom_attributes: { ticket_number: 'EKS-20250101-500' },
- };
- const result = await createTicket(500, 'handoff', as(cw));
- assert.equal(result.ticketNumber, 'EKS-20250101-500');
- assert.equal(result.status, 'existing');
- const row = await db().ticket.findUnique({ where: { conversationId: 500 } });
- assert.equal(row?.ticketNumber, 'EKS-20250101-500');
- const events = await db().auditEvent.findMany({ where: { eventType: 'ticket_adopted' } });
- assert.equal(events.length, 1);
- });
- test('a ticket_number of "0" is not treated as an existing ticket', async () => {
- const cw = new FakeChatwoot();
- cw.conversation = { id: 500, labels: [], custom_attributes: { ticket_number: '0' } };
- const result = await createTicket(500, 'handoff', as(cw));
- assert.equal(result.status, 'created');
- assert.match(result.ticketNumber, /^EKS-\d{8}-500$/);
- });
- test('unassign is skipped when the flag is off', async () => {
- applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'false' });
- const cw = new FakeChatwoot();
- await createTicket(500, 'handoff', as(cw));
- assert.equal(cw.unassignCalls, 0);
- const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
- const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
- assert.equal(meta.unassignRequested, false);
- assert.equal(meta.unassigned, null);
- applyTestConfig({ DATABASE_URL: dbUrl });
- });
- test('ticket creation opens the conversation by default and audits the real outcome', async () => {
- applyTestConfig({ DATABASE_URL: dbUrl });
- const cw = new FakeChatwoot();
- await createTicket(500, 'handoff', as(cw));
- assert.deepEqual(cw.statusCalls, ['open']);
- const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
- const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
- assert.equal(meta.openRequested, true);
- assert.equal(meta.opened, true);
- });
- test('open status is skipped when the flag is off', async () => {
- applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_OPEN_ON_TICKET: 'false' });
- const cw = new FakeChatwoot();
- await createTicket(500, 'handoff', as(cw));
- assert.deepEqual(cw.statusCalls, []);
- const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
- const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
- assert.equal(meta.openRequested, false);
- assert.equal(meta.opened, null);
- applyTestConfig({ DATABASE_URL: dbUrl });
- });
- test('a failed open status update is audited as failed, not success', async () => {
- applyTestConfig({ DATABASE_URL: dbUrl });
- const cw = new FakeChatwoot();
- cw.statusSucceeds = false;
- await createTicket(500, 'handoff', as(cw));
- const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
- const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
- assert.equal(meta.opened, false, 'the audit must record effect, not intent');
- });
- test('unassign runs when the flag is on and the real outcome is audited', async () => {
- applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
- const cw = new FakeChatwoot();
- await createTicket(500, 'handoff', as(cw));
- assert.equal(cw.unassignCalls, 1);
- const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
- const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
- assert.equal(meta.unassignRequested, true);
- assert.equal(meta.unassigned, true);
- applyTestConfig({ DATABASE_URL: dbUrl });
- });
- test('a failed unassign is audited as failed, not as success', async () => {
- applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
- const cw = new FakeChatwoot();
- cw.unassignSucceeds = false;
- await createTicket(500, 'handoff', as(cw));
- const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
- const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
- assert.equal(meta.unassigned, false, 'the audit must record effect, not intent');
- applyTestConfig({ DATABASE_URL: dbUrl });
- });
- test('an adopted ticket is also unassigned when the flag is on', async () => {
- applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
- const cw = new FakeChatwoot();
- cw.conversation = { id: 500, labels: [], custom_attributes: { ticket_number: 'EKS-20250101-500' } };
- await createTicket(500, 'handoff', as(cw));
- assert.equal(cw.unassignCalls, 1, 'adoption must reach the same Chatwoot state as creation');
- assert.deepEqual(cw.statusCalls, ['open'], 'adoption must leave the conversation open');
- assert.ok(cw.labels.includes('ticket'));
- applyTestConfig({ DATABASE_URL: dbUrl });
- });
- test('a locally known ticket has its label re-asserted if Chatwoot lost it', async () => {
- const cw = new FakeChatwoot();
- await createTicket(500, 'handoff', as(cw));
- // Somebody clears the label in the panel; the attributes stay.
- cw.labels = [];
- const again = await createTicket(500, 'handoff again', as(cw));
- assert.equal(again.status, 'existing');
- assert.ok(cw.labels.includes('ticket'), 'label must be restored');
- });
- test('an audit summary carrying an e-mail is redacted at write time', async () => {
- const cw = new FakeChatwoot();
- await createTicket(500, 'klient jan.kowalski@example.com prosi o kontakt', as(cw));
- const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
- assert.ok(!(event?.metaJson ?? '').includes('jan.kowalski@example.com'));
- const { audit } = await import('../../src/store/auditLog.js');
- await audit({
- conversationId: 500,
- eventType: 'test_event',
- summary: 'kontakt: jan.kowalski@example.com',
- });
- const row = await db().auditEvent.findFirst({ where: { eventType: 'test_event' } });
- assert.ok(!row?.summary.includes('jan.kowalski@example.com'));
- assert.match(row?.summary ?? '', /REDACTED_EMAIL/);
- });
- test('hasLocalTicket reflects the stored state', async () => {
- assert.equal(await hasLocalTicket(500), false);
- await createTicket(500, 'handoff', new FakeChatwoot() as unknown as ChatwootClient);
- assert.equal(await hasLocalTicket(500), true);
- });
|