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>
181 lines
6.1 KiB
TypeScript
181 lines
6.1 KiB
TypeScript
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.
|
||
});
|
||
});
|