164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
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>): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
|
|
});
|
|
});
|