Files
tonemark/src/tests/webhook.test.ts
Giancarmine Salucci 1072679360
All checks were successful
Build & Push Docker Image / test (push) Successful in 12s
Build & Push Docker Image / build-and-push (push) Successful in 52s
fix(whisper): handle model warmup events
- Ignore backend model lifecycle webhooks so model warmup does not
  mark jobs done early
- Parse batched SSE messages and relay model load states during
  submit retries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 00:08:32 +02:00

432 lines
16 KiB
TypeScript

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> = {}): 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' })
);
});
});