webhook.test.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  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. });