// Step 17 — tests offline : parsers scrape + dispatcher (AUCUN réseau, AUCUNE clé). import assert from 'node:assert/strict'; import { mapFlatEntry, parseFlatPlaylistJson, classifyScrapeError } from '../providers/youtube-scrape.mjs'; import { getSearchMode, hashSearchKey, buildYtDlpExtraArgs, resolveYtDlpBin, resetYtDlpBinCache } from '../providers/youtube-common.mjs'; function ok(msg) { console.log(`✓ ${msg}`); } // 1) mapFlatEntry { const s = mapFlatEntry({ id: 'dQw4w9WgXcQ', title: 'Demo', channel_id: 'UC123', channel: 'Chaine', duration: 212, view_count: 1000, timestamp: 1700000000, thumbnails: [{ url: 'http://t/low.jpg', height: 90 }, { url: 'http://t/high.jpg', height: 720 }], }); assert.equal(s.id, 'dQw4w9WgXcQ'); assert.equal(s.url, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'); assert.equal(s.thumbnail, 'http://t/high.jpg'); assert.equal(s.duration, 212); ok('mapFlatEntry maps flat entry to Suggestion'); } assert.equal(mapFlatEntry(null), null); assert.equal(mapFlatEntry({}), null); ok('mapFlatEntry null-safe'); // 2) parseFlatPlaylistJson : objet + NDJSON + vide { const raw = JSON.stringify({ entries: [{ id: 'a', title: 'A' }, { id: '', title: 'x' }, null] }); assert.equal(parseFlatPlaylistJson(raw).length, 1); ok('parseFlatPlaylistJson objet .entries'); } { const raw = `{"id":"a","title":"A"}\n{"id":"b","title":"B"}\nnot-json\n`; assert.equal(parseFlatPlaylistJson(raw).length, 2); ok('parseFlatPlaylistJson NDJSON tolerant'); } assert.deepEqual(parseFlatPlaylistJson(''), []); assert.deepEqual(parseFlatPlaylistJson('{{{'), []); ok('parseFlatPlaylistJson vide/invalide -> []'); // 3) getSearchMode défaut innertube-first + normalisation { const prev = process.env.YT_SEARCH_MODE; delete process.env.YT_SEARCH_MODE; assert.equal(getSearchMode(), 'innertube-first'); process.env.YT_SEARCH_MODE = 'API-FIRST'; assert.equal(getSearchMode(), 'api-first'); process.env.YT_SEARCH_MODE = 'scrape-first'; assert.equal(getSearchMode(), 'scrape-first'); process.env.YT_SEARCH_MODE = 'nonsense'; assert.equal(getSearchMode(), 'innertube-first'); if (prev === undefined) delete process.env.YT_SEARCH_MODE; else process.env.YT_SEARCH_MODE = prev; ok('getSearchMode défaut + normalisation'); } // 4) hash stable + extra args jamais de secret en clair autre que proxy path { assert.equal(hashSearchKey('a'), hashSearchKey('a')); assert.notEqual(hashSearchKey('a'), hashSearchKey('b')); const args = buildYtDlpExtraArgs(); assert.ok(Array.isArray(args)); ok('hashSearchKey + buildYtDlpExtraArgs'); } // 5) dispatcher scrape-only sans clé : ne doit PAS exiger YOUTUBE_API_KEY au import, // et searchViaScrape vide sur query courte { const { searchViaScrape } = await import('../providers/youtube-scrape.mjs'); assert.deepEqual(await searchViaScrape('a'), []); ok('searchViaScrape query courte -> [] sans réseau'); } // 6) classifyScrapeError : ENOENT -> yt_scrape_no_binary (message actionnable, jamais de spawn brut en UI) { const e = classifyScrapeError(Object.assign(new Error('spawn yt-dlp ENOENT'), { code: 'ENOENT', errno: 'ENOENT' })); assert.equal(e.code, 'yt_scrape_no_binary'); assert.equal(e.ytStatus, 503); assert.match(e.message, /YT_DLP_PATH/); const passthrough = classifyScrapeError(Object.assign(new Error('x'), { code: 'yt_scrape_no_binary' })); assert.equal(passthrough.code, 'yt_scrape_no_binary'); ok('classifyScrapeError ENOENT -> yt_scrape_no_binary'); } // 7) resolveYtDlpBin : YT_DLP_PATH inexistant -> fallback PATH/bundled (jamais de throw si un binaire existe) { resetYtDlpBinCache(); const prev = process.env.YT_DLP_PATH; process.env.YT_DLP_PATH = '/nonexistent/yt-dlp'; try { const bin = await resolveYtDlpBin(); assert.ok(typeof bin === 'string' && bin.length > 0); ok(`resolveYtDlpBin fallback -> ${bin}`); } finally { resetYtDlpBinCache(); if (prev === undefined) delete process.env.YT_DLP_PATH; else process.env.YT_DLP_PATH = prev; } } console.log('youtube-scrape tests passed');