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.
203 lines
8.7 KiB
JavaScript
203 lines
8.7 KiB
JavaScript
/**
|
|
* Phase 8.2 / 8.3 — contrat `Suggestion` v2 strict + feature flags `FF_<PROVIDER>`.
|
|
*
|
|
* Le module front `src/app/search/search-contract.ts` est réellement *chargé*
|
|
* (transpilé à la volée via esbuild) au lieu d'être relu par expression
|
|
* régulière : une regex passerait au travers d'un bug de logique alors qu'elle
|
|
* voit bien les mots-clés, ce qui est exactement le défaut qu'on traque ici.
|
|
* esbuild est une dépendance d'@angular/build ; si elle disparaît, le test
|
|
* échoue bruyamment plutôt que de devenir vert par accident.
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import {
|
|
providerFlag, isProviderDisabled, partitionEnabledProviders, applyProviderFlags,
|
|
} from '../providers/feature-flags.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, '../..');
|
|
|
|
/** Charge un module TypeScript du front dans le contexte node (esbuild). */
|
|
async function loadTs(relPath) {
|
|
const abs = path.join(ROOT, relPath);
|
|
const esbuild = await import('esbuild');
|
|
const { code } = esbuild.transformSync(fs.readFileSync(abs, 'utf8'), {
|
|
loader: 'ts', format: 'esm', target: 'node20',
|
|
});
|
|
// `import type` est effacé par esbuild : le module n'a plus de dépendance.
|
|
const url = 'data:text/javascript;base64,' + Buffer.from(code, 'utf8').toString('base64');
|
|
return import(url);
|
|
}
|
|
|
|
const contract = await loadTs('src/app/search/search-contract.ts');
|
|
|
|
/** Capture les console.warn pendant l'appel à `fn`. */
|
|
async function captureWarnings(fn) {
|
|
const original = console.warn;
|
|
const lines = [];
|
|
console.warn = (...a) => { lines.push(a.map(String).join(' ')); };
|
|
try { return { value: await fn(), lines }; }
|
|
finally { console.warn = original; }
|
|
}
|
|
|
|
// --- 8.2 : contrat v2 strict ------------------------------------------------
|
|
|
|
test('8.2 — v2 est accepté sans avertissement', async () => {
|
|
contract.resetContractWarningsForTests();
|
|
const res = { v: 2, groups: { yt: [{ id: 'a' }] } };
|
|
const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'yt'));
|
|
assert.equal(value.legacyContract, false);
|
|
assert.equal(value.contractVersion, 2);
|
|
assert.equal(value.items.length, 1);
|
|
assert.deepEqual(lines, [], 'un contrat à jour ne doit rien écrire dans la console');
|
|
});
|
|
|
|
test('8.2 — v1 reste lisible (compat) mais est signalé', async () => {
|
|
contract.resetContractWarningsForTests();
|
|
const res = { v: 1, groups: { ru: [{ id: 'x' }, { id: 'y' }] } };
|
|
const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'ru'));
|
|
assert.equal(value.legacyContract, true, 'v1 doit être marqué legacy');
|
|
assert.equal(value.items.length, 2, 'la compat ne doit pas perdre de résultats');
|
|
assert.equal(lines.length, 1, 'un serveur obsolète doit être signalé');
|
|
assert.match(lines[0], /contrat v1/);
|
|
});
|
|
|
|
test('8.2 — version absente = legacy (le plus probable est un proxy mal configuré)', async () => {
|
|
contract.resetContractWarningsForTests();
|
|
const { value, lines } = await captureWarnings(() => contract.readSearchGroup({ groups: {} }, 'od'));
|
|
assert.equal(value.legacyContract, true);
|
|
assert.equal(value.contractVersion, null);
|
|
assert.match(lines[0], /contrat v\?/);
|
|
});
|
|
|
|
test('8.2 — l\'avertissement est émis une seule fois par process', async () => {
|
|
contract.resetContractWarningsForTests();
|
|
const first = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt'));
|
|
const second = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt'));
|
|
const third = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'dm'));
|
|
assert.equal(first.lines.length, 1);
|
|
assert.deepEqual(second.lines, [], 'une 2e recherche ne doit pas respammer');
|
|
assert.deepEqual(third.lines, [], 'le décompte est global, pas par provider');
|
|
});
|
|
|
|
test('8.2 — un groupe absent ou malformé donne un tableau vide, jamais un throw', async () => {
|
|
contract.resetContractWarningsForTests();
|
|
for (const res of [undefined, {}, { v: 2 }, { v: 2, groups: { yt: null } }, { v: 2, groups: { yt: 'nope' } }]) {
|
|
const r = contract.readSearchGroup(res, 'yt');
|
|
assert.deepEqual(r.items, [], `cas ${JSON.stringify(res)}`);
|
|
}
|
|
});
|
|
|
|
test('8.2 — l\'erreur serveur du provider est remontée', async () => {
|
|
contract.resetContractWarningsForTests();
|
|
const r = contract.readSearchGroup({ v: 2, groups: {}, errors: { pt: { message: 'upstream 503' } } }, 'pt');
|
|
assert.equal(r.providerError, 'upstream 503');
|
|
});
|
|
|
|
test('8.3 — un provider désactivé par FF n\'est PAS une erreur technique', async () => {
|
|
contract.resetContractWarningsForTests();
|
|
const r = contract.readSearchGroup(
|
|
{ v: 2, groups: {}, errors: { ru: { message: 'Provider désactivé par le feature flag FF_RU', code: 'disabled_by_ff' } } },
|
|
'ru',
|
|
);
|
|
assert.equal(r.providerError, undefined, 'sinon l\'UI afficherait une panne pour un arrêt volontaire');
|
|
});
|
|
|
|
// --- 8.3 : feature flags ----------------------------------------------------
|
|
|
|
const withEnv = (vars, fn) => {
|
|
const saved = {};
|
|
for (const k of Object.keys(vars)) {
|
|
saved[k] = process.env[k];
|
|
// `process.env[k] = undefined` stocke la chaîne « undefined » : il faut
|
|
// supprimer la clé pour truly simuler un flag absent.
|
|
if (vars[k] === undefined) delete process.env[k];
|
|
else process.env[k] = vars[k];
|
|
}
|
|
try { return fn(); } finally {
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
}
|
|
}
|
|
};
|
|
|
|
test('8.3 — un flag absent laisse le provider actif (comportement historique)', () => {
|
|
withEnv({ FF_RU: undefined }, () => {
|
|
const f = providerFlag('ru');
|
|
assert.equal(f.enabled, true);
|
|
assert.equal(f.set, false, '`set` distingue « non configuré » de « désactivé »');
|
|
assert.equal(f.flag, 'FF_RU');
|
|
});
|
|
});
|
|
|
|
test('8.3 — les fausses valeurs usuelles désactivent le provider', () => {
|
|
for (const v of ['0', 'false', 'FALSE', 'off', 'no', 'disabled', ' 0 ', 'No']) {
|
|
withEnv({ FF_RU: v }, () => {
|
|
assert.equal(isProviderDisabled('ru'), true, `FF_RU=${JSON.stringify(v)} doit désactiver`);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('8.3 — les vraies valeurs usuelles gardent le provider actif', () => {
|
|
for (const v of ['1', 'true', 'TRUE', 'on', 'yes', 'enabled']) {
|
|
withEnv({ FF_RU: v }, () => {
|
|
assert.equal(isProviderDisabled('ru'), false, `FF_RU=${JSON.stringify(v)} doit rester actif`);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('8.3 — une variable vide est « non configurée », pas « désactivée »', () => {
|
|
// Piège classique en prod : un `FF_RU=` laissé vide dans un .env éteindrait
|
|
// silencieusement tout le provider. On privilégie le comportement historique.
|
|
withEnv({ FF_RU: ' ' }, () => {
|
|
const f = providerFlag('ru');
|
|
assert.equal(f.enabled, true);
|
|
assert.equal(f.set, false);
|
|
});
|
|
});
|
|
|
|
test('8.3 — les flags sont lus à chaque appel (pas de cache figé)', () => {
|
|
withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false));
|
|
withEnv({ FF_RU: '0' }, () => assert.equal(isProviderDisabled('ru'), true));
|
|
// Relu après bascule, sans redémarrage ni invalidation manuelle.
|
|
withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false));
|
|
});
|
|
|
|
test('8.3 — le flag est insensibilisé à la casse de l\'id provider', () => {
|
|
withEnv({ FF_RU: '0' }, () => {
|
|
for (const id of ['ru', 'RU', ' Ru ']) assert.equal(isProviderDisabled(id), true, `id=${id}`);
|
|
});
|
|
});
|
|
|
|
test('8.3 — partition : seuls les ids valides survivent, et on sait lesquels tombent', () => {
|
|
withEnv({ FF_RU: '0', FF_OD: '1' }, () => {
|
|
const { enabled, disabled } = partitionEnabledProviders(['yt', 'ru', 'od', 'dm']);
|
|
assert.deepEqual(enabled, ['yt', 'od', 'dm'], 'l\'ordre demandé est préservé');
|
|
assert.deepEqual(disabled, [{ provider: 'ru', flag: 'FF_RU' }]);
|
|
});
|
|
});
|
|
|
|
test('8.3 — applyProviderFlags produit une erreur `disabled_by_ff` explicable', () => {
|
|
withEnv({ FF_RU: 'off' }, () => {
|
|
const { providerIds, errors } = applyProviderFlags(['yt', 'ru', 'tw']);
|
|
assert.deepEqual(providerIds, ['yt', 'tw']);
|
|
assert.equal(errors.ru.code, 'disabled_by_ff');
|
|
assert.match(errors.ru.message, /FF_RU/, 'le message doit nommer le flag : c\'est la piste n°1 en prod');
|
|
assert.equal(errors.yt, undefined, 'un provider actif ne doit pas produire d\'erreur');
|
|
});
|
|
});
|
|
|
|
test('8.3 — entrée non tableau : dégradation sûre', () => {
|
|
withEnv({}, () => {
|
|
for (const bad of [undefined, null, 'yt', 42]) {
|
|
const { enabled, disabled } = partitionEnabledProviders(bad);
|
|
assert.deepEqual(enabled, []);
|
|
assert.deepEqual(disabled, []);
|
|
}
|
|
});
|
|
});
|