youtubeDownloader/server/test/queueRoute.test.ts
Nicolas 7fa28ea4f7 feat: téléchargement groupé en zip
Remplace le "tout télécharger" par téléchargements individuels décalés
(PR précédente) par une unique archive zip, streamée sans être bufferisée
en mémoire (paquet `archiver`).

- server/src/routes/queue.ts : GET /api/queue/download-all, construit le
  zip à la volée depuis les fichiers des éléments `completed`, noms
  dédupliqués en cas de titres identiques. Point d'attention : le flux
  est envoyé à la réponse AVANT archive.finalize(), pas l'inverse -
  finalize() n'aboutit qu'une fois tout le contenu écrit dans le flux, ce
  qui bloquerait indéfiniment sur une grosse archive si rien ne la lit
  encore.
- QueueService : buildDelivery() factorise la logique de livraison
  (chemin + nettoyage post-envoi) entre consumeCompletedFile (un fichier)
  et le nouveau consumeAllCompletedFiles (tous les fichiers prêts).
- QueuePanel : le bouton "Tout télécharger" déclenche maintenant un seul
  lien vers /api/queue/download-all. Tous les liens de téléchargement
  (individuel et groupé) portent target="_blank" : si jamais rien n'est
  disponible côté serveur au moment du clic, l'erreur s'ouvre dans un
  nouvel onglet plutôt que de remplacer l'application.
- test/helpers.ts : extractOutputDir et waitFor factorisés (dupliqués
  entre deux fichiers de test) ; waitFor accepte maintenant un prédicat
  async (la version précédente, sync uniquement, aurait toujours vu un
  prédicat async comme "vrai" immédiatement - une Promise est truthy).

4 nouveaux tests, dont un qui vérifie la signature zip réelle (PK\x03\x04)
sur une archive construite à partir de vrais fichiers temporaires (pas
juste des mocks) - 65 tests serveur au total. Vérifié aussi sur le
serveur réel en cours d'exécution (pas seulement les tests automatisés) :
GET /api/queue/download-all répond bien 404 sur une file vide, bouton
correctement cablé et masqué côté client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:39:18 +02:00

194 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof import('../src/services/ytDlpRunner.js')>();
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' },
});
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/),
});
});
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);
});
});
});