import type { FastifyBaseLogger } from 'fastify'; import { describe, expect, it, vi } from 'vitest'; vi.mock('../src/services/ytDlpRunner.js', () => ({ runYtDlp: vi.fn(), })); import { ChannelDiscoveryService, InvalidChannelError, } from '../src/services/ChannelDiscoveryService.js'; import { runYtDlp } from '../src/services/ytDlpRunner.js'; const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger; describe('ChannelDiscoveryService', () => { it('parse les lignes yt-dlp et filtre par mots-clés', async () => { vi.mocked(runYtDlp).mockResolvedValue( [ JSON.stringify({ id: 'a1', title: 'Finale speed 2026', url: 'https://youtu.be/a1', duration: 120, thumbnail: 'https://x/a1.jpg', upload_date: '20260101', }), JSON.stringify({ id: 'a2', title: 'Interview backstage', url: 'https://youtu.be/a2' }), '', ].join('\n'), ); const service = new ChannelDiscoveryService(noopLogger); const results = await service.listChannel('@worldclimbing', 'streams', ['speed']); expect(results).toEqual([ { id: 'a1', title: 'Finale speed 2026', url: 'https://youtu.be/a1', thumbnailUrl: 'https://x/a1.jpg', durationSeconds: 120, uploadDate: '20260101', }, ]); expect(runYtDlp).toHaveBeenCalledWith( ['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/streams'], { timeoutMs: 60_000 }, ); }); it('retombe sur le tableau "thumbnails" (forme réelle de yt-dlp en --flat-playlist)', async () => { vi.mocked(runYtDlp).mockResolvedValue( JSON.stringify({ id: 'd1', title: 'Sans champ thumbnail singulier', url: 'https://youtu.be/d1', thumbnails: [ { url: 'https://x/d1-small.jpg', height: 90 }, { url: 'https://x/d1-large.jpg', height: 360 }, ], }), ); const service = new ChannelDiscoveryService(noopLogger); const results = await service.listChannel('@worldclimbing', 'videos'); expect(results).toHaveLength(1); expect(results[0]?.thumbnailUrl).toBe('https://x/d1-large.jpg'); }); it('accepte une URL de chaîne complète et ignore la casse des mots-clés', async () => { vi.mocked(runYtDlp).mockResolvedValue( JSON.stringify({ id: 'c1', title: 'BIG Speed Final', url: 'https://youtu.be/c1' }), ); const service = new ChannelDiscoveryService(noopLogger); const results = await service.listChannel('https://www.youtube.com/@worldclimbing/', 'videos', [ 'SPEED', ]); expect(results).toHaveLength(1); expect(runYtDlp).toHaveBeenCalledWith( ['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/videos'], { timeoutMs: 60_000 }, ); }); it.each(['--exec=rm', '--exec', '-x', '.hidden'])( 'rejette "%s" comme identifiant de chaîne invalide sans jamais appeler yt-dlp', async (channel) => { const service = new ChannelDiscoveryService(noopLogger); await expect(service.listChannel(channel, 'videos')).rejects.toBeInstanceOf( InvalidChannelError, ); expect(runYtDlp).not.toHaveBeenCalled(); }, ); it('ignore silencieusement les lignes JSON invalides ou incomplètes', async () => { vi.mocked(runYtDlp).mockResolvedValue( [ 'not json', JSON.stringify({ id: 'b1' }), JSON.stringify({ id: 'b2', title: 'ok', url: 'https://youtu.be/b2' }), ].join('\n'), ); const service = new ChannelDiscoveryService(noopLogger); const results = await service.listChannel('somehandle', 'videos'); expect(results).toEqual([ { id: 'b2', title: 'ok', url: 'https://youtu.be/b2', thumbnailUrl: null, durationSeconds: null, uploadDate: null, }, ]); }); });