fix: harden queue lifecycle and publish gate

- 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>
This commit is contained in:
2026-05-16 18:39:19 +02:00
parent 679053b27d
commit a9429e2118
16 changed files with 1867 additions and 87 deletions

View File

@@ -72,4 +72,128 @@ describe('WebhookDispatcher', () => {
});
}
});
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();
});
});
}
});
});