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