Files
NewTube/server/tests/search_stream.test.mjs
T
bruno 665a0f0ebd
CI / build-and-test (push) Successful in 14m43s
feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
2026-09-30 07:57:11 -04:00

142 lines
6.3 KiB
JavaScript

/**
* 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 <done> 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');
});
});