- Preserve phase results on partial retry and keep interrupted phase context after restart. - Avoid webhook bookkeeping crashes when retention deletes stale jobs. - Add deeper unit, integration, and e2e coverage around queue seams. - Require verify job to pass before publish runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
200 lines
5.3 KiB
TypeScript
200 lines
5.3 KiB
TypeScript
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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
});
|
|
});
|