/** * Phase 8.2 / 8.3 — contrat `Suggestion` v2 strict + feature flags `FF_`. * * 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, []); } }); });