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>
194 lines
6.6 KiB
TypeScript
194 lines
6.6 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|