|
|
@@ -0,0 +1,158 @@
|
|
|
+import assert from 'node:assert/strict';
|
|
|
+import crypto from 'node:crypto';
|
|
|
+import { test } from 'node:test';
|
|
|
+
|
|
|
+import {
|
|
|
+ TERMINAL_WEBHOOK_EVENTS,
|
|
|
+ MAX_WEBHOOK_ATTEMPTS,
|
|
|
+ WEBHOOK_LEASE_MS,
|
|
|
+ eventTypeForStatus,
|
|
|
+ resolveWebhooksForEvent,
|
|
|
+ buildWebhookPayload,
|
|
|
+ signWebhookBody,
|
|
|
+ nextBackoffMs,
|
|
|
+ isTerminalWebhookStatus,
|
|
|
+ normalizeWebhookEvents,
|
|
|
+ parseWebhookEventsJson
|
|
|
+} from '../src/webhook-model.js';
|
|
|
+
|
|
|
+test('maps terminal statuses to email.* types', () => {
|
|
|
+ assert.equal(eventTypeForStatus('sent'), 'email.sent');
|
|
|
+ assert.equal(eventTypeForStatus('bounced'), 'email.bounced');
|
|
|
+ assert.equal(eventTypeForStatus('failed'), 'email.failed');
|
|
|
+ assert.equal(eventTypeForStatus('queued'), null);
|
|
|
+ assert.equal(eventTypeForStatus('deferred'), null);
|
|
|
+});
|
|
|
+
|
|
|
+test('isTerminalWebhookStatus matches terminal set', () => {
|
|
|
+ assert.equal(isTerminalWebhookStatus('sent'), true);
|
|
|
+ assert.equal(isTerminalWebhookStatus('bounced'), true);
|
|
|
+ assert.equal(isTerminalWebhookStatus('failed'), true);
|
|
|
+ assert.equal(isTerminalWebhookStatus('queued'), false);
|
|
|
+ assert.equal(isTerminalWebhookStatus('processing'), false);
|
|
|
+ assert.deepEqual(TERMINAL_WEBHOOK_EVENTS, ['sent', 'bounced', 'failed']);
|
|
|
+ assert.equal(MAX_WEBHOOK_ATTEMPTS, 8);
|
|
|
+ assert.equal(WEBHOOK_LEASE_MS, 2 * 60 * 1000);
|
|
|
+});
|
|
|
+
|
|
|
+test('domain webhooks override account for the same event', () => {
|
|
|
+ const account = [
|
|
|
+ { id: 1, domainId: null, enabled: true, events: ['sent', 'failed'] },
|
|
|
+ { id: 2, domainId: null, enabled: true, events: ['bounced'] }
|
|
|
+ ];
|
|
|
+ const domain = [
|
|
|
+ { id: 3, domainId: 9, enabled: true, events: ['sent'] }
|
|
|
+ ];
|
|
|
+ const resolved = resolveWebhooksForEvent({
|
|
|
+ accountWebhooks: account,
|
|
|
+ domainWebhooks: domain,
|
|
|
+ eventType: 'sent'
|
|
|
+ });
|
|
|
+ assert.deepEqual(resolved.map((w) => w.id), [3]);
|
|
|
+});
|
|
|
+
|
|
|
+test('falls back to account when domain has no matching enabled subscription', () => {
|
|
|
+ const resolved = resolveWebhooksForEvent({
|
|
|
+ accountWebhooks: [{ id: 1, domainId: null, enabled: true, events: ['failed'] }],
|
|
|
+ domainWebhooks: [{ id: 3, domainId: 9, enabled: true, events: ['sent'] }],
|
|
|
+ eventType: 'failed'
|
|
|
+ });
|
|
|
+ assert.deepEqual(resolved.map((w) => w.id), [1]);
|
|
|
+});
|
|
|
+
|
|
|
+test('skips disabled webhooks and unsubscribed events', () => {
|
|
|
+ const resolved = resolveWebhooksForEvent({
|
|
|
+ accountWebhooks: [
|
|
|
+ { id: 1, domainId: null, enabled: false, events: ['sent'] },
|
|
|
+ { id: 2, domainId: null, enabled: 'false', events: ['sent'] },
|
|
|
+ { id: 3, domainId: null, enabled: true, events: ['bounced'] },
|
|
|
+ { id: 4, domainId: null, enabled: true, events: ['sent'] }
|
|
|
+ ],
|
|
|
+ domainWebhooks: [],
|
|
|
+ eventType: 'sent'
|
|
|
+ });
|
|
|
+ assert.deepEqual(resolved.map((w) => w.id), [4]);
|
|
|
+});
|
|
|
+
|
|
|
+test('builds webhook payload for real and test deliveries', () => {
|
|
|
+ const real = buildWebhookPayload({
|
|
|
+ deliveryId: 42,
|
|
|
+ eventType: 'email.sent',
|
|
|
+ createdAt: '2026-07-09T12:00:00.000Z',
|
|
|
+ sendEvent: {
|
|
|
+ id: 7,
|
|
|
+ status: 'sent',
|
|
|
+ queueId: 'A1B2C3',
|
|
|
+ domain: 'example.com',
|
|
|
+ sender: 'noreply@example.com',
|
|
|
+ recipients: ['user@example.com'],
|
|
|
+ subject: 'Hello',
|
|
|
+ detail: 'ok',
|
|
|
+ deliveredAt: '2026-07-09T12:00:01.000Z'
|
|
|
+ }
|
|
|
+ });
|
|
|
+ assert.equal(real.id, 'whd_42');
|
|
|
+ assert.equal(real.type, 'email.sent');
|
|
|
+ assert.equal(real.created_at, '2026-07-09T12:00:00.000Z');
|
|
|
+ assert.equal(real.data.message_id, 'mh-7');
|
|
|
+ assert.equal(real.data.send_event_id, 7);
|
|
|
+ assert.equal(real.data.queue_id, 'A1B2C3');
|
|
|
+ assert.equal(real.data.test, undefined);
|
|
|
+
|
|
|
+ const synthetic = buildWebhookPayload({
|
|
|
+ deliveryId: 1,
|
|
|
+ eventType: 'email.failed',
|
|
|
+ createdAt: '2026-07-09T12:00:00.000Z',
|
|
|
+ sendEvent: {
|
|
|
+ id: 0,
|
|
|
+ status: 'failed',
|
|
|
+ domain: 'example.com',
|
|
|
+ sender: 'noreply@example.com',
|
|
|
+ recipients: ['user@example.com'],
|
|
|
+ subject: 'Test'
|
|
|
+ },
|
|
|
+ test: true
|
|
|
+ });
|
|
|
+ assert.equal(synthetic.data.test, true);
|
|
|
+ assert.equal(synthetic.data.message_id, 'mh-test');
|
|
|
+ assert.equal(synthetic.data.send_event_id, 0);
|
|
|
+ assert.equal(synthetic.type, 'email.failed');
|
|
|
+});
|
|
|
+
|
|
|
+test('signs body with Stripe-style t and v1', () => {
|
|
|
+ const body = '{"id":"whd_1"}';
|
|
|
+ const secret = 'secret';
|
|
|
+ const t = 1_700_000_000;
|
|
|
+ const header = signWebhookBody(body, secret, t);
|
|
|
+ assert.equal(header.startsWith('t=1700000000,v1='), true);
|
|
|
+ assert.match(header, /^t=\d+,v1=[0-9a-f]{64}$/);
|
|
|
+
|
|
|
+ const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
|
|
|
+ assert.equal(header, `t=${t},v1=${expected}`);
|
|
|
+});
|
|
|
+
|
|
|
+test('backoff grows then caps', () => {
|
|
|
+ assert.equal(nextBackoffMs(1), 60_000);
|
|
|
+ assert.equal(nextBackoffMs(2), 300_000);
|
|
|
+ assert.equal(nextBackoffMs(3), 1_800_000);
|
|
|
+ assert.equal(nextBackoffMs(4), 7_200_000);
|
|
|
+ assert.equal(nextBackoffMs(5), 21_600_000);
|
|
|
+ assert.equal(nextBackoffMs(6), 43_200_000);
|
|
|
+ assert.ok(nextBackoffMs(1) < nextBackoffMs(2));
|
|
|
+ assert.equal(nextBackoffMs(6), nextBackoffMs(7));
|
|
|
+ assert.equal(nextBackoffMs(10), nextBackoffMs(20));
|
|
|
+});
|
|
|
+
|
|
|
+test('normalizeWebhookEvents accepts non-empty subset of terminal events', () => {
|
|
|
+ assert.deepEqual(normalizeWebhookEvents(['failed', 'sent', 'sent']), ['sent', 'failed']);
|
|
|
+ assert.deepEqual(normalizeWebhookEvents(['bounced']), ['bounced']);
|
|
|
+ assert.throws(() => normalizeWebhookEvents([]), /events/i);
|
|
|
+ assert.throws(() => normalizeWebhookEvents(['queued']), /events/i);
|
|
|
+ assert.throws(() => normalizeWebhookEvents(null), /events/i);
|
|
|
+});
|
|
|
+
|
|
|
+test('parseWebhookEventsJson parses JSON array of events', () => {
|
|
|
+ assert.deepEqual(parseWebhookEventsJson('["sent","bounced"]'), ['sent', 'bounced']);
|
|
|
+ assert.throws(() => parseWebhookEventsJson('not-json'), /events/i);
|
|
|
+ assert.throws(() => parseWebhookEventsJson('[]'), /events/i);
|
|
|
+});
|