import { existsSync } from 'node:fs'; 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'; import { extractOutputDir, waitFor } from './helpers.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 }, }; 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('remove() supprime 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.remove(second.id)).toBe(true); expect(service.get(second.id)).toBeNull(); // 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); expect(runYtDlp).toHaveBeenCalledTimes(1); // jamais appelé pour le second, supprimé avant de démarrer }); it("remove() supprime 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 remove() 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.remove(item.id)).toBe(true); // Retiré immédiatement de la liste, pas seulement une fois l'abort // effectif côté yt-dlp. expect(service.get(item.id)).toBeNull(); }); it('remove() retourne false pour un identifiant inconnu', () => { const service = new QueueService(noopLogger, baseDir, 1); expect(service.remove('inconnu')).toBe(false); }); it('remove() supprime un élément terminé et nettoie le fichier jamais téléchargé', 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'); expect(service.remove(item.id)).toBe(true); expect(service.get(item.id)).toBeNull(); await waitFor(() => !existsSync(join(baseDir, item.id))); }); 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(); // Le fichier a été livré : un second appel ne doit plus rien exposer, // sous peine de tenter de relire un fichier qui n'existe plus. expect(service.get(item.id)?.fileName).toBeNull(); expect(service.consumeCompletedFile(item.id)).toBeNull(); }); });