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

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, []);
}
});
});