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

189 lines
6.4 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 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 { SearchService, type SearchServiceOptions } from '../src/services/SearchService.js';
import { InvalidSearchQueryError } from '../src/services/searchQuery.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
import { type FakeYtDlpRun, installFakeYtDlp } from './fakeYtDlp.js';
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
let runs: FakeYtDlpRun[];
/**
* @param overrides - Réglages à surcharger.
* @returns Un service de test.
*/
function createService(overrides: Partial<SearchServiceOptions> = {}): SearchService {
return new SearchService(noopLogger, overrides);
}
/**
* Ouvre une recherche dont le yt-dlp simulé produit `entries` résultats.
*
* @param service - Service concerné.
* @param entries - Nombre de résultats à faire produire au yt-dlp simulé.
* @param limit - Taille de la première page.
* @returns L'identifiant de la recherche, sa première page, et son yt-dlp simulé.
*/
async function openSearch(
service: SearchService,
entries: number,
limit = 5,
): Promise<{ id: string; run: FakeYtDlpRun; nextOffset: number | null }> {
const started = service.start('speed climbing', 1000, limit);
const run = runs.at(-1);
if (!run) {
throw new Error('yt-dlp simulé non lancé');
}
run.emitEntries(entries);
const { id, page } = await started;
return { id, run, nextOffset: page.nextOffset };
}
beforeEach(() => {
vi.mocked(runYtDlp).mockReset();
runs = installFakeYtDlp(vi.mocked(runYtDlp));
});
afterEach(() => {
vi.useRealTimers();
});
describe('SearchService', () => {
it('ouvre une recherche et retourne son identifiant et sa première page', async () => {
const service = createService();
const { id, run, nextOffset } = await openSearch(service, 20, 5);
expect(id).toEqual(expect.any(String));
expect(nextOffset).toBe(5);
expect(run.args.at(-1)).toBe('ytsearch1000:speed climbing');
service.closeAll();
});
it('sert les pages suivantes d’une recherche ouverte', async () => {
const service = createService();
const { id } = await openSearch(service, 20, 5);
const page = await service.page(id, 5, 5);
expect(page?.videos.map((video) => video.id)).toEqual(['id6', 'id7', 'id8', 'id9', 'id10']);
expect(page?.nextOffset).toBe(10);
service.closeAll();
});
it('retourne null pour une recherche inconnue', async () => {
const service = createService();
await expect(service.page('inconnue', 0, 5)).resolves.toBeNull();
});
it('rejette une requête invalide sans jamais lancer yt-dlp', async () => {
const service = createService();
await expect(service.start(' ', 100, 5)).rejects.toBeInstanceOf(InvalidSearchQueryError);
expect(runs).toHaveLength(0);
});
it('ferme la recherche dès que sa dernière page a été servie', async () => {
const service = createService();
const started = service.start('speed climbing', 1000, 5);
runs[0]?.emitEntries(3);
runs[0]?.finish(); // fin des résultats : la première page est aussi la dernière
const { id, page } = await started;
expect(page.nextOffset).toBeNull();
await expect(service.page(id, 0, 5)).resolves.toBeNull();
});
it('dispose ferme la recherche (yt-dlp annulé) et n’existe plus ensuite', async () => {
const service = createService();
const { id, run } = await openSearch(service, 20, 5);
expect(service.dispose(id)).toBe(true);
expect(run.aborted).toBe(true);
expect(service.dispose(id)).toBe(false);
await expect(service.page(id, 5, 5)).resolves.toBeNull();
});
it('ferme la recherche et relance l’erreur quand la première page échoue', async () => {
const service = createService();
const started = service.start('speed climbing', 1000, 5);
runs[0]?.fail(new Error('boom'));
await expect(started).rejects.toThrow('boom');
expect(runs[0]?.aborted).toBe(true);
});
it('ferme la moins récemment utilisée quand la limite de recherches simultanées est atteinte', async () => {
const service = createService({ maxSessions: 2 });
const first = await openSearch(service, 20);
const second = await openSearch(service, 20);
// La première est la moins récemment utilisée : consulter la seconde le confirme.
await service.page(second.id, 5, 5);
const third = await openSearch(service, 20);
expect(first.run.aborted).toBe(true);
expect(second.run.aborted).toBe(false);
expect(third.run.aborted).toBe(false);
await expect(service.page(first.id, 5, 5)).resolves.toBeNull();
service.closeAll();
});
it('ferme les recherches inactives, et arrête son balayage quand il n’en reste plus', async () => {
vi.useFakeTimers();
const service = createService({ idleTtlMs: 1000, sweepIntervalMs: 500 });
const { run } = await openSearch(service, 20);
expect(vi.getTimerCount()).toBe(1); // le balayage tourne
await vi.advanceTimersByTimeAsync(2000);
expect(run.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0); // plus rien à balayer
});
it('ne ferme pas une recherche encore active', async () => {
vi.useFakeTimers();
const service = createService({ idleTtlMs: 1000, sweepIntervalMs: 500 });
const { id, run } = await openSearch(service, 40, 5);
for (let offset = 5; offset <= 25; offset += 5) {
await vi.advanceTimersByTimeAsync(800); // toujours sous le délai d'inactivité
await service.page(id, offset, 5);
}
expect(run.aborted).toBe(false);
service.closeAll();
});
it('ferme une recherche qui dépasse sa durée de vie maximale, même active', async () => {
vi.useFakeTimers();
const service = createService({ idleTtlMs: 10_000, maxLifetimeMs: 2000, sweepIntervalMs: 500 });
const { id, run } = await openSearch(service, 40, 5);
await vi.advanceTimersByTimeAsync(1000);
await service.page(id, 5, 5); // activité récente...
await vi.advanceTimersByTimeAsync(1500); // ...mais durée de vie dépassée
expect(run.aborted).toBe(true);
});
it('closeAll ferme toutes les recherches', async () => {
const service = createService();
const first = await openSearch(service, 20);
const second = await openSearch(service, 20);
service.closeAll();
expect(first.run.aborted).toBe(true);
expect(second.run.aborted).toBe(true);
});
});