import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Fastify 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(() => new Promise(() => {})) }; }); import { createQueueRoutes } from '../src/routes/queue.js'; import { runYtDlp } from '../src/services/ytDlpRunner.js'; import { extractOutputDir, waitFor } from './helpers.js'; const validUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-route-test-')); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); describe('routes de file d’attente', () => { it('POST /api/queue ajoute un élément valide et le retourne', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); const response = await app.inject({ method: 'POST', url: '/api/queue', payload: { url: validUrl, mode: 'audio', audioFormat: 'opus', thumbnailUrl: 'https://x/thumb.jpg', }, }); expect(response.statusCode).toBe(201); const body = response.json(); expect(body.item).toMatchObject({ url: validUrl, mode: { kind: 'audio', format: 'opus' }, status: expect.stringMatching(/pending|running/), thumbnailUrl: 'https://x/thumb.jpg', }); }); it('POST /api/queue renvoie 400 pour une URL non-YouTube', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); const response = await app.inject({ method: 'POST', url: '/api/queue', payload: { url: 'https://evil.com/x', mode: 'video' }, }); expect(response.statusCode).toBe(400); }); it('POST /api/queue renvoie 400 pour un mode invalide (validation de schéma)', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); const response = await app.inject({ method: 'POST', url: '/api/queue', payload: { url: validUrl, mode: 'video-4k-ultra' }, }); expect(response.statusCode).toBe(400); }); it('GET /api/queue liste les éléments ajoutés', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); await app.inject({ method: 'POST', url: '/api/queue', payload: { url: validUrl, mode: 'thumbnail' }, }); const response = await app.inject({ method: 'GET', url: '/api/queue' }); expect(response.statusCode).toBe(200); expect(response.json().items).toHaveLength(1); }); it('DELETE /api/queue/:id renvoie 404 pour un identifiant inconnu', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); const response = await app.inject({ method: 'DELETE', url: '/api/queue/inconnu' }); expect(response.statusCode).toBe(404); }); it('DELETE /api/queue/:id supprime un élément et le retire de la liste', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); const created = await app.inject({ method: 'POST', url: '/api/queue', payload: { url: validUrl, mode: 'video' }, }); const { id } = created.json().item; const deleteResponse = await app.inject({ method: 'DELETE', url: `/api/queue/${id}` }); expect(deleteResponse.statusCode).toBe(204); const listResponse = await app.inject({ method: 'GET', url: '/api/queue' }); expect(listResponse.json().items).toHaveLength(0); }); it('GET /api/queue/:id/file renvoie 404 tant que le téléchargement n’est pas terminé', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); const created = await app.inject({ method: 'POST', url: '/api/queue', payload: { url: validUrl, mode: 'video' }, }); const { id } = created.json().item; const response = await app.inject({ method: 'GET', url: `/api/queue/${id}/file` }); expect(response.statusCode).toBe(404); }); describe('GET /api/queue/download-all', () => { it('renvoie 404 quand aucun fichier n’est disponible', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); const response = await app.inject({ method: 'GET', url: '/api/queue/download-all' }); expect(response.statusCode).toBe(404); }); it('renvoie un zip contenant les fichiers terminés, puis les nettoie', async () => { // Simule deux téléchargements terminés en écrivant directement le // fichier attendu dans le dossier de sortie fourni à yt-dlp. vi.mocked(runYtDlp).mockImplementationOnce(async (args) => { await writeFile(join(extractOutputDir(args), 'Video A.mp4'), 'contenu A'); return ''; }); vi.mocked(runYtDlp).mockImplementationOnce(async (args) => { await writeFile(join(extractOutputDir(args), 'Video B.mp4'), 'contenu B'); return ''; }); const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); await app.inject({ method: 'POST', url: '/api/queue', payload: { url: validUrl, mode: 'video' }, }); await app.inject({ method: 'POST', url: '/api/queue', payload: { url: validUrl, mode: 'video' }, }); await waitFor(async () => { const list = await app.inject({ method: 'GET', url: '/api/queue' }); const items: Array<{ status: string }> = list.json().items; return items.length === 2 && items.every((item) => item.status === 'completed'); }); const response = await app.inject({ method: 'GET', url: '/api/queue/download-all' }); expect(response.statusCode).toBe(200); expect(response.headers['content-type']).toBe('application/zip'); expect(response.headers['content-disposition']).toContain('youtubedownloader.zip'); // Signature ZIP standard (PK\x03\x04) en tête du fichier. expect(response.rawPayload.subarray(0, 4)).toEqual(Buffer.from([0x50, 0x4b, 0x03, 0x04])); // Les deux éléments ont bien été livrés : plus de fichier disponible // (et un second appel ne renverrait donc plus rien). await waitFor(async () => { const after = await app.inject({ method: 'GET', url: '/api/queue' }); const items: Array<{ fileName: string | null }> = after.json().items; return items.every((item) => item.fileName === null); }); const secondAttempt = await app.inject({ method: 'GET', url: '/api/queue/download-all' }); expect(secondAttempt.statusCode).toBe(404); }); }); });