Files
NewTube/server/tests/feature_flags_http.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

113 lines
4.8 KiB
JavaScript

/**
* Phase 8.3 (intégration) — le câblage HTTP des feature flags.
*
* Les tests unitaires de `feature-flags.mjs` prouvent la sémantique des flags ;
* ceux-ci prouvent qu'ils sont *branchés* : que le provider éteint n'est pas
* appelé, qu'il est signalé dans la réponse, et que le diagnostic de santé ne le
* sonde pas.
*
* Méthode : on démarre le serveur avec les SIX flags à `0`. Un fan-out normal
* appellerait six upstreams ; ici il n'y a donc **aucun accès réseau**, ce qui
* rend le test déterministe (et le prouve : voir l'assertion de durée).
*/
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-ff-'));
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, 'ff.db'),
JWT_SECRET: 'ff-test-secret',
NODE_ENV: 'test',
// Tous les providers éteints -> aucun appel upstream attendu.
...Object.fromEntries(ALL.map((p) => [`FF_${p.toUpperCase()}`, '0'])),
// Coupe aussi l'enrichissement web des suggestions (sinon : réseau).
SUGGEST_WEB_ENABLED: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let logs = '';
server.stdout.on('data', (d) => { logs += d; });
server.stderr.on('data', (d) => { logs += d; });
const J = async (url) => {
const r = await fetch(url);
return { status: r.status, body: await r.json().catch(() => ({})) };
};
describe('Phase 8.3 — feature flags câblés sur le HTTP', () => {
before(async () => {
const t0 = Date.now();
while (Date.now() - t0 < 25000) {
try { const r = await fetch(`${base}/api/health`); if (r.status < 500) return; } catch {}
await new Promise((r) => setTimeout(r, 300));
}
assert.fail(`API ne démarre pas:\n${logs.slice(-3000)}`);
});
after(() => {
server.kill();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
it('/api/search exclut les providers éteints du fan-out et les signale', async () => {
const t0 = Date.now();
const { status, body } = await J(`${base}/api/search?q=test&providers=${ALL.join(',')}`);
const elapsed = Date.now() - t0;
assert.equal(status, 200);
assert.equal(body.v, 2, 'la version de contrat reste annoncée');
assert.deepEqual(body.providers, [], 'aucun provider ne doit rester dans le fan-out');
for (const p of ALL) {
assert.deepEqual(body.groups[p], [], `${p} doit avoir un groupe vide et non absent`);
assert.equal(body.errors[p].code, 'disabled_by_ff', `${p} doit être signalé disabled_by_ff`);
assert.match(body.errors[p].message, new RegExp(`FF_${p.toUpperCase()}`), 'le message nomme le flag');
}
// Preuve indirecte qu'aucun upstream n'a été contacté : un fan-out réel
// part en parallèle sur 6 services et dépasse largement ce seuil.
assert.ok(elapsed < 2000, `réponse en ${elapsed}ms : un provider éteint est probablement encore appelé`);
});
it('un provider éteint est absent de `providers` mais garde sa colonne', async () => {
const { body } = await J(`${base}/api/search?q=test&providers=yt,ru,od`);
assert.deepEqual(body.providers, [], 'tous les providers demandés sont éteints dans ce test');
assert.ok('yt' in body.groups && 'ru' in body.groups && 'od' in body.groups,
'le front doit pouvoir afficher une colonne pour chacun des providers demandés');
});
it('/api/suggest applique les mêmes flags et signale la raison', async () => {
const { status, body } = await J(`${base}/api/search/suggest?q=test&providers=${ALL.join(',')}`);
assert.equal(status, 200);
assert.equal(body.errors.ru.code, 'disabled_by_ff');
for (const p of ALL) assert.deepEqual(body.groups[p], [], `${p} doit être vide`);
});
it('/api/providers/health distingue « éteint » de « en panne »', async () => {
const { body } = await J(`${base}/api/providers/health?provider=ru`);
const ru = body.providers.ru;
assert.equal(ru.disabled, true);
assert.equal(ru.flag, 'FF_RU');
assert.equal(ru.flagValue, '0');
assert.equal(ru.lastError, 'disabled_by_ff');
// Un provider éteint n'est pas « cassé » : ni erreur, ni latence mesurée.
assert.equal(ru.consecutiveFailures, 0);
assert.equal(ru.errorRate, 0);
assert.equal(ru.latencyMs, 0);
});
});