import { createServer } from 'node:http'; import { WebhookDispatcher } from '../src/index.js'; describe('WebhookDispatcher', () => { it('delivers signed webhook payloads', async () => { const payloads: string[] = []; const headers: string[] = []; const server = createServer((request, response) => { let body = ''; request.on('data', (chunk) => { body += chunk.toString(); }); request.on('end', () => { payloads.push(body); headers.push(String(request.headers['x-jobqueue-signature'])); response.writeHead(202).end(); }); }); await new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve()); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Server address unavailable'); } const dispatcher = new WebhookDispatcher({ url: `http://127.0.0.1:${address.port}/hook`, secret: 'top-secret', events: ['job:completed'], }); try { const result = await dispatcher.dispatch('job:completed', { id: 'job-1', status: 'completed', data: { url: 'https://example.com' }, currentPhase: null, phases: [], phaseResults: {}, progress: 100, progressMessage: null, error: null, retryCount: 0, maxAttempts: 1, webhookUrl: null, webhookSent: false, createdAt: new Date().toISOString(), startedAt: null, completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), scheduledAt: null, cancelledAt: null, }); expect(result.status).toBe(202); expect(payloads).toHaveLength(1); expect(payloads[0]).toContain('"event":"job:completed"'); expect(headers[0]).toMatch(/^[a-f0-9]{64}$/); } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); } }); it('retries retryable webhook responses until success', async () => { let attempts = 0; const server = createServer((_request, response) => { attempts += 1; response.writeHead(attempts < 3 ? 503 : 202).end(); }); await new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve()); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Server address unavailable'); } const dispatcher = new WebhookDispatcher({ url: `http://127.0.0.1:${address.port}/hook`, events: ['job:completed'], maxAttempts: 4, baseDelayMs: 1, maxDelayMs: 1, }); try { const result = await dispatcher.dispatch('job:completed', { id: 'job-1', status: 'completed', data: { url: 'https://example.com' }, currentPhase: null, phases: [], phaseResults: {}, progress: 100, progressMessage: null, error: null, retryCount: 0, maxAttempts: 1, webhookUrl: null, webhookSent: false, createdAt: new Date().toISOString(), startedAt: null, completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), scheduledAt: null, cancelledAt: null, }); expect(result.status).toBe(202); expect(attempts).toBe(3); } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); } }); it('does not retry non-retryable webhook responses', async () => { let attempts = 0; const server = createServer((_request, response) => { attempts += 1; response.writeHead(400).end(); }); await new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve()); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Server address unavailable'); } const dispatcher = new WebhookDispatcher({ url: `http://127.0.0.1:${address.port}/hook`, events: ['job:completed'], maxAttempts: 4, baseDelayMs: 1, maxDelayMs: 1, }); try { await expect( dispatcher.dispatch('job:completed', { id: 'job-1', status: 'completed', data: { url: 'https://example.com' }, currentPhase: null, phases: [], phaseResults: {}, progress: 100, progressMessage: null, error: null, retryCount: 0, maxAttempts: 1, webhookUrl: null, webhookSent: false, createdAt: new Date().toISOString(), startedAt: null, completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), scheduledAt: null, cancelledAt: null, }), ).rejects.toThrow(/non-retryable status 400/); expect(attempts).toBe(1); } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); } }); });