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[] = []; unassignCalls = 0; statusCalls: string[] = []; unassignSucceeds = true; statusSucceeds = true; getCalls = 0; conversation: Record = { id: 500, labels: [], custom_attributes: {} }; async getConversation(): Promise> { this.getCalls++; return this.conversation; } async addLabel(_id: number, label: string): Promise { if (!this.labels.includes(label)) this.labels.push(label); } async setCustomAttributes(_id: number, attrs: Record): Promise { this.attributeWrites.push(attrs); const existing = (this.conversation.custom_attributes ?? {}) as Record; this.conversation.custom_attributes = { ...existing, ...attrs }; } async unassignConversation(): Promise { this.unassignCalls++; return this.unassignSucceeds; } async setConversationStatus(_id: number, status: 'open' | 'resolved' | 'pending'): Promise { this.statusCalls.push(status); return this.statusSucceeds; } async sendOutgoingMessage(): Promise { 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; 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; 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; 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; 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; 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; 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; 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); });