import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Segment, WhisperJob } from '$lib/types.js'; // ── Hoist all mock functions so they're available inside vi.mock() factories ── const { mockGetJob, mockUpdateJob, mockSetJobStatus, mockWriteOutputs, mockSendNotification, mockCleanupJobTmp, mockEmitProgress } = vi.hoisted(() => ({ mockGetJob: vi.fn(), mockUpdateJob: vi.fn(), mockSetJobStatus: vi.fn(), mockWriteOutputs: vi.fn(), mockSendNotification: vi.fn(), mockCleanupJobTmp: vi.fn(), mockEmitProgress: vi.fn() })); vi.mock('$lib/server/db.js', () => ({ getJob: mockGetJob, updateJob: mockUpdateJob, setJobStatus: mockSetJobStatus })); vi.mock('$lib/server/formatter.js', () => ({ writeOutputs: mockWriteOutputs })); vi.mock('$lib/server/push.js', () => ({ sendNotification: mockSendNotification })); vi.mock('$lib/server/downloader.js', () => ({ cleanupJobTmp: mockCleanupJobTmp })); vi.mock('$lib/server/pipeline.js', () => ({ emitProgress: mockEmitProgress })); // Import the handler AFTER mocks are in place import { POST } from '$lib/../routes/api/webhook/[jobId]/+server.js'; // ── Test helpers ────────────────────────────────────────────────────────────── function makeEvent(jobId: string, body: unknown) { return { params: { jobId }, request: new Request(`http://localhost/api/webhook/${jobId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) }; } function makeJob(id: string, title = 'Test Video') { return { id, status: 'transcribing', title, source: 'url', audioMode: 'auto', progress: 10 }; } function makeWhisperJob(overrides: Partial = {}): WhisperJob { return { id: 'whisper-id', status: 'done', language: 'en', task: 'transcribe', duration_secs: 60, progress: 100, segments: [], error: null, created_at: new Date().toISOString(), completed_at: new Date().toISOString(), ...overrides }; } function makeSeg(index: number, text: string): Segment { return { index, start: index * 5, end: index * 5 + 5, text, words: [] }; } beforeEach(() => { vi.clearAllMocks(); mockWriteOutputs.mockResolvedValue({ srt: '/out/dir/title.srt', txt: '/out/dir/title.txt', md: '/out/dir/title.md', json: '/out/dir/title.json' }); mockSendNotification.mockResolvedValue(undefined); mockCleanupJobTmp.mockResolvedValue(undefined); }); // ── 404 for unknown job ─────────────────────────────────────────────────────── describe('POST /api/webhook/[jobId] — job not found', () => { it('throws 404 when the job does not exist in the database', async () => { mockGetJob.mockReturnValue(null); await expect(POST(makeEvent('ghost-id', makeWhisperJob()) as any)).rejects.toMatchObject({ status: 404 }); }); }); // ── Ignore backend model lifecycle webhooks ───────────────────────────────────── describe('POST /api/webhook/[jobId] — non-job webhook payloads', () => { it('ignores model_ready events sent to job webhooks', async () => { mockGetJob.mockReturnValue(makeJob('job-model-ready')); const res = await POST( makeEvent('job-model-ready', { type: 'model_ready', loaded_at: new Date().toISOString() }) as any ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, ignored: 'not_a_job_event' }); expect(mockSetJobStatus).not.toHaveBeenCalled(); expect(mockUpdateJob).not.toHaveBeenCalled(); expect(mockWriteOutputs).not.toHaveBeenCalled(); expect(mockSendNotification).not.toHaveBeenCalled(); }); it('ignores model_unloaded events sent to job webhooks', async () => { mockGetJob.mockReturnValue(makeJob('job-model-unloaded')); const res = await POST( makeEvent('job-model-unloaded', { type: 'model_unloaded', unloaded_at: new Date().toISOString() }) as any ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, ignored: 'not_a_job_event' }); expect(mockSetJobStatus).not.toHaveBeenCalled(); expect(mockUpdateJob).not.toHaveBeenCalled(); expect(mockWriteOutputs).not.toHaveBeenCalled(); expect(mockSendNotification).not.toHaveBeenCalled(); }); it('ignores payloads with invalid status values', async () => { mockGetJob.mockReturnValue(makeJob('job-invalid-status')); const res = await POST( makeEvent('job-invalid-status', { id: 'bogus-whisper-id', status: 'model_ready', segments: [] }) as any ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, ignored: 'not_a_job_event' }); expect(mockSetJobStatus).not.toHaveBeenCalled(); expect(mockUpdateJob).not.toHaveBeenCalled(); expect(mockWriteOutputs).not.toHaveBeenCalled(); }); }); // ── Local cancellation guard ────────────────────────────────────────────────── describe('POST /api/webhook/[jobId] — locally cancelled job', () => { it('returns ok without processing when the local job is already cancelled', async () => { mockGetJob.mockReturnValue({ ...makeJob('job-lc'), status: 'cancelled' }); const payload = makeWhisperJob({ status: 'done' }); const res = await POST(makeEvent('job-lc', payload) as any); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); // Must not touch outputs, status, or notifications expect(mockSetJobStatus).not.toHaveBeenCalled(); expect(mockUpdateJob).not.toHaveBeenCalled(); expect(mockWriteOutputs).not.toHaveBeenCalled(); expect(mockSendNotification).not.toHaveBeenCalled(); }); }); // ── Duplicate / stale callback guards ────────────────────────────────────────── describe('POST /api/webhook/[jobId] — duplicate and stale callbacks', () => { it('ignores replayed success callbacks after the transcript is already done', async () => { mockGetJob.mockReturnValue({ ...makeJob('job-done'), status: 'done', segmentsJson: JSON.stringify([makeSeg(0, 'Already saved.')]), whisperJobId: 'whisper-id' }); const res = await POST(makeEvent('job-done', makeWhisperJob()) as any); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, ignored: 'duplicate_webhook' }); expect(mockSetJobStatus).not.toHaveBeenCalled(); expect(mockUpdateJob).not.toHaveBeenCalled(); expect(mockWriteOutputs).not.toHaveBeenCalled(); }); it('ignores stale callbacks from an older whisper job after retry', async () => { mockGetJob.mockReturnValue({ ...makeJob('job-stale'), status: 'transcribing', whisperJobId: 'current-whisper-job' }); const res = await POST( makeEvent('job-stale', makeWhisperJob({ id: 'old-whisper-job', segments: [makeSeg(0, 'stale')] })) as any ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, ignored: 'stale_whisper_job' }); expect(mockSetJobStatus).not.toHaveBeenCalled(); expect(mockUpdateJob).not.toHaveBeenCalled(); expect(mockWriteOutputs).not.toHaveBeenCalled(); }); }); // ── Whisper job failed / cancelled ─────────────────────────────────────────── describe('POST /api/webhook/[jobId] — whisper failure', () => { it('marks the job as failed when whisper status is "failed"', async () => { mockGetJob.mockReturnValue(makeJob('job-1')); const payload = makeWhisperJob({ status: 'failed', error: 'GPU OOM', segments: [] }); const res = await POST(makeEvent('job-1', payload) as any); expect(res.status).toBe(200); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ id: 'job-1', status: 'failed', error: 'GPU OOM' }) ); expect(mockWriteOutputs).not.toHaveBeenCalled(); expect(mockSendNotification).not.toHaveBeenCalled(); }); it('marks the job as failed when whisper status is "cancelled"', async () => { mockGetJob.mockReturnValue(makeJob('job-2')); const payload = makeWhisperJob({ status: 'cancelled', segments: [] }); await POST(makeEvent('job-2', payload) as any); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ status: 'failed' }) ); }); it('uses the whisper error message when provided', async () => { mockGetJob.mockReturnValue(makeJob('job-err')); const payload = makeWhisperJob({ status: 'failed', error: 'CUDA device error', segments: [] }); await POST(makeEvent('job-err', payload) as any); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ error: 'CUDA device error' }) ); }); it('emits an error progress event', async () => { mockGetJob.mockReturnValue(makeJob('job-ev')); await POST(makeEvent('job-ev', makeWhisperJob({ status: 'failed', segments: [] })) as any); expect(mockEmitProgress).toHaveBeenCalledWith('job-ev', expect.objectContaining({ type: 'error' })); }); }); // ── Successful transcription with segments ─────────────────────────────────── describe('POST /api/webhook/[jobId] — success with segments', () => { const segments = [makeSeg(0, 'Hello world.'), makeSeg(1, 'This is a test.')]; it('passes received segments through unchanged', async () => { mockGetJob.mockReturnValue(makeJob('job-3')); await POST(makeEvent('job-3', makeWhisperJob({ segments })) as any); expect(mockWriteOutputs).toHaveBeenCalledWith(segments, 'Test Video', 'job-3'); }); it('calls writeOutputs with the received segments and job title', async () => { mockGetJob.mockReturnValue(makeJob('job-4', 'My Lecture')); await POST(makeEvent('job-4', makeWhisperJob({ segments })) as any); expect(mockWriteOutputs).toHaveBeenCalledWith(segments, 'My Lecture', 'job-4'); }); it('stores serialised segments_json in the database', async () => { mockGetJob.mockReturnValue(makeJob('job-5')); await POST(makeEvent('job-5', makeWhisperJob({ segments })) as any); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ id: 'job-5', status: 'done', segmentsJson: JSON.stringify(segments) }) ); }); it('sets job status to done with progress 100', async () => { mockGetJob.mockReturnValue(makeJob('job-6')); await POST(makeEvent('job-6', makeWhisperJob({ segments })) as any); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ status: 'done', progress: 100 }) ); }); it('sets outputDir from the paths returned by writeOutputs', async () => { mockGetJob.mockReturnValue(makeJob('job-7')); mockWriteOutputs.mockResolvedValue({ srt: '/home/user/transcripts/My_Title/My_Title.srt', txt: '/home/user/transcripts/My_Title/My_Title.txt', md: '/home/user/transcripts/My_Title/My_Title.md', json: '/home/user/transcripts/My_Title/My_Title.json' }); await POST(makeEvent('job-7', makeWhisperJob({ segments })) as any); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ outputDir: '/home/user/transcripts/My_Title' }) ); }); it('sends a push notification with the job title', async () => { mockGetJob.mockReturnValue(makeJob('job-8', 'Awesome Lecture')); await POST(makeEvent('job-8', makeWhisperJob({ segments })) as any); expect(mockSendNotification).toHaveBeenCalledWith( 'job-8', '✅ Transcript ready', 'Awesome Lecture' ); }); it('cleans up tmp files after completion', async () => { mockGetJob.mockReturnValue(makeJob('job-9')); await POST(makeEvent('job-9', makeWhisperJob({ segments })) as any); expect(mockCleanupJobTmp).toHaveBeenCalledWith('job-9'); }); it('emits a done progress event', async () => { mockGetJob.mockReturnValue(makeJob('job-10')); await POST(makeEvent('job-10', makeWhisperJob({ segments })) as any); expect(mockEmitProgress).toHaveBeenCalledWith('job-10', expect.objectContaining({ type: 'done' })); }); it('returns { ok: true } with status 200', async () => { mockGetJob.mockReturnValue(makeJob('job-11')); const res = await POST(makeEvent('job-11', makeWhisperJob({ segments })) as any); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({ ok: true }); }); }); // ── Empty segments (whisper produced no output) ─────────────────────────────── describe('POST /api/webhook/[jobId] — empty segments', () => { it('completes the job as done even with zero segments', async () => { mockGetJob.mockReturnValue(makeJob('job-empty')); await POST(makeEvent('job-empty', makeWhisperJob({ segments: [] })) as any); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ status: 'done' }) ); }); it('writes empty outputs for an empty segment array', async () => { mockGetJob.mockReturnValue(makeJob('job-empty-2')); await POST(makeEvent('job-empty-2', makeWhisperJob({ segments: [] })) as any); expect(mockWriteOutputs).toHaveBeenCalledWith([], expect.any(String), 'job-empty-2'); }); it('still sends a push notification for empty transcription', async () => { mockGetJob.mockReturnValue(makeJob('job-empty-3')); await POST(makeEvent('job-empty-3', makeWhisperJob({ segments: [] })) as any); expect(mockSendNotification).toHaveBeenCalled(); }); }); // ── Undefined / missing segments (model returned no segments field) ─────────── describe('POST /api/webhook/[jobId] — undefined segments', () => { it('completes the job as done when segments field is absent from whisper payload', async () => { mockGetJob.mockReturnValue(makeJob('job-noseg')); // Simulate whisper returning a result without a segments field const payload = { ...makeWhisperJob(), segments: undefined as unknown as never[] }; const res = await POST(makeEvent('job-noseg', payload) as any); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ status: 'done', id: 'job-noseg' }) ); }); it('does not throw "cannot read properties of undefined" when segments is null', async () => { mockGetJob.mockReturnValue(makeJob('job-nullseg')); const payload = { ...makeWhisperJob(), segments: null as unknown as never[] }; // Must NOT throw — previously crashed with "Cannot read properties of undefined (reading 'map')" await expect(POST(makeEvent('job-nullseg', payload) as any)).resolves.toBeDefined(); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ status: 'done' }) ); }); }); // ── Internal error handling ─────────────────────────────────────────────────── describe('POST /api/webhook/[jobId] — internal errors', () => { it('returns 500 and marks job failed when writeOutputs throws', async () => { mockGetJob.mockReturnValue(makeJob('job-err-2')); mockWriteOutputs.mockRejectedValue(new Error('disk full')); const segments = [makeSeg(0, 'Hello.')]; const res = await POST(makeEvent('job-err-2', makeWhisperJob({ segments })) as any); expect(res.status).toBe(500); expect(mockUpdateJob).toHaveBeenCalledWith( expect.objectContaining({ status: 'failed', error: 'disk full' }) ); }); it('emits an error progress event on internal failure', async () => { mockGetJob.mockReturnValue(makeJob('job-err-3')); mockWriteOutputs.mockRejectedValue(new Error('oops')); const segments = [makeSeg(0, 'Hello.')]; await POST(makeEvent('job-err-3', makeWhisperJob({ segments })) as any); expect(mockEmitProgress).toHaveBeenCalledWith( 'job-err-3', expect.objectContaining({ type: 'error' }) ); }); });