- QueueService.remove() remplace cancel() : supprime l'élément de la liste quel que soit son état (annule d'abord s'il était en attente/en cours), au lieu de se limiter aux deux cas actifs et renvoyer false pour un élément déjà terminé. - consumeCompletedFile() met fileName à null une fois le fichier livré (et son dossier nettoyé) : évite qu'un second appel tente de relire un fichier qui n'existe déjà plus. - QueuePanel : un même bouton retire l'élément quel que soit son état - "Annuler" en attente/en cours, "Supprimer" une fois terminé. Bouton "Tout télécharger" dans l'en-tête, qui déclenche avec un léger décalage chaque téléchargement encore disponible (évite le blocage navigateur des téléchargements multiples simultanés). - Liste de résultats de recherche de chaîne agrandie (320px -> 65vh). 11 nouveaux tests côté serveur (63 -> alignés). Vérifié en navigateur : ajout, échec, suppression effective (l'élément disparaît de la liste). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
129 lines
4 KiB
TypeScript
129 lines
4 KiB
TypeScript
import { mkdtemp, rm } 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';
|
||
|
||
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);
|
||
});
|
||
});
|