import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import type { FastifyBaseLogger } from 'fastify'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runYtDlp: vi.fn() }; }); import { QueueService } from '../src/services/QueueService.js'; import type { DownloadMode } from '../src/services/downloadPlan.js'; import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js'; const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger; const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; const videoMode: DownloadMode = { kind: 'video', options: { subtitles: false, embedThumbnail: false }, }; /** Extrait le dossier de sortie passé à yt-dlp via `-o` (voir downloadPlan.ts). */ function extractOutputDir(args: readonly string[]): string { const oIndex = args.indexOf('-o'); const template = args[oIndex + 1] ?? ''; const withoutPrefix = template.replace(/^thumbnail:/, ''); return withoutPrefix.slice(0, withoutPrefix.lastIndexOf('/')); } /** Attend qu'une condition devienne vraie, en pollant (pas de timers factices ici : vraie I/O fichier en jeu). */ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { const start = Date.now(); while (!predicate()) { if (Date.now() - start > timeoutMs) { throw new Error('waitFor: timeout'); } await new Promise((resolve) => setTimeout(resolve, 10)); } } let baseDir: string; beforeEach(async () => { baseDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-test-')); vi.mocked(runYtDlp).mockReset(); }); afterEach(async () => { await rm(baseDir, { recursive: true, force: true }); }); describe('QueueService', () => { it('exécute un élément jusqu’à complétion et retrouve le fichier produit', async () => { vi.mocked(runYtDlp).mockImplementation(async (args, options) => { options?.onStdoutLine?.('[download] 50.0% of 10.00MiB'); await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake'); return ''; }); const service = new QueueService(noopLogger, baseDir, 2); const item = service.enqueue(url, videoMode); await waitFor(() => service.get(item.id)?.status === 'completed'); const final = service.get(item.id); expect(final?.fileName).toBe('My Video.mp4'); expect(final?.progress).toBe(100); expect(final?.error).toBeNull(); }); it('respecte la concurrence maximale configurée', async () => { let concurrentCount = 0; let maxObservedConcurrent = 0; const releaseFns: Array<() => void> = []; vi.mocked(runYtDlp).mockImplementation(async (args) => { concurrentCount += 1; maxObservedConcurrent = Math.max(maxObservedConcurrent, concurrentCount); await new Promise((resolve) => releaseFns.push(resolve)); concurrentCount -= 1; await writeFile(join(extractOutputDir(args), 'v.mp4'), 'x'); return ''; }); const service = new QueueService(noopLogger, baseDir, 2); const first = service.enqueue(url, videoMode); const second = service.enqueue(url, videoMode); const third = service.enqueue(url, videoMode); const items = [first, second, third]; await waitFor(() => releaseFns.length === 2); expect(maxObservedConcurrent).toBe(2); expect(service.get(third.id)?.status).toBe('pending'); releaseFns[0]?.(); await waitFor(() => releaseFns.length === 3); releaseFns[1]?.(); releaseFns[2]?.(); await waitFor(() => items.every((item) => service.get(item.id)?.status === 'completed')); expect(maxObservedConcurrent).toBeLessThanOrEqual(2); }); it('marque un élément en échec et nettoie son dossier de sortie quand yt-dlp échoue', async () => { vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network unreachable')); const service = new QueueService(noopLogger, baseDir, 2); const item = service.enqueue(url, videoMode); await waitFor(() => service.get(item.id)?.status === 'failed'); expect(service.get(item.id)?.error).toContain('network unreachable'); await expect(stat(join(baseDir, item.id))).rejects.toThrow(); }); it('annule un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => { vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment const service = new QueueService(noopLogger, baseDir, 1); service.enqueue(url, videoMode); const second = service.enqueue(url, videoMode); expect(service.get(second.id)?.status).toBe('pending'); expect(service.cancel(second.id)).toBe(true); expect(service.get(second.id)?.status).toBe('cancelled'); // Le premier élément appelle yt-dlp de façon asynchrone (après son // `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà // eu lieu au moment où enqueue() rend la main. await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1); }); it("annule un élément en cours en déclenchant son signal d'annulation", async () => { vi.mocked(runYtDlp).mockImplementation( (_args, options) => new Promise((_resolve, reject) => { const rejectAborted = () => { const error = new Error('aborted'); error.name = 'AbortError'; reject(error); }; // Le signal peut déjà être annulé si cancel() a été appelé avant // que ce mock ne soit invoqué (après le `mkdir` de runItem) : un // listener "abort" posé après coup ne se déclencherait jamais. if (options?.signal?.aborted) { rejectAborted(); return; } options?.signal?.addEventListener('abort', rejectAborted); }), ); const service = new QueueService(noopLogger, baseDir, 1); const item = service.enqueue(url, videoMode); await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1); expect(service.cancel(item.id)).toBe(true); await waitFor(() => service.get(item.id)?.status === 'cancelled'); }); it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => { vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); const service = new QueueService(noopLogger, baseDir, 1); const item = service.enqueue(url, videoMode); expect(service.consumeCompletedFile('inconnu')).toBeNull(); expect(service.consumeCompletedFile(item.id)).toBeNull(); // encore en cours }); it('consumeCompletedFile expose le fichier puis onSent nettoie le dossier', async () => { vi.mocked(runYtDlp).mockImplementation(async (args) => { await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake'); return ''; }); const service = new QueueService(noopLogger, baseDir, 1); const item = service.enqueue(url, videoMode); await waitFor(() => service.get(item.id)?.status === 'completed'); const result = service.consumeCompletedFile(item.id); if (!result) { throw new Error('expected consumeCompletedFile to return a result'); } expect(result.fileName).toBe('My Video.mp4'); expect(result.path).toBe(join(baseDir, item.id, 'My Video.mp4')); await result.onSent(); await expect(stat(dirname(result.path))).rejects.toThrow(); }); });