youtubeDownloader/server/test/searchRoute.test.ts
Nicolas 374cfbb192 feat: recherche YouTube globale, avec defilement infini
Nouveau formulaire "Recherche YouTube" (mots-cles + limite optionnelle),
branche sur la zone de resultats existante (miniatures, bouton "Ajouter"
par ligne ; pas de "Tout ajouter", comme demande).

- Avec une limite : tous les resultats d'un coup (une seule page).
- Sans limite : defilement infini. Un element sentinelle en fin de liste,
  observe par un IntersectionObserver (racine = la liste, marge 300 px),
  charge la page suivante ; il est rearme apres chaque page, affiche
  chargement / erreur avec "Reessayer" / "Fin des resultats", et une page
  en echec ne fait pas perdre les precedentes.

Serveur (GET/POST/DELETE /api/search) : une "session" = un seul yt-dlp
(--flat-playlist --lazy-playlist -j -- ytsearch1000:<requete>) servi page
par page depuis un tampon. Pourquoi pas un appel yt-dlp par page - mesure
avec le vrai yt-dlp : l'ordre des resultats de YouTube n'est pas
deterministe meme pour deux appels identiques (5/20 aux memes positions,
69 % de recouvrement sur 100), donc paginer en relancant la recherche
donnait 15 a 30 % de doublons des la 3e-4e page et des trous ; YouTube
s'arrete vers ~590 resultats ; ~7 % de doublons internes (dedoublonnes a
l'ingestion) ; sans --lazy-playlist yt-dlp n'imprime rien avant la fin
(~23 s), avec il emet au fil des pages (20 en ~2,3 s).

Le parcours n'avance qu'a la demande : quand le tampon a 20 resultats
d'avance, la lecture de stdout est suspendue (nouvelle option onSpawn de
runYtDlp) ; le tube se remplit et yt-dlp bloque (mesure : plus aucune
ligne pendant 10 s de pause). On n'aspire donc pas ~590 resultats, et ~30
requetes vers YouTube, quand l'utilisateur ne defile pas.

Cycle de vie : 4 sessions simultanees max (la moins recemment utilisee est
fermee), expiration a 10 min d'inactivite / 30 min de vie, fermeture des
que la derniere page est servie, sur DELETE (liste remplacee cote client),
au pagehide du client, et a l'arret du serveur. Requete validee (1 a 200
caracteres, sans caractere de controle) et transmise en un seul argument
ytsearchN:<requete>, jamais interpretable comme une option.

Mise en page : ce troisieme formulaire laissait a la liste ~104 px (une
ligne) a 1366x650. Une liste non vide garde min-height 13rem et la colonne
du formulaire defile au lieu de rogner (la page ne defile toujours pas).

Verifie : contre le vrai yt-dlp (page tamponnee en 4 ms, page 200 apres
12 s d'inactivite en ~4 s : la suspension tient ; 29 pages -> 576 videos,
576 uniques ; processus fermes a la fin / sur DELETE / au plafond) et dans
le navigateur (580 resultats sans doublon jusqu'a "Fin des resultats",
requetes de page uniques malgre deux signaux simultanes, echec + reprise,
session expiree, remplacement de la recherche, mode limite, ajout d'un
resultat a la file, pagehide, playlist et chaine en non-regression). Le
volet IntersectionObserver a ete pilote par un observateur factice : le
panneau de navigation n'affichait plus de frames (rAF/IO natifs muets).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:43:46 +02:00

181 lines
6.1 KiB
TypeScript
Raw Permalink 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 Fastify, { type FastifyInstance } 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 { searchRoutes } from '../src/routes/search.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
import { type FakeYtDlpRun, installFakeYtDlp } from './fakeYtDlp.js';
import { waitFor } from './helpers.js';
let runs: FakeYtDlpRun[];
let app: FastifyInstance;
/**
* Ouvre une recherche via l'API, en faisant produire `entries` résultats au yt-dlp simulé.
*
* @param entries - Nombre de résultats à produire.
* @param body - Corps de la requête `POST /api/search`.
* @returns La réponse de l'API et le yt-dlp simulé de la recherche.
*/
async function openSearch(entries: number, body: Record<string, unknown> = { query: 'test' }) {
const responsePromise = app.inject({ method: 'POST', url: '/api/search', payload: body });
await waitFor(() => runs.length > 0);
const run = runs.at(-1) as FakeYtDlpRun;
run.emitEntries(entries);
return { response: await responsePromise, run };
}
beforeEach(async () => {
vi.mocked(runYtDlp).mockReset();
runs = installFakeYtDlp(vi.mocked(runYtDlp));
app = Fastify();
await app.register(searchRoutes);
});
afterEach(async () => {
await app.close();
});
describe('POST /api/search', () => {
it('ouvre une recherche et retourne son identifiant et sa première page', async () => {
const { response } = await openSearch(30, { query: 'speed climbing', limit: 5 });
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.id).toEqual(expect.any(String));
expect(body.videos).toHaveLength(5);
expect(body.videos[0]).toEqual({
id: 'id1',
title: 'Vidéo 1',
url: 'https://www.youtube.com/watch?v=id1',
thumbnailUrl: 'https://i.ytimg.com/vi/id1/hq.jpg',
durationSeconds: 60,
uploadDate: null,
});
expect(body.nextOffset).toBe(5);
});
it('utilise une page de 20 par défaut', async () => {
const { response } = await openSearch(50);
expect(response.json().videos).toHaveLength(20);
});
it('avec un plafond (max), retourne tout d’un coup et signale la fin', async () => {
const { response, run } = await openSearch(50, { query: 'test', max: 12, limit: 12 });
const body = response.json();
expect(body.videos).toHaveLength(12);
expect(body.nextOffset).toBeNull();
expect(run.aborted).toBe(true);
});
it.each([
['une requête d’espaces', { query: ' ' }],
['une requête vide', { query: '' }],
['une requête trop longue', { query: 'a'.repeat(201) }],
['une requête absente', {}],
['une limite hors bornes', { query: 'x', limit: 5000 }],
['un plafond nul', { query: 'x', max: 0 }],
['un plafond non entier', { query: 'x', max: 1.5 }],
])('renvoie 400 pour %s, sans lancer yt-dlp', async (_label, body) => {
const response = await app.inject({ method: 'POST', url: '/api/search', payload: body });
expect(response.statusCode).toBe(400);
expect(runs).toHaveLength(0);
});
it('renvoie 502 (pas 500) quand yt-dlp échoue avant le moindre résultat', async () => {
const responsePromise = app.inject({
method: 'POST',
url: '/api/search',
payload: { query: 'test' },
});
await waitFor(() => runs.length > 0);
runs[0]?.fail(new Error('spawn yt-dlp ENOENT'));
const response = await responsePromise;
expect(response.statusCode).toBe(502);
});
});
describe('GET /api/search/:id', () => {
it('retourne les pages suivantes', async () => {
const { response } = await openSearch(60, { query: 'test', limit: 10 });
const { id } = response.json();
const second = await app.inject({ method: 'GET', url: `/api/search/${id}?offset=10&limit=10` });
expect(second.statusCode).toBe(200);
expect(second.json().videos.map((video: { id: string }) => video.id)).toEqual(
Array.from({ length: 10 }, (_, i) => `id${i + 11}`),
);
expect(second.json().nextOffset).toBe(20);
});
it('renvoie 404 pour une recherche inconnue', async () => {
const response = await app.inject({ method: 'GET', url: '/api/search/inconnue?offset=0' });
expect(response.statusCode).toBe(404);
});
it.each([
['un offset négatif', 'offset=-1'],
['un offset non numérique', 'offset=abc'],
['une limite nulle', 'limit=0'],
['une limite hors bornes', 'limit=5000'],
])('renvoie 400 pour %s', async (_label, query) => {
const response = await app.inject({ method: 'GET', url: `/api/search/quelconque?${query}` });
expect(response.statusCode).toBe(400);
});
it('renvoie 404 une fois la dernière page servie (la recherche est fermée)', async () => {
const responsePromise = app.inject({
method: 'POST',
url: '/api/search',
payload: { query: 'test', limit: 10 },
});
await waitFor(() => runs.length > 0);
runs[0]?.emitEntries(3);
runs[0]?.finish();
const { id, nextOffset } = (await responsePromise).json();
expect(nextOffset).toBeNull();
const response = await app.inject({ method: 'GET', url: `/api/search/${id}?offset=0` });
expect(response.statusCode).toBe(404);
});
});
describe('DELETE /api/search/:id', () => {
it('ferme la recherche (yt-dlp annulé), puis renvoie 404', async () => {
const { response, run } = await openSearch(30, { query: 'test', limit: 5 });
const { id } = response.json();
const deleted = await app.inject({ method: 'DELETE', url: `/api/search/${id}` });
expect(deleted.statusCode).toBe(204);
expect(run.aborted).toBe(true);
const again = await app.inject({ method: 'DELETE', url: `/api/search/${id}` });
expect(again.statusCode).toBe(404);
const page = await app.inject({ method: 'GET', url: `/api/search/${id}?offset=5` });
expect(page.statusCode).toBe(404);
});
});
describe('arrêt du serveur', () => {
it('ferme les recherches ouvertes (aucun yt-dlp ne survit à l’arrêt)', async () => {
const { run } = await openSearch(30, { query: 'test', limit: 5 });
expect(run.aborted).toBe(false);
await app.close();
expect(run.aborted).toBe(true);
// afterEach rappelle app.close() : il doit rester sans effet.
});
});