import type { FastifyBaseLogger } from 'fastify'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../src/services/ytDlpRunner.js', () => ({ runYtDlp: vi.fn(), })); import { PlaylistDiscoveryService } from '../src/services/PlaylistDiscoveryService.js'; import { InvalidPlaylistUrlError } from '../src/services/playlistUrl.js'; import { runYtDlp } from '../src/services/ytDlpRunner.js'; const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger; const playlistUrl = 'https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U'; // Forme réelle de `yt-dlp --flat-playlist -J` sur une playlist (relevée avec // yt-dlp 2026.07.04) : un objet racine avec `title`, et des entrées `url` // portant `thumbnails` (tableau, jamais `thumbnail`) et pas de `upload_date`. const realShapeOutput = JSON.stringify({ _type: 'playlist', id: 'PLKDZ1ig0uz-U', title: 'Deep clean dreams', playlist_count: 3, entries: [ { _type: 'url', ie_key: 'Youtube', id: '9rqQgOERpDo', url: 'https://www.youtube.com/watch?v=9rqQgOERpDo', title: 'Restoring an ABANDONED 1963 Split Window Corvette!', duration: 1917, thumbnails: [ { url: 'https://i.ytimg.com/vi/9rqQgOERpDo/small.jpg', width: 168, height: 94 }, { url: 'https://i.ytimg.com/vi/9rqQgOERpDo/large.jpg', width: 336, height: 188 }, ], }, { _type: 'url', id: 'aaaaaaaaaaa', url: 'https://www.youtube.com/watch?v=aaaaaaaaaaa', title: 'Deuxième vidéo', duration: 60, thumbnails: [{ url: 'https://i.ytimg.com/vi/aaaaaaaaaaa/large.jpg' }], }, { _type: 'url', id: 'bbbbbbbbbbb', title: 'Entrée sans URL (indisponible)' }, ], }); beforeEach(() => { vi.mocked(runYtDlp).mockReset(); }); describe('PlaylistDiscoveryService', () => { it('parse la sortie réelle de yt-dlp : titre, vidéos dans l’ordre, entrées inexploitables ignorées', async () => { vi.mocked(runYtDlp).mockResolvedValue(realShapeOutput); const service = new PlaylistDiscoveryService(noopLogger); const listing = await service.listPlaylist(playlistUrl); expect(listing).toEqual({ title: 'Deep clean dreams', videos: [ { id: '9rqQgOERpDo', title: 'Restoring an ABANDONED 1963 Split Window Corvette!', url: 'https://www.youtube.com/watch?v=9rqQgOERpDo', thumbnailUrl: 'https://i.ytimg.com/vi/9rqQgOERpDo/large.jpg', durationSeconds: 1917, uploadDate: null, }, { id: 'aaaaaaaaaaa', title: 'Deuxième vidéo', url: 'https://www.youtube.com/watch?v=aaaaaaaaaaa', thumbnailUrl: 'https://i.ytimg.com/vi/aaaaaaaaaaa/large.jpg', durationSeconds: 60, uploadDate: null, }, ], }); }); it('appelle yt-dlp avec -J, l’URL canonique après "--", et un délai adapté aux longues playlists', async () => { vi.mocked(runYtDlp).mockResolvedValue(realShapeOutput); const service = new PlaylistDiscoveryService(noopLogger); await service.listPlaylist( ' https://music.youtube.com/playlist?si=zzz&list=PLKDZ1ig0uz-U&index=2 ', ); expect(runYtDlp).toHaveBeenCalledTimes(1); expect(runYtDlp).toHaveBeenCalledWith(['--flat-playlist', '-J', '--', playlistUrl], { timeoutMs: 120_000, }); }); it('renvoie une playlist vide avec son titre quand entries est vide', async () => { vi.mocked(runYtDlp).mockResolvedValue( JSON.stringify({ _type: 'playlist', title: 'Vide', entries: [] }), ); const service = new PlaylistDiscoveryService(noopLogger); await expect(service.listPlaylist(playlistUrl)).resolves.toEqual({ title: 'Vide', videos: [] }); }); it('tolère l’absence de titre et d’entries (titre null, aucune vidéo)', async () => { vi.mocked(runYtDlp).mockResolvedValue(JSON.stringify({ _type: 'playlist' })); const service = new PlaylistDiscoveryService(noopLogger); await expect(service.listPlaylist(playlistUrl)).resolves.toEqual({ title: null, videos: [] }); }); it('rejette une URL invalide sans jamais appeler yt-dlp', async () => { const service = new PlaylistDiscoveryService(noopLogger); await expect( service.listPlaylist('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc'), ).rejects.toBeInstanceOf(InvalidPlaylistUrlError); expect(runYtDlp).not.toHaveBeenCalled(); }); it('propage l’échec de yt-dlp', async () => { const failure = new Error('yt-dlp a échoué (code 1) : The playlist does not exist.'); vi.mocked(runYtDlp).mockRejectedValue(failure); const service = new PlaylistDiscoveryService(noopLogger); await expect(service.listPlaylist(playlistUrl)).rejects.toBe(failure); }); it('lève une erreur explicite (avec la cause) quand la sortie n’est pas du JSON', async () => { vi.mocked(runYtDlp).mockResolvedValue('ceci n’est pas du JSON'); const service = new PlaylistDiscoveryService(noopLogger); await expect(service.listPlaylist(playlistUrl)).rejects.toMatchObject({ message: expect.stringContaining('illisible'), cause: expect.any(SyntaxError), }); }); });