D2 verify: upload-source retry endpoint widened and status-honest
Verification test for outcome-contract clauses: - retry-endpoint-widened-and-status-honest: 200 upload | 404 unknown | 409 done - failed-job-error-exposed-for-ui-banner: error field in GET /api/jobs Seeds exact outcome-contract IDs (sh-outcome-*) on real SQLite. All 10 verification tests + 13 existing D2 tests pass (23 total).
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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 } 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(async () => {
|
||||
await rm(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user