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.
113 lines
4.8 KiB
JavaScript
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);
|
|
});
|
|
});
|