import type { FastifyBaseLogger } from 'fastify'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runYtDlp: vi.fn() }; }); import { SEARCH_DEPTH, SearchSession, type SearchSessionOptions, } from '../src/services/SearchSession.js'; import { runYtDlp } from '../src/services/ytDlpRunner.js'; import { type FakeYtDlpRun, entryLine, installFakeYtDlp } from './fakeYtDlp.js'; let runs: FakeYtDlpRun[]; let logger: { info: ReturnType; error: ReturnType }; /** * @param overrides - Réglages à surcharger. * @returns Des réglages de session de test (plafond large, longue avance). */ function options(overrides: Partial = {}): SearchSessionOptions { return { query: 'speed climbing', max: 1000, readAhead: 20, lifetimeMs: 60_000, ...overrides }; } /** * @param overrides - Réglages à surcharger. * @returns Une session ouverte avec le logger de test (son yt-dlp simulé est `runs.at(-1)`). */ function open(overrides: Partial = {}): SearchSession { return new SearchSession(logger as unknown as FastifyBaseLogger, options(overrides)); } /** Laisse s'exécuter les micro-tâches et les callbacks en attente. */ const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { vi.mocked(runYtDlp).mockReset(); runs = installFakeYtDlp(vi.mocked(runYtDlp)); logger = { info: vi.fn(), error: vi.fn() }; }); describe('SearchSession', () => { it('lance yt-dlp en mode paresseux, avec la requête (préfixe ytsearch) et la durée de vie demandée', () => { open({ query: 'escalade été', lifetimeMs: 12_345 }); expect(runs).toHaveLength(1); expect(runs[0]?.args).toEqual([ '--flat-playlist', '--lazy-playlist', '-j', '--', `ytsearch${SEARCH_DEPTH}:escalade été`, ]); expect(runs[0]?.options.timeoutMs).toBe(12_345); }); it('attend d’avoir assez de résultats avant de répondre à une page', async () => { const session = open(); let settled = false; const pagePromise = session.page(0, 3).then((page) => { settled = true; return page; }); runs[0]?.emitEntries(2); await flush(); expect(settled).toBe(false); runs[0]?.emitEntries(1); const page = await pagePromise; expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']); expect(page.nextOffset).toBe(3); }); it('sert les pages suivantes depuis le tampon, sans relancer yt-dlp', async () => { const session = open(); const first = session.page(0, 2); runs[0]?.emitEntries(6); await first; const second = await session.page(2, 2); const third = await session.page(4, 2); expect(second.videos.map((video) => video.id)).toEqual(['id3', 'id4']); expect(third.videos.map((video) => video.id)).toEqual(['id5', 'id6']); expect(runs).toHaveLength(1); }); it('ignore les doublons, les lignes illisibles et les entrées incomplètes', async () => { const session = open(); const pagePromise = session.page(0, 10); runs[0]?.emit(entryLine(1)); runs[0]?.emit(entryLine(1)); // doublon : YouTube répète parfois une vidéo runs[0]?.emit('ceci n’est pas du JSON'); runs[0]?.emit(JSON.stringify({ id: 'sans-url', title: 'Incomplète' })); runs[0]?.emit(entryLine(2)); runs[0]?.finish(); const page = await pagePromise; expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2']); }); it('signale la fin des résultats : page partielle et nextOffset null', async () => { const session = open(); const pagePromise = session.page(0, 5); runs[0]?.emitEntries(2); runs[0]?.finish(); const page = await pagePromise; expect(page.videos).toHaveLength(2); expect(page.nextOffset).toBeNull(); }); it('signale la fin par une page vide quand la dernière page tombe pile sur le dernier résultat', async () => { const session = open(); const first = session.page(0, 4); runs[0]?.emitEntries(4); const firstPage = await first; // yt-dlp n'a pas encore signalé sa fin au moment où la page est pleine : // une suite reste possible, la page suivante tranche. expect(firstPage.videos).toHaveLength(4); expect(firstPage.nextOffset).toBe(4); runs[0]?.finish(); const next = await session.page(4, 4); expect(next).toEqual({ videos: [], nextOffset: null }); }); it('renvoie une page vide et la fin quand on demande au-delà des résultats', async () => { const session = open(); const first = session.page(0, 2); runs[0]?.emitEntries(2); runs[0]?.finish(); await first; const beyond = await session.page(50, 10); expect(beyond).toEqual({ videos: [], nextOffset: null }); }); it('s’arrête au plafond : annule yt-dlp, ignore la suite et signale la fin', async () => { const session = open({ max: 3 }); const pagePromise = session.page(0, 10); runs[0]?.emitEntries(5); const page = await pagePromise; expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']); expect(page.nextOffset).toBeNull(); expect(runs[0]?.aborted).toBe(true); expect(logger.error).not.toHaveBeenCalled(); }); it('suspend la lecture quand le tampon a assez d’avance, et la reprend à la demande', async () => { const session = open({ readAhead: 2 }); const first = session.page(0, 2); // demande : 2 résultats runs[0]?.emitEntries(3); await first; expect(runs[0]?.stdout.pause).not.toHaveBeenCalled(); // 3 < 2 + 2 runs[0]?.emitEntries(1); // 4 >= 2 + 2 : assez d'avance expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1); expect(runs[0]?.stdout.resume).not.toHaveBeenCalled(); const next = await session.page(2, 2); // servi depuis le tampon... expect(next.videos.map((video) => video.id)).toEqual(['id3', 'id4']); expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1); // ...et le parcours repart }); it('ne suspend qu’une fois tant qu’il n’a pas été relancé', async () => { const session = open({ readAhead: 1 }); const first = session.page(0, 1); runs[0]?.emitEntries(2); // suspend await first; runs[0]?.emitEntries(3); // encore des lignes déjà dans le tube au moment de la pause expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1); }); it('lève l’erreur quand yt-dlp échoue avant le moindre résultat', async () => { const session = open(); const pagePromise = session.page(0, 5); runs[0]?.fail(new Error('boom')); await expect(pagePromise).rejects.toThrow('boom'); expect(logger.error).toHaveBeenCalled(); }); it('sert ce qu’il a et signale la fin quand yt-dlp échoue après des résultats', async () => { const session = open(); const pagePromise = session.page(0, 10); runs[0]?.emitEntries(2); runs[0]?.fail(new Error('réseau coupé')); const page = await pagePromise; expect(page.videos).toHaveLength(2); expect(page.nextOffset).toBeNull(); expect(logger.error).toHaveBeenCalled(); }); it('dispose : annule yt-dlp sans le compter comme un échec, et libère les appels en attente', async () => { const session = open(); const pending = session.page(0, 5); session.dispose('test'); const page = await pending; await flush(); expect(runs[0]?.aborted).toBe(true); expect(page).toEqual({ videos: [], nextOffset: null }); expect(logger.error).not.toHaveBeenCalled(); }); it('dispose relance un flux suspendu pour qu’il puisse se vider et se fermer', async () => { const session = open({ readAhead: 1 }); const first = session.page(0, 1); runs[0]?.emitEntries(2); // suspend await first; expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1); session.dispose('test'); expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1); }); it('dispose est idempotent', () => { const session = open(); session.dispose('premier'); session.dispose('second'); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ reason: 'premier' }), expect.any(String), ); expect(logger.info).not.toHaveBeenCalledWith( expect.objectContaining({ reason: 'second' }), expect.any(String), ); }); });