Merge fleet/story-d2-verify-upload-retry: D2 verify upload-source retry endpoint widened and status-honest
Build & Push Docker Image / test (push) Failing after 12s
Build & Push Docker Image / build-and-push (push) Has been skipped

This commit is contained in:
Giancarmine Salucci
2026-07-09 06:09:01 +02:00
+189
View File
@@ -0,0 +1,189 @@
import { describe, it, expect, afterAll, vi } from 'vitest';
import { join } from 'path';
import { tmpdir } from 'os';
import { rm, mkdir, writeFile } from 'fs/promises';
import { existsSync, rmSync } from 'fs';
import Database from 'better-sqlite3';
/**
* D2 verification: outcome-contract clauses against REAL SQLite + REAL endpoint.
*
* Clause retry-endpoint-widened-and-status-honest:
* POST /api/jobs/:id/retry → 200 for failed upload-source,
* 404 for unknown ID, 409 for non-retryable (done).
*
* Clause failed-job-error-exposed-for-ui-banner:
* GET /api/jobs → error field present for failed jobs.
*
* Seeds exact IDs from the outcome-contract cmd so the contract script
* can be replaced by this vitest suite. D2 code already merged (f03b757);
* this is non-blocking verification against deployed behaviour.
*/
const TEST_DATA_DIR = join(tmpdir(), 'whisper-pwa-verify-d2-' + process.pid);
vi.stubEnv('DATA_DIR', TEST_DATA_DIR);
const { getJob } = await import('$lib/server/db.js');
const { POST } = await import('$lib/../routes/api/jobs/[id]/retry/+server.js');
const { GET } = await import('$lib/../routes/api/jobs/+server.js');
afterAll(() => {
try {
// rmSync with maxRetries handles ENOTEMPTY/EBUSY from WAL/filesystem races
rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 3 });
} catch {
// Best-effort: retryJob's async IIFE may recreate files during cleanup.
// CI container is ephemeral — leftover /tmp files are harmless.
}
});
function makeEvent(jobId: string) {
return { params: { id: jobId } } as any;
}
/**
* Seed a job row with explicit ID + fields.
* createJob() generates a random UUID, so we INSERT directly via
* a second connection to the same test DB.
*/
function seedJob(
id: string,
status: string,
source: string,
error: string | null,
title?: string
) {
const dbPath = join(TEST_DATA_DIR, 'jobs.db');
const db = new Database(dbPath);
db.prepare(`
INSERT OR REPLACE INTO jobs
(id, status, title, source, audio_mode, error, created_at, updated_at)
VALUES (?, ?, ?, ?, 'auto', ?, datetime('now'), datetime('now'))
`).run(id, status, title ?? id, source, error);
db.close();
}
/**
* Create a stub upload file on disk so async retry can read it.
*/
async function seedUploadFile(jobId: string, filename: string): Promise<void> {
const uploadDir = join(TEST_DATA_DIR, 'uploads', jobId);
if (!existsSync(uploadDir)) await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, filename), 'fake audio data');
}
describe('clause: retry-endpoint-widened-and-status-honest', () => {
it('retries a failed upload-source job → 200', async () => {
seedJob('sh-outcome-upload-retry', 'failed',
'probe-upload.webm', 'simulated failure');
await seedUploadFile('sh-outcome-upload-retry', 'probe-upload.webm');
const res = await POST(makeEvent('sh-outcome-upload-retry'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ ok: true });
// Confirm job was reset to pending
const job = getJob('sh-outcome-upload-retry')!;
expect(job.status).toBe('pending');
expect(job.error).toBeNull();
});
it('returns 404 for unknown job ID', async () => {
await expect(
POST(makeEvent('sh-outcome-does-not-exist'))
).rejects.toMatchObject({ status: 404 });
});
it('returns 409 for a done (non-retryable) job', async () => {
seedJob('sh-outcome-nonretryable-done', 'done',
'probe-done.webm', null);
await expect(
POST(makeEvent('sh-outcome-nonretryable-done'))
).rejects.toMatchObject({ status: 409 });
});
it('returns 409 for a pending job (not yet retryable)', async () => {
seedJob('sh-outcome-pending', 'pending',
'probe-pending.webm', null);
await expect(
POST(makeEvent('sh-outcome-pending'))
).rejects.toMatchObject({ status: 409 });
});
it('allows retry for a cancelled YouTube-source job', async () => {
seedJob('sh-outcome-cancelled', 'cancelled',
'https://youtube.com/watch?v=example', 'user cancelled');
const res = await POST(makeEvent('sh-outcome-cancelled'));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});
it('preserves 200 for failed YouTube-source jobs (widened, not restricted)', async () => {
seedJob('sh-outcome-yt-retry', 'failed',
'https://youtube.com/watch?v=test', 'YouTube download failed');
const res = await POST(makeEvent('sh-outcome-yt-retry'));
expect(res.status).toBe(200);
});
it('allows retry for failed upload-source with non-webm filename', async () => {
seedJob('sh-outcome-mp3-retry', 'failed',
'podcast.mp3', 'audio prep failure');
await seedUploadFile('sh-outcome-mp3-retry', 'podcast.mp3');
const res = await POST(makeEvent('sh-outcome-mp3-retry'));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});
});
describe('clause: failed-job-error-exposed-for-ui-banner', () => {
it('includes error field for a failed job', async () => {
seedJob('sh-outcome-error-banner', 'failed',
'probe-upload-2.webm',
'simulated transcription failure for outcome-contract probe');
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
const found = body.find((j: any) => j.id === 'sh-outcome-error-banner');
expect(found).toBeDefined();
expect(found.status).toBe('failed');
expect(found.error).toBe(
'simulated transcription failure for outcome-contract probe'
);
});
it('returns null error for a successful done job', async () => {
seedJob('sh-outcome-done-probe', 'done',
'probe-success.webm', null);
const res = await GET();
const body = await res.json();
const found = body.find((j: any) => j.id === 'sh-outcome-done-probe');
expect(found).toBeDefined();
expect(found.error).toBeNull();
});
it('preserves error field alongside other job fields', async () => {
seedJob('sh-outcome-full-probe', 'failed',
'my-recording.webm', 'transcription failed', 'My Recording');
const res = await GET();
const body = await res.json();
const found = body.find((j: any) => j.id === 'sh-outcome-full-probe');
expect(found).toBeDefined();
expect(found.status).toBe('failed');
expect(found.error).toBe('transcription failed');
expect(found.title).toBe('My Recording');
expect(found.source).toBe('my-recording.webm');
expect(found.audioMode).toBe('auto');
});
});