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