youtubeDownloader/server/test/QueueService.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

213 lines
8.1 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';
import { extractOutputDir, waitFor } from './helpers.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 },
};
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('conserve la miniature fournie à enqueue() dans la vue publique', async () => {
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode, 'Titre', 'https://x/thumb.jpg');
expect(item.thumbnailUrl).toBe('https://x/thumb.jpg');
expect(service.get(item.id)?.thumbnailUrl).toBe('https://x/thumb.jpg');
});
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();
});
});