youtubeDownloader/server/test/queueRoute.test.ts
Nicolas a8cd7ae907
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
fix: miniatures manquantes (resultats de recherche + file d'attente)
Deux causes distinctes au meme symptome (aucune ligne de liste n'affichait
de miniature) :

- ChannelDiscoveryService : en --flat-playlist, l'extracteur YouTube de
  yt-dlp ne renseigne quasiment jamais le champ singulier `thumbnail`,
  seulement `thumbnails` (tableau, trie par resolution croissante). Le
  parsing ne lisait que `thumbnail` -> thumbnailUrl toujours null. Passe
  inapercu en test car le mock ne reproduisait que la forme theorique,
  jamais la forme reelle observee en prod. extractThumbnailUrl() lit
  desormais aussi `thumbnails`, en repli.
- QueuePanel (file d'attente) : aucun champ thumbnailUrl n'existait sur
  QueueItem/QueueItemDto - fonctionnalite jamais implementee, pas de
  regression. Ajoutee de bout en bout : POST /api/queue accepte
  thumbnailUrl (purement informatif, jamais transmis a yt-dlp),
  QueueService la conserve et l'expose, DownloadForm la transmet depuis
  les resultats de recherche, QueuePanel l'affiche (miniature 96x54 a
  gauche de chaque ligne).

Verifie en navigateur reel (pas seulement les tests) : element ajoute via
POST direct avec thumbnailUrl, image reellement chargee (naturalWidth 480)
et positionnee dans le DOM rendu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:43:25 +02:00

200 lines
6.7 KiB
TypeScript
Raw 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',
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);
});
});
});