CI / build-and-test (push) Successful in 14m43s
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.
142 lines
6.3 KiB
JavaScript
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');
|
|
});
|
|
}); |