diff --git a/src/tests/verify-d2-upload-retry.test.ts b/src/tests/verify-d2-upload-retry.test.ts new file mode 100644 index 0000000..6311191 --- /dev/null +++ b/src/tests/verify-d2-upload-retry.test.ts @@ -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 { + 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'); + }); +});