- 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>
432 lines
16 KiB
TypeScript
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' })
|
|
);
|
|
});
|
|
});
|