ticketLogic.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import { after, before, beforeEach, test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { prepareTestDatabase } from '../helpers/testServer.js';
  4. import { applyTestConfig } from '../helpers/testConfig.js';
  5. import type { ChatwootClient } from '../../src/clients/chatwootClient.js';
  6. const dbUrl = prepareTestDatabase('test-ticket-logic');
  7. const { createTicket, hasLocalTicket } = await import('../../src/domain/ticketService.js');
  8. const { db, disconnectDb } = await import('../../src/store/db.js');
  9. /** Chatwoot double that records writes instead of performing them. */
  10. class FakeChatwoot {
  11. labels: string[] = [];
  12. attributeWrites: Record<string, unknown>[] = [];
  13. unassignCalls = 0;
  14. statusCalls: string[] = [];
  15. unassignSucceeds = true;
  16. statusSucceeds = true;
  17. getCalls = 0;
  18. conversation: Record<string, unknown> = { id: 500, labels: [], custom_attributes: {} };
  19. async getConversation(): Promise<Record<string, unknown>> {
  20. this.getCalls++;
  21. return this.conversation;
  22. }
  23. async addLabel(_id: number, label: string): Promise<void> {
  24. if (!this.labels.includes(label)) this.labels.push(label);
  25. }
  26. async setCustomAttributes(_id: number, attrs: Record<string, unknown>): Promise<void> {
  27. this.attributeWrites.push(attrs);
  28. const existing = (this.conversation.custom_attributes ?? {}) as Record<string, unknown>;
  29. this.conversation.custom_attributes = { ...existing, ...attrs };
  30. }
  31. async unassignConversation(): Promise<boolean> {
  32. this.unassignCalls++;
  33. return this.unassignSucceeds;
  34. }
  35. async setConversationStatus(_id: number, status: 'open' | 'resolved' | 'pending'): Promise<boolean> {
  36. this.statusCalls.push(status);
  37. return this.statusSucceeds;
  38. }
  39. async sendOutgoingMessage(): Promise<unknown> {
  40. return {};
  41. }
  42. }
  43. const as = (c: FakeChatwoot) => c as unknown as ChatwootClient;
  44. before(() => applyTestConfig({ DATABASE_URL: dbUrl }));
  45. beforeEach(async () => {
  46. await db().auditEvent.deleteMany({});
  47. await db().ticket.deleteMany({});
  48. });
  49. after(async () => {
  50. await disconnectDb();
  51. });
  52. test('creating a ticket sets ticket_number, handoff and the ticket label', async () => {
  53. const cw = new FakeChatwoot();
  54. const result = await createTicket(500, 'klient prosi o człowieka', as(cw));
  55. assert.equal(result.status, 'created');
  56. assert.match(result.ticketNumber, /^EKS-\d{8}-500$/);
  57. assert.ok(cw.labels.includes('ticket'));
  58. const written = cw.attributeWrites[0] as Record<string, unknown>;
  59. assert.equal(written.ticket_number, result.ticketNumber);
  60. assert.equal(written.handoff, true);
  61. assert.equal(written.handoff_reason, 'klient prosi o człowieka');
  62. });
  63. test('a repeated new_ticket returns the same number and creates no second row', async () => {
  64. const cw = new FakeChatwoot();
  65. const first = await createTicket(500, 'raz', as(cw));
  66. const second = await createTicket(500, 'dwa', as(cw));
  67. assert.equal(second.ticketNumber, first.ticketNumber);
  68. assert.equal(second.status, 'existing');
  69. assert.equal(await db().ticket.count({ where: { conversationId: 500 } }), 1);
  70. });
  71. test('concurrent new_ticket calls resolve to one ticket without throwing', async () => {
  72. const cw = new FakeChatwoot();
  73. const results = await Promise.all([
  74. createTicket(500, 'a', as(cw)),
  75. createTicket(500, 'b', as(cw)),
  76. createTicket(500, 'c', as(cw)),
  77. ]);
  78. const numbers = new Set(results.map((r) => r.ticketNumber));
  79. assert.equal(numbers.size, 1, 'all callers must see the same ticket number');
  80. assert.equal(results.filter((r) => r.status === 'created').length, 1, 'exactly one creator');
  81. assert.equal(await db().ticket.count({ where: { conversationId: 500 } }), 1);
  82. });
  83. test('an existing Chatwoot ticket_number is adopted, not overwritten', async () => {
  84. const cw = new FakeChatwoot();
  85. cw.conversation = {
  86. id: 500,
  87. labels: [],
  88. custom_attributes: { ticket_number: 'EKS-20250101-500' },
  89. };
  90. const result = await createTicket(500, 'handoff', as(cw));
  91. assert.equal(result.ticketNumber, 'EKS-20250101-500');
  92. assert.equal(result.status, 'existing');
  93. const row = await db().ticket.findUnique({ where: { conversationId: 500 } });
  94. assert.equal(row?.ticketNumber, 'EKS-20250101-500');
  95. const events = await db().auditEvent.findMany({ where: { eventType: 'ticket_adopted' } });
  96. assert.equal(events.length, 1);
  97. });
  98. test('a ticket_number of "0" is not treated as an existing ticket', async () => {
  99. const cw = new FakeChatwoot();
  100. cw.conversation = { id: 500, labels: [], custom_attributes: { ticket_number: '0' } };
  101. const result = await createTicket(500, 'handoff', as(cw));
  102. assert.equal(result.status, 'created');
  103. assert.match(result.ticketNumber, /^EKS-\d{8}-500$/);
  104. });
  105. test('unassign is skipped when the flag is off', async () => {
  106. applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'false' });
  107. const cw = new FakeChatwoot();
  108. await createTicket(500, 'handoff', as(cw));
  109. assert.equal(cw.unassignCalls, 0);
  110. const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
  111. const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
  112. assert.equal(meta.unassignRequested, false);
  113. assert.equal(meta.unassigned, null);
  114. applyTestConfig({ DATABASE_URL: dbUrl });
  115. });
  116. test('ticket creation opens the conversation by default and audits the real outcome', async () => {
  117. applyTestConfig({ DATABASE_URL: dbUrl });
  118. const cw = new FakeChatwoot();
  119. await createTicket(500, 'handoff', as(cw));
  120. assert.deepEqual(cw.statusCalls, ['open']);
  121. const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
  122. const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
  123. assert.equal(meta.openRequested, true);
  124. assert.equal(meta.opened, true);
  125. });
  126. test('open status is skipped when the flag is off', async () => {
  127. applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_OPEN_ON_TICKET: 'false' });
  128. const cw = new FakeChatwoot();
  129. await createTicket(500, 'handoff', as(cw));
  130. assert.deepEqual(cw.statusCalls, []);
  131. const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
  132. const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
  133. assert.equal(meta.openRequested, false);
  134. assert.equal(meta.opened, null);
  135. applyTestConfig({ DATABASE_URL: dbUrl });
  136. });
  137. test('a failed open status update is audited as failed, not success', async () => {
  138. applyTestConfig({ DATABASE_URL: dbUrl });
  139. const cw = new FakeChatwoot();
  140. cw.statusSucceeds = false;
  141. await createTicket(500, 'handoff', as(cw));
  142. const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
  143. const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
  144. assert.equal(meta.opened, false, 'the audit must record effect, not intent');
  145. });
  146. test('unassign runs when the flag is on and the real outcome is audited', async () => {
  147. applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
  148. const cw = new FakeChatwoot();
  149. await createTicket(500, 'handoff', as(cw));
  150. assert.equal(cw.unassignCalls, 1);
  151. const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
  152. const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
  153. assert.equal(meta.unassignRequested, true);
  154. assert.equal(meta.unassigned, true);
  155. applyTestConfig({ DATABASE_URL: dbUrl });
  156. });
  157. test('a failed unassign is audited as failed, not as success', async () => {
  158. applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
  159. const cw = new FakeChatwoot();
  160. cw.unassignSucceeds = false;
  161. await createTicket(500, 'handoff', as(cw));
  162. const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
  163. const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
  164. assert.equal(meta.unassigned, false, 'the audit must record effect, not intent');
  165. applyTestConfig({ DATABASE_URL: dbUrl });
  166. });
  167. test('an adopted ticket is also unassigned when the flag is on', async () => {
  168. applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_UNASSIGN_ON_TICKET: 'true' });
  169. const cw = new FakeChatwoot();
  170. cw.conversation = { id: 500, labels: [], custom_attributes: { ticket_number: 'EKS-20250101-500' } };
  171. await createTicket(500, 'handoff', as(cw));
  172. assert.equal(cw.unassignCalls, 1, 'adoption must reach the same Chatwoot state as creation');
  173. assert.deepEqual(cw.statusCalls, ['open'], 'adoption must leave the conversation open');
  174. assert.ok(cw.labels.includes('ticket'));
  175. applyTestConfig({ DATABASE_URL: dbUrl });
  176. });
  177. test('a locally known ticket has its label re-asserted if Chatwoot lost it', async () => {
  178. const cw = new FakeChatwoot();
  179. await createTicket(500, 'handoff', as(cw));
  180. // Somebody clears the label in the panel; the attributes stay.
  181. cw.labels = [];
  182. const again = await createTicket(500, 'handoff again', as(cw));
  183. assert.equal(again.status, 'existing');
  184. assert.ok(cw.labels.includes('ticket'), 'label must be restored');
  185. });
  186. test('an audit summary carrying an e-mail is redacted at write time', async () => {
  187. const cw = new FakeChatwoot();
  188. await createTicket(500, 'klient jan.kowalski@example.com prosi o kontakt', as(cw));
  189. const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
  190. assert.ok(!(event?.metaJson ?? '').includes('jan.kowalski@example.com'));
  191. const { audit } = await import('../../src/store/auditLog.js');
  192. await audit({
  193. conversationId: 500,
  194. eventType: 'test_event',
  195. summary: 'kontakt: jan.kowalski@example.com',
  196. });
  197. const row = await db().auditEvent.findFirst({ where: { eventType: 'test_event' } });
  198. assert.ok(!row?.summary.includes('jan.kowalski@example.com'));
  199. assert.match(row?.summary ?? '', /REDACTED_EMAIL/);
  200. });
  201. test('hasLocalTicket reflects the stored state', async () => {
  202. assert.equal(await hasLocalTicket(500), false);
  203. await createTicket(500, 'handoff', new FakeChatwoot() as unknown as ChatwootClient);
  204. assert.equal(await hasLocalTicket(500), true);
  205. });