ticketLogic.test.ts 8.0 KB

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