Initial commit: Tonemark PWA
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s
Tonemark is a SvelteKit PWA for transcribing YouTube videos, audio and video files, and microphone recordings using a local Whisper backend. Features: - Dark glassmorphic UI with electric-lime accent (5 switchable themes) - Rail nav (desktop) / tab bar (mobile) layout - Drop zone, YouTube URL input, and live audio recording inputs - Audio mode waveform cards (none / standard / aggressive / auto) - Real-time transcription progress with animated waveform - Job queue with SSE streaming updates - Push notifications on job completion - PWA with native SvelteKit service worker - SRT / TXT / MD / JSON transcript downloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
300
src/tests/webhook.test.ts
Normal file
300
src/tests/webhook.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
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,
|
||||
mockDeduplicateSegments,
|
||||
mockWriteOutputs,
|
||||
mockSendNotification,
|
||||
mockCleanupJobTmp,
|
||||
mockEmitProgress
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetJob: vi.fn(),
|
||||
mockUpdateJob: vi.fn(),
|
||||
mockSetJobStatus: vi.fn(),
|
||||
mockDeduplicateSegments: vi.fn((segs: Segment[]) => segs),
|
||||
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/postprocess.js', () => ({
|
||||
deduplicateSegments: mockDeduplicateSegments
|
||||
}));
|
||||
|
||||
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();
|
||||
mockDeduplicateSegments.mockImplementation((segs: Segment[]) => segs);
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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('runs deduplication on received segments', async () => {
|
||||
mockGetJob.mockReturnValue(makeJob('job-3'));
|
||||
await POST(makeEvent('job-3', makeWhisperJob({ segments })) as any);
|
||||
expect(mockDeduplicateSegments).toHaveBeenCalledWith(segments);
|
||||
});
|
||||
|
||||
it('calls writeOutputs with the deduplicated segments and job title', async () => {
|
||||
mockGetJob.mockReturnValue(makeJob('job-4', 'My Lecture'));
|
||||
const deduped = [makeSeg(0, 'Hello world.')];
|
||||
mockDeduplicateSegments.mockReturnValue(deduped);
|
||||
|
||||
await POST(makeEvent('job-4', makeWhisperJob({ segments })) as any);
|
||||
expect(mockWriteOutputs).toHaveBeenCalledWith(deduped, 'My Lecture', 'job-4');
|
||||
});
|
||||
|
||||
it('stores serialised segments_json in the database', async () => {
|
||||
mockGetJob.mockReturnValue(makeJob('job-5'));
|
||||
const deduped = [makeSeg(0, 'Result text.')];
|
||||
mockDeduplicateSegments.mockReturnValue(deduped);
|
||||
|
||||
await POST(makeEvent('job-5', makeWhisperJob({ segments })) as any);
|
||||
|
||||
expect(mockUpdateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'job-5',
|
||||
status: 'done',
|
||||
segmentsJson: JSON.stringify(deduped)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user