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(); 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 = {}): 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); }); });