webhook-model.test.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import assert from 'node:assert/strict';
  2. import crypto from 'node:crypto';
  3. import { test } from 'node:test';
  4. import {
  5. TERMINAL_WEBHOOK_EVENTS,
  6. MAX_WEBHOOK_ATTEMPTS,
  7. WEBHOOK_LEASE_MS,
  8. eventTypeForStatus,
  9. resolveWebhooksForEvent,
  10. buildWebhookPayload,
  11. signWebhookBody,
  12. nextBackoffMs,
  13. isTerminalWebhookStatus,
  14. normalizeWebhookEvents,
  15. parseWebhookEventsJson
  16. } from '../src/webhook-model.js';
  17. test('maps terminal statuses to email.* types', () => {
  18. assert.equal(eventTypeForStatus('sent'), 'email.sent');
  19. assert.equal(eventTypeForStatus('bounced'), 'email.bounced');
  20. assert.equal(eventTypeForStatus('failed'), 'email.failed');
  21. assert.equal(eventTypeForStatus('queued'), null);
  22. assert.equal(eventTypeForStatus('deferred'), null);
  23. });
  24. test('isTerminalWebhookStatus matches terminal set', () => {
  25. assert.equal(isTerminalWebhookStatus('sent'), true);
  26. assert.equal(isTerminalWebhookStatus('bounced'), true);
  27. assert.equal(isTerminalWebhookStatus('failed'), true);
  28. assert.equal(isTerminalWebhookStatus('queued'), false);
  29. assert.equal(isTerminalWebhookStatus('processing'), false);
  30. assert.deepEqual(TERMINAL_WEBHOOK_EVENTS, ['sent', 'bounced', 'failed']);
  31. assert.equal(MAX_WEBHOOK_ATTEMPTS, 8);
  32. assert.equal(WEBHOOK_LEASE_MS, 2 * 60 * 1000);
  33. });
  34. test('domain webhooks override account for the same event', () => {
  35. const account = [
  36. { id: 1, domainId: null, enabled: true, events: ['sent', 'failed'] },
  37. { id: 2, domainId: null, enabled: true, events: ['bounced'] }
  38. ];
  39. const domain = [
  40. { id: 3, domainId: 9, enabled: true, events: ['sent'] }
  41. ];
  42. const resolved = resolveWebhooksForEvent({
  43. accountWebhooks: account,
  44. domainWebhooks: domain,
  45. eventType: 'sent'
  46. });
  47. assert.deepEqual(resolved.map((w) => w.id), [3]);
  48. });
  49. test('falls back to account when domain has no matching enabled subscription', () => {
  50. const resolved = resolveWebhooksForEvent({
  51. accountWebhooks: [{ id: 1, domainId: null, enabled: true, events: ['failed'] }],
  52. domainWebhooks: [{ id: 3, domainId: 9, enabled: true, events: ['sent'] }],
  53. eventType: 'failed'
  54. });
  55. assert.deepEqual(resolved.map((w) => w.id), [1]);
  56. });
  57. test('skips disabled webhooks and unsubscribed events', () => {
  58. const resolved = resolveWebhooksForEvent({
  59. accountWebhooks: [
  60. { id: 1, domainId: null, enabled: false, events: ['sent'] },
  61. { id: 2, domainId: null, enabled: 'false', events: ['sent'] },
  62. { id: 3, domainId: null, enabled: true, events: ['bounced'] },
  63. { id: 4, domainId: null, enabled: true, events: ['sent'] }
  64. ],
  65. domainWebhooks: [],
  66. eventType: 'sent'
  67. });
  68. assert.deepEqual(resolved.map((w) => w.id), [4]);
  69. });
  70. test('builds webhook payload for real and test deliveries', () => {
  71. const real = buildWebhookPayload({
  72. deliveryId: 42,
  73. eventType: 'email.sent',
  74. createdAt: '2026-07-09T12:00:00.000Z',
  75. sendEvent: {
  76. id: 7,
  77. status: 'sent',
  78. queueId: 'A1B2C3',
  79. domain: 'example.com',
  80. sender: 'noreply@example.com',
  81. recipients: ['user@example.com'],
  82. subject: 'Hello',
  83. detail: 'ok',
  84. deliveredAt: '2026-07-09T12:00:01.000Z'
  85. }
  86. });
  87. assert.equal(real.id, 'whd_42');
  88. assert.equal(real.type, 'email.sent');
  89. assert.equal(real.created_at, '2026-07-09T12:00:00.000Z');
  90. assert.equal(real.data.message_id, 'mh-7');
  91. assert.equal(real.data.send_event_id, 7);
  92. assert.equal(real.data.queue_id, 'A1B2C3');
  93. assert.equal(real.data.test, undefined);
  94. const synthetic = buildWebhookPayload({
  95. deliveryId: 1,
  96. eventType: 'email.failed',
  97. createdAt: '2026-07-09T12:00:00.000Z',
  98. sendEvent: {
  99. id: 0,
  100. status: 'failed',
  101. domain: 'example.com',
  102. sender: 'noreply@example.com',
  103. recipients: ['user@example.com'],
  104. subject: 'Test'
  105. },
  106. test: true
  107. });
  108. assert.equal(synthetic.data.test, true);
  109. assert.equal(synthetic.data.message_id, 'mh-test');
  110. assert.equal(synthetic.data.send_event_id, 0);
  111. assert.equal(synthetic.type, 'email.failed');
  112. });
  113. test('signs body with Stripe-style t and v1', () => {
  114. const body = '{"id":"whd_1"}';
  115. const secret = 'secret';
  116. const t = 1_700_000_000;
  117. const header = signWebhookBody(body, secret, t);
  118. assert.equal(header.startsWith('t=1700000000,v1='), true);
  119. assert.match(header, /^t=\d+,v1=[0-9a-f]{64}$/);
  120. const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
  121. assert.equal(header, `t=${t},v1=${expected}`);
  122. });
  123. test('backoff grows then caps', () => {
  124. assert.equal(nextBackoffMs(1), 60_000);
  125. assert.equal(nextBackoffMs(2), 300_000);
  126. assert.equal(nextBackoffMs(3), 1_800_000);
  127. assert.equal(nextBackoffMs(4), 7_200_000);
  128. assert.equal(nextBackoffMs(5), 21_600_000);
  129. assert.equal(nextBackoffMs(6), 43_200_000);
  130. assert.ok(nextBackoffMs(1) < nextBackoffMs(2));
  131. assert.equal(nextBackoffMs(6), nextBackoffMs(7));
  132. assert.equal(nextBackoffMs(10), nextBackoffMs(20));
  133. });
  134. test('normalizeWebhookEvents accepts non-empty subset of terminal events', () => {
  135. assert.deepEqual(normalizeWebhookEvents(['failed', 'sent', 'sent']), ['sent', 'failed']);
  136. assert.deepEqual(normalizeWebhookEvents(['bounced']), ['bounced']);
  137. assert.throws(() => normalizeWebhookEvents([]), /events/i);
  138. assert.throws(() => normalizeWebhookEvents(['queued']), /events/i);
  139. assert.throws(() => normalizeWebhookEvents(null), /events/i);
  140. });
  141. test('parseWebhookEventsJson parses JSON array of events', () => {
  142. assert.deepEqual(parseWebhookEventsJson('["sent","bounced"]'), ['sent', 'bounced']);
  143. assert.throws(() => parseWebhookEventsJson('not-json'), /events/i);
  144. assert.throws(() => parseWebhookEventsJson('[]'), /events/i);
  145. });