Files
tonemark/src/tests/downloader.test.ts
T
Giancarmine Salucci f03b757174 D2: Widen retry to upload-source jobs
- Remove http-only guard in retry endpoint (src/routes/api/jobs/[id]/retry/+server.ts)
- Extend pipeline.ts retryJob() to handle upload-source jobs:
  read file from persistent storage, pass buffer to runJob
- Change saveUploadedFile() to save to DATA_DIR/uploads/{jobId}/
  (survives cleanupJobTmp, available for retry on failure)
- Add getUploadPath(), cleanupUploadDir() to downloader.ts
- Remove cleanupFiles(rawAudioPath) from catch block in runJob()
  — cleanupJobTmp handles TMP_DIR for YouTube, uploads survive
- Add cleanupUploadDir() on webhook success path
- Add retry-endpoint.test.ts with 9 tests covering:
  - 404 for unknown job
  - 409 for non-retryable status (done, pending)
  - 200 for cancelled (retryable)
  - 200 for failed YouTube job (existing behavior)
  - 200 for failed upload-source job (new behavior, .webm and .mp3)
  - Negative: retryJob not called on 404/409
- Add downloader.test.ts tests for saveUploadedFile persistent path,
  getUploadPath, cleanupUploadDir, isolation, and non-existent cleanup
- Fix webhook.test.ts mock to include cleanupUploadDir

All 189 tests pass across 12 test files.
2026-07-08 01:25:49 +02:00

136 lines
4.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { rm } from 'fs/promises';
import type { TranscriptResponse } from 'youtube-transcript';
const { mockExecFile, mockFetchTranscript } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
mockFetchTranscript: vi.fn()
}));
const TEST_DATA_DIR = `/tmp/tonemark-downloader-test-${Date.now()}`;
vi.stubEnv('DATA_DIR', TEST_DATA_DIR);
vi.mock('child_process', () => ({
execFile: mockExecFile
}));
vi.mock('youtube-transcript', () => ({
fetchTranscript: mockFetchTranscript
}));
import { downloadYouTube, transcriptEntriesToSegments, saveUploadedFile, getUploadPath, cleanupUploadDir } from '$lib/server/downloader.js';
beforeEach(() => {
vi.clearAllMocks();
mockExecFile.mockImplementation((...args: unknown[]) => {
const cb = args.at(-1) as (...callbackArgs: unknown[]) => void;
cb(null, JSON.stringify({ title: 'Fetched Title' }), '');
});
});
afterEach(async () => {
await rm(TEST_DATA_DIR, { recursive: true, force: true }).catch(() => {});
});
describe('transcriptEntriesToSegments', () => {
it('converts millisecond transcript offsets into second-based segments', () => {
const entries: TranscriptResponse[] = [
{ text: 'Hello everyone.', offset: 15240, duration: 4240, lang: 'en' },
{ text: 'Um, welcome to this talk.', offset: 16600, duration: 5080, lang: 'en' }
];
expect(transcriptEntriesToSegments(entries)).toEqual([
{ index: 0, start: 15.24, end: 19.48, text: 'Hello everyone.', words: [] },
{ index: 1, start: 16.6, end: 21.68, text: 'Um, welcome to this talk.', words: [] }
]);
});
it('preserves second-based transcript offsets and drops empty text', () => {
const entries: TranscriptResponse[] = [
{ text: ' ', offset: 0, duration: 1.5, lang: 'en' },
{ text: 'Clean caption cue', offset: 91.08, duration: 3.72, lang: 'en' }
];
expect(transcriptEntriesToSegments(entries)).toEqual([
{ index: 0, start: 91.08, end: 94.8, text: 'Clean caption cue', words: [] }
]);
});
});
describe('saveUploadedFile', () => {
it('saves file to persistent DATA_DIR/uploads/{jobId}/ location', async () => {
const buffer = Buffer.from('fake audio content');
const filePath = await saveUploadedFile(buffer, 'test-recording.webm', 'upload-test-1');
// Must be under DATA_DIR/uploads/, NOT under TMP_DIR/downloads/
expect(filePath).toContain('/uploads/upload-test-1/test-recording.webm');
expect(filePath).not.toContain('/downloads/');
// File must exist on disk
const { accessSync } = await import('fs');
expect(() => accessSync(filePath)).not.toThrow();
// Content must match
const { readFileSync } = await import('fs');
expect(readFileSync(filePath)).toEqual(buffer);
// cleanup
await cleanupUploadDir('upload-test-1');
});
it('getUploadPath returns correct path without creating file', async () => {
const p = getUploadPath('some-job', 'audio.mp3');
expect(p).toContain('/uploads/some-job/audio.mp3');
expect(p).not.toContain('/downloads/');
});
it('cleanupUploadDir removes the job upload directory', async () => {
const buffer = Buffer.from('data');
await saveUploadedFile(buffer, 'temp-file.txt', 'cleanup-test');
const p = getUploadPath('cleanup-test', 'temp-file.txt');
const { accessSync } = await import('fs');
expect(() => accessSync(p)).not.toThrow();
await cleanupUploadDir('cleanup-test');
expect(() => accessSync(p)).toThrow();
});
it('cleanupUploadDir does not throw for non-existent dir', async () => {
await expect(cleanupUploadDir('non-existent-job')).resolves.toBeUndefined();
});
it('each job gets an isolated upload directory', async () => {
const p1 = await saveUploadedFile(Buffer.from('a'), 'a.wav', 'job-iso-1');
const p2 = await saveUploadedFile(Buffer.from('b'), 'b.wav', 'job-iso-2');
expect(p1).toContain('/uploads/job-iso-1/a.wav');
expect(p2).toContain('/uploads/job-iso-2/b.wav');
await cleanupUploadDir('job-iso-1');
await cleanupUploadDir('job-iso-2');
});
});
describe('downloadYouTube', () => {
it('uses fetched transcript entries directly for caption jobs', async () => {
mockFetchTranscript.mockResolvedValue([
{ text: 'Hello everyone.', offset: 15240, duration: 4240, lang: 'en' },
{ text: 'Um, welcome to this talk.', offset: 16600, duration: 5080, lang: 'en' }
] satisfies TranscriptResponse[]);
const result = await downloadYouTube('https://youtube.com/watch?v=qdh_x-uRs9g', 'job-1');
expect(mockFetchTranscript).toHaveBeenCalledWith('https://youtube.com/watch?v=qdh_x-uRs9g', {
lang: 'en'
});
expect(result).toMatchObject({
type: 'captions',
segments: [
{ index: 0, start: 15.24, end: 19.48, text: 'Hello everyone.', words: [] },
{ index: 1, start: 16.6, end: 21.68, text: 'Um, welcome to this talk.', words: [] }
]
});
});
});