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