learning_ai_common_plat/services/extraction-service/src/modules/extract/webhooks.test.ts

225 lines
7.2 KiB
TypeScript

/**
* Tests for webhook delivery system.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
signWebhookPayload,
verifyWebhookSignature,
buildWebhookPayload,
triggerJobWebhook,
getWebhookDelivery,
listJobDeliveries,
getDeliveryStats,
retryWebhookDelivery,
resetDeliveryLog,
type WebhookConfig,
} from './webhooks.js';
describe('webhooks', () => {
const mockJob = {
id: 'job-123',
status: 'completed' as const,
inputs: [{ text: 'test input' }],
results: [
{
extractions: [{ extraction_class: 'test', extraction_text: 'result' }],
metadata: { model_id: 'gemini', duration_ms: 100, char_count: 10 },
},
],
errors: [],
progress: { completed: 1, total: 1 },
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const mockConfig: WebhookConfig = {
url: 'https://example.com/webhook',
secret: 'test-secret',
};
beforeEach(() => {
vi.restoreAllMocks();
resetDeliveryLog();
});
describe('signWebhookPayload', () => {
it('generates consistent HMAC signatures', () => {
const payload = '{"test": true}';
const sig1 = signWebhookPayload(payload, 'secret');
const sig2 = signWebhookPayload(payload, 'secret');
expect(sig1).toBe(sig2);
expect(sig1).toHaveLength(64); // SHA-256 hex
});
it('produces different signatures for different secrets', () => {
const payload = '{"test": true}';
const sig1 = signWebhookPayload(payload, 'secret1');
const sig2 = signWebhookPayload(payload, 'secret2');
expect(sig1).not.toBe(sig2);
});
});
describe('verifyWebhookSignature', () => {
it('returns true for valid signature', () => {
const payload = '{"test": true}';
const secret = 'my-secret';
const signature = signWebhookPayload(payload, secret);
expect(verifyWebhookSignature(payload, signature, secret)).toBe(true);
});
it('returns false for invalid signature', () => {
const payload = '{"test": true}';
const signature = signWebhookPayload(payload, 'correct-secret');
expect(verifyWebhookSignature(payload, signature, 'wrong-secret')).toBe(false);
});
it('returns false for tampered payload', () => {
const originalPayload = '{"test": true}';
const tamperedPayload = '{"test": false}';
const secret = 'my-secret';
const signature = signWebhookPayload(originalPayload, secret);
expect(verifyWebhookSignature(tamperedPayload, signature, secret)).toBe(false);
});
});
describe('buildWebhookPayload', () => {
it('includes job metadata', () => {
const payload = JSON.parse(buildWebhookPayload(mockJob));
expect(payload.event).toBe('job.completed');
expect(payload.jobId).toBe('job-123');
expect(payload.status).toBe('completed');
expect(payload.timestamp).toBeDefined();
});
it('includes result summary', () => {
const payload = JSON.parse(buildWebhookPayload(mockJob));
expect(payload.resultSummary).toEqual({
totalInputs: 1,
successfulResults: 1,
errorCount: 0,
});
});
});
describe('triggerJobWebhook', () => {
it('delivers webhook successfully', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
await triggerJobWebhook(mockJob, mockConfig);
expect(fetch).toHaveBeenCalledWith(
'https://example.com/webhook',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'X-Webhook-Event': 'job.completed',
}),
})
);
const delivery = listJobDeliveries('job-123')[0];
expect(delivery).toBeDefined();
expect(delivery.status).toBe('delivered');
});
it('records delivery on failure', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
await triggerJobWebhook(mockJob, mockConfig);
const delivery = listJobDeliveries('job-123')[0];
expect(delivery).toBeDefined();
expect(delivery.status).toBe('failed');
expect(delivery.error).toContain('Network error');
});
it('includes correct signature in request', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
await triggerJobWebhook(mockJob, mockConfig);
const call = vi.mocked(fetch).mock.calls[0];
const headers = call[1]?.headers as Record<string, string>;
const payload = call[1]?.body as string;
expect(headers['X-Webhook-Signature']).toMatch(/^sha256=/);
const signature = headers['X-Webhook-Signature'].replace('sha256=', '');
expect(verifyWebhookSignature(payload, signature, mockConfig.secret)).toBe(true);
});
});
describe('delivery stats', () => {
it('returns zero stats when no deliveries', () => {
const stats = getDeliveryStats();
expect(stats.total).toBe(0);
expect(stats.delivered).toBe(0);
expect(stats.failed).toBe(0);
expect(stats.pending).toBe(0);
});
it('accurately counts delivery statuses', async () => {
globalThis.fetch = vi.fn()
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockRejectedValueOnce(new Error('Failed'));
const job1 = { ...mockJob, id: 'job-1' };
const job2 = { ...mockJob, id: 'job-2' };
await triggerJobWebhook(job1, mockConfig);
await triggerJobWebhook(job2, mockConfig);
const stats = getDeliveryStats();
expect(stats.total).toBe(2);
expect(stats.delivered).toBe(1);
expect(stats.failed).toBe(1);
});
});
describe('retryWebhookDelivery', () => {
it('retries failed delivery successfully', async () => {
// First, make all fetch calls fail (for initial trigger with retries)
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Persistent failure'));
await triggerJobWebhook(mockJob, mockConfig);
const delivery = listJobDeliveries('job-123')[0];
expect(delivery.status).toBe('failed');
// Now set up mock to succeed for the retry
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
const retried = await retryWebhookDelivery(delivery.id, mockJob, mockConfig);
expect(retried).toBe(true);
const updated = getWebhookDelivery(delivery.id);
expect(updated?.status).toBe('delivered');
});
it('returns false for unknown delivery ID', async () => {
const result = await retryWebhookDelivery('unknown-id', mockJob, mockConfig);
expect(result).toBe(false);
});
});
describe('listJobDeliveries', () => {
it('returns deliveries sorted by creation time', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
const job1 = { ...mockJob, id: 'job-1' };
const job2 = { ...mockJob, id: 'job-2' };
await triggerJobWebhook(job1, mockConfig);
await new Promise(r => setTimeout(r, 10));
await triggerJobWebhook(job2, mockConfig);
const deliveries = listJobDeliveries('job-1');
expect(deliveries).toHaveLength(1);
expect(deliveries[0].jobId).toBe('job-1');
});
});
});