webhook.test.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { after, before, test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { prepareTestDatabase, startServer, type RunningServer } from '../helpers/testServer.js';
  4. import { applyTestConfig } from '../helpers/testConfig.js';
  5. import {
  6. incomingEmailMessage,
  7. noLabelsPayload,
  8. outgoingMessage,
  9. privateNote,
  10. statusUpdate,
  11. } from '../fixtures/chatwoot.js';
  12. const dbUrl = prepareTestDatabase('test-webhook');
  13. // Imported after the DATABASE_URL is in place.
  14. const { createApp } = await import('../../src/http/app.js');
  15. const { db, disconnectDb } = await import('../../src/store/db.js');
  16. let running: RunningServer;
  17. before(async () => {
  18. applyTestConfig({ DATABASE_URL: dbUrl });
  19. running = await startServer(createApp());
  20. });
  21. after(async () => {
  22. await running.close();
  23. await disconnectDb();
  24. });
  25. async function post(path: string, body: unknown, headers: Record<string, string> = {}) {
  26. const res = await fetch(`${running.baseUrl}${path}`, {
  27. method: 'POST',
  28. headers: { 'Content-Type': 'application/json', ...headers },
  29. body: JSON.stringify(body),
  30. });
  31. return { status: res.status, body: (await res.json()) as Record<string, unknown> };
  32. }
  33. test('GET /health returns 200 without touching any dependency', async () => {
  34. const res = await fetch(`${running.baseUrl}/health`);
  35. assert.equal(res.status, 200);
  36. const body = (await res.json()) as Record<string, unknown>;
  37. assert.equal(body.ok, true);
  38. assert.equal(body.service, 'eks-relay');
  39. });
  40. test('an incoming message is accepted with 202 and recorded', async () => {
  41. const res = await post('/webhooks/chatwoot', incomingEmailMessage);
  42. assert.equal(res.status, 202);
  43. assert.equal(res.body.accepted, true);
  44. assert.equal(res.body.conversationId, 1311);
  45. assert.ok(res.body.jobId);
  46. const row = await db().processedMessage.findUnique({
  47. where: { source_messageId: { source: 'chatwoot', messageId: '90210' } },
  48. });
  49. assert.ok(row, 'ProcessedMessage row should exist');
  50. assert.equal(row.conversationId, 1311);
  51. assert.equal(row.status, 'queued');
  52. const jobs = await db().job.findMany({ where: { type: 'chatwoot_message' } });
  53. assert.equal(jobs.length, 1);
  54. });
  55. test('the same message id a second time is a no-op duplicate', async () => {
  56. const res = await post('/webhooks/chatwoot', incomingEmailMessage);
  57. assert.equal(res.status, 200);
  58. assert.equal(res.body.duplicate, true);
  59. const jobs = await db().job.findMany({ where: { type: 'chatwoot_message' } });
  60. assert.equal(jobs.length, 1, 'a duplicate must not create a second job');
  61. const rows = await db().processedMessage.findMany({ where: { messageId: '90210' } });
  62. assert.equal(rows.length, 1);
  63. });
  64. test('concurrent deliveries of the same message create exactly one job', async () => {
  65. const payload = { ...incomingEmailMessage, id: 99001 };
  66. const results = await Promise.all([
  67. post('/webhooks/chatwoot', payload),
  68. post('/webhooks/chatwoot', payload),
  69. post('/webhooks/chatwoot', payload),
  70. ]);
  71. const accepted = results.filter((r) => r.status === 202);
  72. assert.equal(accepted.length, 1, 'exactly one delivery may be accepted');
  73. const jobs = await db().job.findMany({});
  74. const forMessage = jobs.filter((j) => j.payloadJson.includes('"messageId":"99001"'));
  75. assert.equal(forMessage.length, 1);
  76. });
  77. test('the webhook stores an audit event', async () => {
  78. const events = await db().auditEvent.findMany({ where: { messageId: '90210' } });
  79. assert.ok(events.length >= 1);
  80. assert.equal(events[0]?.eventType, 'webhook_accepted');
  81. });
  82. test('outgoing messages, private notes and status events are skipped with 200', async () => {
  83. for (const payload of [outgoingMessage, privateNote, statusUpdate]) {
  84. const res = await post('/webhooks/chatwoot', payload);
  85. assert.equal(res.status, 200);
  86. assert.equal(res.body.skipped, true);
  87. }
  88. const skippedRows = await db().processedMessage.findMany({
  89. where: { messageId: { in: ['90211', '90212', '90213'] } },
  90. });
  91. assert.equal(skippedRows.length, 0, 'skipped events must not be recorded as processed');
  92. });
  93. test('a payload without labels is still accepted and flagged for a fetch', async () => {
  94. const res = await post('/webhooks/chatwoot', noLabelsPayload);
  95. assert.equal(res.status, 202);
  96. const job = await db().job.findFirst({
  97. where: { payloadJson: { contains: '"messageId":"90217"' } },
  98. });
  99. assert.ok(job);
  100. const payload = JSON.parse(job.payloadJson) as { needsConversationFetch: boolean };
  101. assert.equal(payload.needsConversationFetch, true);
  102. });
  103. test('garbage bodies are answered 200 without creating work', async () => {
  104. const before = await db().job.count();
  105. const res = await post('/webhooks/chatwoot', { totally: 'unrelated' });
  106. assert.equal(res.status, 200);
  107. assert.equal(res.body.skipped, true);
  108. assert.equal(await db().job.count(), before);
  109. });
  110. test('an already-ticketed conversation is settled at the webhook without queueing', async () => {
  111. const jobsBefore = await db().job.count();
  112. const res = await post('/webhooks/chatwoot', {
  113. ...incomingEmailMessage,
  114. id: 99500,
  115. conversation: {
  116. ...incomingEmailMessage.conversation,
  117. labels: ['ticket'],
  118. custom_attributes: { ticket_number: 'EKS-20260820-1311', handoff: true },
  119. },
  120. });
  121. assert.equal(res.status, 200);
  122. assert.equal(res.body.skipped, true);
  123. assert.equal(res.body.reason, 'ticket_mode');
  124. assert.equal(await db().job.count(), jobsBefore, 'no job may be queued for a ticketed conversation');
  125. const row = await db().processedMessage.findUnique({
  126. where: { source_messageId: { source: 'chatwoot', messageId: '99500' } },
  127. });
  128. assert.equal(row?.status, 'skipped');
  129. assert.equal(row?.reason, 'ticket_mode');
  130. const events = await db().auditEvent.findMany({ where: { messageId: '99500' } });
  131. assert.equal(events[0]?.eventType, 'skipped_ticket_mode');
  132. assert.match(events[0]?.metaJson ?? '', /"stage":"webhook"/);
  133. });
  134. test('a ticketed conversation is still deduplicated on redelivery', async () => {
  135. const payload = {
  136. ...incomingEmailMessage,
  137. id: 99501,
  138. conversation: {
  139. ...incomingEmailMessage.conversation,
  140. labels: ['ticket'],
  141. custom_attributes: {},
  142. },
  143. };
  144. const first = await post('/webhooks/chatwoot', payload);
  145. assert.equal(first.body.reason, 'ticket_mode');
  146. const second = await post('/webhooks/chatwoot', payload);
  147. assert.equal(second.status, 200);
  148. assert.equal(second.body.duplicate, true);
  149. const rows = await db().processedMessage.findMany({ where: { messageId: '99501' } });
  150. assert.equal(rows.length, 1);
  151. });
  152. test('a payload lacking labels is still queued, so the worker can fetch them', async () => {
  153. const res = await post('/webhooks/chatwoot', { ...noLabelsPayload, id: 99502 });
  154. assert.equal(res.status, 202, 'the ticket state is unknown here — it must not be guessed');
  155. assert.ok(res.body.jobId);
  156. });
  157. test('the queued audit event records the jobId in its own column', async () => {
  158. const res = await post('/webhooks/chatwoot', { ...incomingEmailMessage, id: 99503 });
  159. assert.equal(res.status, 202);
  160. const event = await db().auditEvent.findFirst({
  161. where: { messageId: '99503', eventType: 'webhook_accepted' },
  162. });
  163. assert.equal(event?.jobId, res.body.jobId);
  164. });