youtubeDownloader/server/test/QueueService.test.ts
Nicolas a96322f64e
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
feat: suppression d'éléments de la file, téléchargement groupé
- 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>
2026-09-16 22:50:54 +02:00

221 lines
8.4 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 { existsSync } from 'node:fs';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import type { FastifyBaseLogger } 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() };
});
import { QueueService } from '../src/services/QueueService.js';
import type { DownloadMode } from '../src/services/downloadPlan.js';
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
const videoMode: DownloadMode = {
kind: 'video',
options: { subtitles: false, embedThumbnail: false },
};
/** Extrait le dossier de sortie passé à yt-dlp via `-o` (voir downloadPlan.ts). */
function extractOutputDir(args: readonly string[]): string {
const oIndex = args.indexOf('-o');
const template = args[oIndex + 1] ?? '';
const withoutPrefix = template.replace(/^thumbnail:/, '');
return withoutPrefix.slice(0, withoutPrefix.lastIndexOf('/'));
}
/** Attend qu'une condition devienne vraie, en pollant (pas de timers factices ici : vraie I/O fichier en jeu). */
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('waitFor: timeout');
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
let baseDir: string;
beforeEach(async () => {
baseDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-test-'));
vi.mocked(runYtDlp).mockReset();
});
afterEach(async () => {
await rm(baseDir, { recursive: true, force: true });
});
describe('QueueService', () => {
it('exécute un élément jusqu’à complétion et retrouve le fichier produit', async () => {
vi.mocked(runYtDlp).mockImplementation(async (args, options) => {
options?.onStdoutLine?.('[download] 50.0% of 10.00MiB');
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
return '';
});
const service = new QueueService(noopLogger, baseDir, 2);
const item = service.enqueue(url, videoMode);
await waitFor(() => service.get(item.id)?.status === 'completed');
const final = service.get(item.id);
expect(final?.fileName).toBe('My Video.mp4');
expect(final?.progress).toBe(100);
expect(final?.error).toBeNull();
});
it('respecte la concurrence maximale configurée', async () => {
let concurrentCount = 0;
let maxObservedConcurrent = 0;
const releaseFns: Array<() => void> = [];
vi.mocked(runYtDlp).mockImplementation(async (args) => {
concurrentCount += 1;
maxObservedConcurrent = Math.max(maxObservedConcurrent, concurrentCount);
await new Promise<void>((resolve) => releaseFns.push(resolve));
concurrentCount -= 1;
await writeFile(join(extractOutputDir(args), 'v.mp4'), 'x');
return '';
});
const service = new QueueService(noopLogger, baseDir, 2);
const first = service.enqueue(url, videoMode);
const second = service.enqueue(url, videoMode);
const third = service.enqueue(url, videoMode);
const items = [first, second, third];
await waitFor(() => releaseFns.length === 2);
expect(maxObservedConcurrent).toBe(2);
expect(service.get(third.id)?.status).toBe('pending');
releaseFns[0]?.();
await waitFor(() => releaseFns.length === 3);
releaseFns[1]?.();
releaseFns[2]?.();
await waitFor(() => items.every((item) => service.get(item.id)?.status === 'completed'));
expect(maxObservedConcurrent).toBeLessThanOrEqual(2);
});
it('marque un élément en échec et nettoie son dossier de sortie quand yt-dlp échoue', async () => {
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network unreachable'));
const service = new QueueService(noopLogger, baseDir, 2);
const item = service.enqueue(url, videoMode);
await waitFor(() => service.get(item.id)?.status === 'failed');
expect(service.get(item.id)?.error).toContain('network unreachable');
await expect(stat(join(baseDir, item.id))).rejects.toThrow();
});
it('remove() supprime un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => {
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment
const service = new QueueService(noopLogger, baseDir, 1);
service.enqueue(url, videoMode);
const second = service.enqueue(url, videoMode);
expect(service.get(second.id)?.status).toBe('pending');
expect(service.remove(second.id)).toBe(true);
expect(service.get(second.id)).toBeNull();
// Le premier élément appelle yt-dlp de façon asynchrone (après son
// `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà
// eu lieu au moment où enqueue() rend la main.
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
expect(runYtDlp).toHaveBeenCalledTimes(1); // jamais appelé pour le second, supprimé avant de démarrer
});
it("remove() supprime un élément en cours en déclenchant son signal d'annulation", async () => {
vi.mocked(runYtDlp).mockImplementation(
(_args, options) =>
new Promise((_resolve, reject) => {
const rejectAborted = () => {
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
};
// Le signal peut déjà être annulé si remove() a été appelé avant
// que ce mock ne soit invoqué (après le `mkdir` de runItem) : un
// listener "abort" posé après coup ne se déclencherait jamais.
if (options?.signal?.aborted) {
rejectAborted();
return;
}
options?.signal?.addEventListener('abort', rejectAborted);
}),
);
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode);
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
expect(service.remove(item.id)).toBe(true);
// Retiré immédiatement de la liste, pas seulement une fois l'abort
// effectif côté yt-dlp.
expect(service.get(item.id)).toBeNull();
});
it('remove() retourne false pour un identifiant inconnu', () => {
const service = new QueueService(noopLogger, baseDir, 1);
expect(service.remove('inconnu')).toBe(false);
});
it('remove() supprime un élément terminé et nettoie le fichier jamais téléchargé', async () => {
vi.mocked(runYtDlp).mockImplementation(async (args) => {
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
return '';
});
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode);
await waitFor(() => service.get(item.id)?.status === 'completed');
expect(service.remove(item.id)).toBe(true);
expect(service.get(item.id)).toBeNull();
await waitFor(() => !existsSync(join(baseDir, item.id)));
});
it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => {
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode);
expect(service.consumeCompletedFile('inconnu')).toBeNull();
expect(service.consumeCompletedFile(item.id)).toBeNull(); // encore en cours
});
it('consumeCompletedFile expose le fichier puis onSent nettoie le dossier', async () => {
vi.mocked(runYtDlp).mockImplementation(async (args) => {
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
return '';
});
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode);
await waitFor(() => service.get(item.id)?.status === 'completed');
const result = service.consumeCompletedFile(item.id);
if (!result) {
throw new Error('expected consumeCompletedFile to return a result');
}
expect(result.fileName).toBe('My Video.mp4');
expect(result.path).toBe(join(baseDir, item.id, 'My Video.mp4'));
await result.onSent();
await expect(stat(dirname(result.path))).rejects.toThrow();
// Le fichier a été livré : un second appel ne doit plus rien exposer,
// sous peine de tenter de relire un fichier qui n'existe plus.
expect(service.get(item.id)?.fileName).toBeNull();
expect(service.consumeCompletedFile(item.id)).toBeNull();
});
});