// Step 15 — Suggest tests: unit parsing/dedup (offline) + API contract (isolated server). // Run with: npm run test:suggest // Scenario coverage (todo Step 15): // - providers=yt returns only yt suggestions // - unknown provider falls back to the full registry // Assertions are on fan-out shape, never on external provider content. import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawn } from 'node:child_process'; import net from 'node:net'; function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); } function logOk(msg) { console.log(`✓ ${msg}`); } // ---------- 1) Offline unit tests: parsers + graceful degradation ---------- const yt = (await import('../providers/youtube.mjs')).default; const { parseYoutubeSuggestResponse } = await import('../providers/youtube.mjs'); const dm = (await import('../providers/dailymotion.mjs')).default; // Bare JSON payload expect( JSON.stringify(parseYoutubeSuggestResponse('["tut",["tutoriel angular","tutoriel android"],[],{}]')) === JSON.stringify(['tutoriel angular', 'tutoriel android']), 'bare JSON suggest payload parses', ); logOk('parseYoutubeSuggestResponse bare JSON'); // window.google.ac.h(...) wrapped payload expect( JSON.stringify( parseYoutubeSuggestResponse('window.google.ac.h(["tut",["tutoriel angular"],[],{}])'), ) === JSON.stringify(['tutoriel angular']), 'wrapped JSONP suggest payload parses', ); logOk('parseYoutubeSuggestResponse wrapped payload'); // Array-wrapped suggestions (newer format: [text, type, ...]) expect( JSON.stringify(parseYoutubeSuggestResponse('["tut",[["tutoriel angular",0],["tutoriel android",0]]]')) === JSON.stringify(['tutoriel angular', 'tutoriel android']), 'array-wrapped suggestions keep text only', ); logOk('parseYoutubeSuggestResponse array-wrapped entries'); // Garbage / empty payloads never throw expect(parseYoutubeSuggestResponse('not json at all').length === 0, 'garbage payload yields []'); expect(parseYoutubeSuggestResponse('').length === 0, 'empty payload yields []'); logOk('parseYoutubeSuggestResponse graceful degradation'); // min-length guard: no network below 2 chars (would throw offline otherwise) expect(JSON.stringify(await yt.suggest('a')) === '[]', 'yt.suggest short query returns [] without network'); expect(JSON.stringify(await dm.suggest('x')) === '[]', 'dm.suggest short query returns [] without network'); logOk('suggest min-length guard (no network)'); // ---------- 2) API contract against a real isolated server ---------- const PORT = await new Promise((resolve) => { const srv = net.createServer(); srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => resolve(p)); }); }); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-suggest-test-')); const server = spawn(process.execPath, ['./server/index.mjs'], { env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: path.join(tmpDir, 'suggest.db'), JWT_SECRET: 'suggest-test-secret', NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'], cwd: path.resolve(import.meta.dirname, '..', '..'), }); let serverLogs = ''; server.stdout.on('data', (d) => { serverLogs += d.toString(); }); server.stderr.on('data', (d) => { serverLogs += d.toString(); }); const baseUrl = `http://127.0.0.1:${PORT}`; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitForServer(timeoutMs = 20000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const res = await fetch(`${baseUrl}/`); if (res.status < 500) return true; } catch {} await sleep(300); } return false; } let failures = 0; async function scenario(name, fn) { try { await fn(); logOk(name); } catch (e) { failures += 1; console.error(`✗ ${name}`); console.error(e?.stack || String(e)); } } try { const up = await waitForServer(); expect(up, 'isolated server starts'); await scenario('q too short -> 400', async () => { const res = await fetch(`${baseUrl}/api/search/suggest?q=a`); expect(res.status === 400, `expected 400, got ${res.status}`); }); await scenario('providers=yt returns only yt suggestions', async () => { const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=yt&limit=5`); expect(res.status === 200, `expected 200, got ${res.status}`); const body = await res.json(); expect(body.q === 'tutoriel', 'q echoed'); expect(JSON.stringify(Object.keys(body.groups)) === JSON.stringify(['yt']), `only yt group (got ${Object.keys(body.groups)})`); expect(Array.isArray(body.groups.yt), 'yt group is an array'); expect(body.groups.yt.length <= 5, 'limit respected'); }); await scenario('unknown provider falls back to the full registry', async () => { const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=xx,yy&limit=3`); expect(res.status === 200, `expected 200, got ${res.status}`); const body = await res.json(); const keys = Object.keys(body.groups).sort(); expect(JSON.stringify(keys) === JSON.stringify(['dm', 'od', 'pt', 'ru', 'tw', 'yt']), `full registry fallback (got ${keys})`); }); } finally { server.kill(); } if (failures > 0) { console.error(`\n${failures} suggest scenario(s) failed. Server logs:\n${serverLogs}`); process.exit(1); } console.log('\nAll suggest tests passed.');