import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { signPayload, deliverToTarget, dispatchToTargets } from './dispatcher.js'; import type { WebhookTarget } from './types.js'; function makeTarget(overrides?: Partial): WebhookTarget { return { id: 'wh_1', url: 'https://example.com/webhook', secret: 'test-secret-key', events: [], enabled: true, ...overrides, }; } describe('signPayload', () => { it('produces consistent HMAC-SHA256 signatures', () => { const sig1 = signPayload('{"test":true}', 'secret'); const sig2 = signPayload('{"test":true}', 'secret'); expect(sig1).toBe(sig2); expect(sig1).toMatch(/^[a-f0-9]{64}$/); }); it('produces different signatures for different secrets', () => { const sig1 = signPayload('data', 'secret1'); const sig2 = signPayload('data', 'secret2'); expect(sig1).not.toBe(sig2); }); }); describe('deliverToTarget', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.restoreAllMocks(); }); it('returns success on 200', async () => { (fetch as ReturnType).mockResolvedValue({ ok: true, status: 200 }); const result = await deliverToTarget( makeTarget(), 'task.created', { taskId: 't1' }, 'flowmonk', { maxRetries: 1, backoffIntervals: [0] } ); expect(result.status).toBe('success'); expect(result.attempts).toHaveLength(1); expect(result.attempts[0].responseCode).toBe(200); }); it('returns failed after exhausting retries on 500', async () => { (fetch as ReturnType).mockResolvedValue({ ok: false, status: 500 }); const result = await deliverToTarget( makeTarget(), 'task.created', { taskId: 't1' }, 'flowmonk', { maxRetries: 2, backoffIntervals: [0] } ); expect(result.status).toBe('failed'); expect(result.attempts).toHaveLength(2); }); it('returns failed on network error', async () => { (fetch as ReturnType).mockRejectedValue(new Error('ECONNREFUSED')); const result = await deliverToTarget( makeTarget(), 'task.created', { taskId: 't1' }, 'flowmonk', { maxRetries: 1, backoffIntervals: [0] } ); expect(result.status).toBe('failed'); expect(result.attempts[0].error).toBe('ECONNREFUSED'); }); it('calls onDelivery callback', async () => { (fetch as ReturnType).mockResolvedValue({ ok: true, status: 200 }); const onDelivery = vi.fn(); await deliverToTarget(makeTarget(), 'task.created', { taskId: 't1' }, 'flowmonk', { maxRetries: 1, backoffIntervals: [0], onDelivery, }); expect(onDelivery).toHaveBeenCalledOnce(); expect(onDelivery.mock.calls[0][0].status).toBe('success'); }); it('sends correct headers', async () => { (fetch as ReturnType).mockResolvedValue({ ok: true, status: 200 }); await deliverToTarget( makeTarget({ url: 'https://test.com/hook' }), 'schedule.generated', {}, 'flowmonk', { maxRetries: 1, backoffIntervals: [0] } ); const call = (fetch as ReturnType).mock.calls[0]; expect(call[0]).toBe('https://test.com/hook'); expect(call[1].headers['Content-Type']).toBe('application/json'); expect(call[1].headers['X-Webhook-Event']).toBe('schedule.generated'); expect(call[1].headers['X-Webhook-Signature']).toMatch(/^sha256=[a-f0-9]{64}$/); }); }); describe('dispatchToTargets', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); }); afterEach(() => { vi.restoreAllMocks(); }); it('dispatches to all enabled matching targets', async () => { const targets = [ makeTarget({ id: 'wh_1', events: ['task.created'] }), makeTarget({ id: 'wh_2', events: ['task.created', 'schedule.generated'] }), makeTarget({ id: 'wh_3', events: ['schedule.generated'], enabled: false }), ]; const results = await dispatchToTargets(targets, 'task.created', {}, 'flowmonk', { maxRetries: 1, backoffIntervals: [0], }); expect(results).toHaveLength(2); expect(results.every(r => r.status === 'success')).toBe(true); }); it('dispatches to targets with empty events (wildcard)', async () => { const targets = [makeTarget({ id: 'wh_1', events: [] })]; const results = await dispatchToTargets(targets, 'any.event', {}, 'flowmonk', { maxRetries: 1, backoffIntervals: [0], }); expect(results).toHaveLength(1); }); it('returns empty array when no targets match', async () => { const targets = [makeTarget({ events: ['other.event'] })]; const results = await dispatchToTargets(targets, 'task.created', {}, 'flowmonk', { maxRetries: 1, backoffIntervals: [0], }); expect(results).toHaveLength(0); }); });