/** * Phase 7.6 — transport NDJSON de `/api/search`. * * Deux modes pour un SEUL fan-out : la route doit produire soit une réponse * atomique (aujourd'hui), soit un flux d'une ligne JSON par provider + une * ligne `done`. Les erreurs de ligne (parse JSON cassé) ici sont le miroir * exact de ce que le client frontend lirait sans parseur SSE. * * Démarrage comme `feature_flags_http.test.mjs` : les SIX flags à `0`, donc * aucun appel upstream — parfaitement déterministe, et le flux sans provider * reste une preuve de la FORME du transport (séquences de lignes, clôture, * types de contenu, contrat). Le réseau ne teste rien ici : il polluerait le * test avec des delais étrangers au protocole. */ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import net from 'node:net'; import { spawn } from 'node:child_process'; const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-stream-')); const PORT = await new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); }); const base = `http://127.0.0.1:${PORT}`; const server = spawn(process.execPath, ['./server/index.mjs'], { cwd: path.resolve(import.meta.dirname, '..', '..'), env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: path.join(tmpDir, 'stream.db'), JWT_SECRET: 'stream-test-secret', NODE_ENV: 'test', ...Object.fromEntries(ALL.map((p) => [`FF_${p.toUpperCase()}`, '0'])), SUGGEST_WEB_ENABLED: '0', }, stdio: ['ignore', 'pipe', 'pipe'], }); let ready = false; server.stdout.on('data', (d) => { if (String(d).includes('listening')) ready = true; }); // Ne pas laisser stdout du serveur polluer l'output du test. server.stdout.resume(); server.stderr.resume(); async function whenReady(timeoutMs = 20_000) { const t0 = Date.now(); while (!ready) { // Sondage HTTP en plus du texte stdout : plus fiable (et prouve au passage // que le serveur répond). `NODE_ENV=test` + flags éteints => aucune requête // amont, le 200 est garanti sans réseau. if (Date.now() - t0 > timeoutMs) throw new Error('serveur de test non démarré'); try { const r = await fetch(`${base}/api/search?q=zz`); if (r.status === 200) ready = true; } catch {} if (!ready) await new Promise((r) => setTimeout(r, 100)); } } describe('Phase 7.6 — transport NDJSON de /api/search', () => { before(async () => { await whenReady(); }); after(async () => { // Windows : `kill()` est asynchrone, la base est encore verrouillée quand // on revient. On attend la SORTIE du process avant de vider le dossier. const exited = new Promise((resolve) => server.once('exit', resolve)); server.kill(); await exited; await new Promise((r) => setTimeout(r, 100)); fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('répond en lignes NDJSON quand le client le demande', async () => { const res = await fetch(`${base}/api/search?q=test`, { headers: { Accept: 'application/x-ndjson' }, }); assert.equal(res.status, 200); const ct = res.headers.get('content-type') || ''; assert.ok(ct.includes('application/x-ndjson'), `media type négocié (got "${ct}")`); assert.equal(res.headers.get('cache-control'), 'no-store', 'jamais mis en cache'); const body = await res.text(); const lines = body.split('\n').filter((l) => l.trim().length > 0); // Flags éteints => AUCUN provider => pas de ligne `provider`, une seule // ligne `done`. C'est un cas limite du protocole, pas un bug : le front // attend pour conclure, pas un nombre précis de lignes. assert.ok(lines.length >= 1, `au moins la ligne done (${lines.length})`); for (const [i, line] of lines.entries()) { assert.ok(line.startsWith('{'), `ligne ${i} est du JSON`); const parsed = JSON.parse(line); // stabilité du protocole : une erreur ici est un échec assert.ok(parsed.type === 'provider' || parsed.type === 'done', `ligne ${i} typée`); } const done = lines.map(JSON.parse).find((l) => l.type === 'done'); assert.ok(done, 'une ligne done clôture le flux'); assert.equal(done.q, 'test'); assert.deepEqual(done.providers, [], '6 flags éteints => aucun provider'); assert.equal(done.v, 2, 'le contrat v2 est annoncé aussi en flux'); assert.ok(typeof done.page === 'number'); assert.ok(typeof done.pageSize === 'number'); }); it('`?stream=1` fonctionne comme l\'en-tête Accept', async () => { const res = await fetch(`${base}/api/search?q=test&stream=1`); const lines = (await res.text()).split('\n').filter((l) => l.trim()); assert.ok(lines.length >= 1, 'flux remis'); assert.ok(lines.every((l) => l.startsWith('{')), 'lignes JSON'); }); it('sans NDJSON, la réponse atomique historique est préservée', async () => { const res = await fetch(`${base}/api/search?q=test`); const body = await res.json(); assert.ok(!('type' in body), 'pas de ligne NDJSON dans le mode atomique'); assert.equal(body.v, 2, 'contrat v2'); // Flags éteints => colonnes présentes mais vides (comportement 8.3 : une // pastille « aucun résultat » peut dire pourquoi, au lieu d'un blank). assert.deepEqual(Object.keys(body.groups).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt']); assert.ok(Object.values(body.groups).every((g) => Array.isArray(g) && g.length === 0), 'colonnes vides'); assert.ok(Array.isArray(body.providers)); assert.equal(typeof body.filters, 'object'); }); it('la requête invalide reste 400, dans les deux transports', async () => { const atomic = await fetch(`${base}/api/search?q=`); assert.equal(atomic.status, 400); const ndjson = await fetch(`${base}/api/search?q=`, { headers: { Accept: 'application/x-ndjson' } }); assert.equal(ndjson.status, 400); }); it('répond vite même sans réseau (flags éteints => zéro appel amont)', async () => { const t0 = Date.now(); const res = await fetch(`${base}/api/search?q=test`, { headers: { Accept: 'application/x-ndjson' } }); await res.text(); assert.ok(Date.now() - t0 < 3000, 'aucun (faux) délai upstream dans le test'); }); });