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

112 lines
5.0 KiB
JavaScript

/**
* Phase 8.4 — même test de forme `Suggestion`, sur fixtures gelées, pour les 6 providers.
*
* Les fixtures viennent de `npm run fixtures:record`, qui appelle les 6
* adaptateurs RÉELS : elles décrivent la sortie d'aujourd'hui, pas une
* intention. Ce test les rejoue à chaque PR, donc il détecte deux dérives :
* - le contrat a changé et un adaptateur ne suit plus ;
* - un adaptateur émet un champ qui viole le contrat.
*
* Il sert aussi de garde-fou « le gel n'est pas pourri » : un fichier de
* fixtures vide ou sans providers serait un test vert vide de sens.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { SUGGESTION_CONTRACT_VERSION, SUGGESTION_V2_FIELDS } from '../providers/registry.mjs';
import { suggestionShapeErrors } from './lib/suggestion-shape.mjs';
const FIXTURES = path.join(import.meta.dirname, 'fixtures/provider-suggestions.json');
const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
const raw = fs.readFileSync(FIXTURES, 'utf8');
const data = JSON.parse(raw);
const errorsOf = (s, label) => {
const acc = [];
// Même sémantique que `expect` : on ne retient que les prédicats faux.
suggestionShapeErrors(s, label, (cond, msg) => { if (!cond) acc.push(msg); });
return acc;
};
test('8.4 — les fixtures sont gelées depuis le contrat v2', () => {
assert.equal(data._contractVersion, SUGGESTION_CONTRACT_VERSION,
'fixtures gelées sous une autre version de contrat : régénérer (`npm run fixtures:record`)');
assert.ok(data._recordedAt, 'date de gel absente');
assert.ok(data._query, 'requête de gel absente');
});
test('8.4 — les 6 providers sont présents dans le gel', () => {
for (const id of ALL) {
assert.ok(data.providers[id], `provider « ${id} » absent des fixtures`);
assert.ok(Array.isArray(data.providers[id].items), `items de « ${id} » n'est pas un tableau`);
}
});
test('8.4 — la forme de chaque Suggestion gelée respecte le contrat', () => {
let checked = 0;
for (const [id, entry] of Object.entries(data.providers)) {
for (const [i, item] of entry.items.entries()) {
const errs = errorsOf(item, `${id}[${i}]`);
assert.deepEqual(errs, [], `forme invalide :\n ${errs.join('\n ')}`);
checked++;
}
}
// Le gel a couvert des providers réels : sinon la suite ne prouve rien.
assert.ok(checked >= 6, `seulement ${checked} items vérifiés, le gel est vide ou cassé`);
});
test('8.4 — aucun provider ne renvoie un objet vide déguisé', () => {
// Un provider sans résultat doit être un tableau VIDE, jamais `null`, `{}`
// ou un groupe à trous : ces valeurs cassent le front bien plus qu'une
// simple absence.
for (const [id, entry] of Object.entries(data.providers)) {
for (const [i, item] of entry.items.entries()) {
assert.equal(typeof item, 'object', `${id}[${i}] n'est pas un objet`);
assert.notEqual(Array.isArray(item), true, `${id}[${i}] est un tableau`);
assert.ok(item.title, `${id}[${i}] sans titre`);
assert.ok(item.id, `${id}[${i}] sans id`);
}
}
});
test('8.4 — les champs du contrat v2 sont tous connus des fixtures', () => {
// Sens de lecture : un champ émis par un adaptateur mais absent de
// SUGGESTION_V2_FIELDS n'est ni documenté ni lu par le front. Le gel le
// révèle (c'est le but de 8.5) ; on ne casse pas le test pour autant, on
// exige que la dérive soit visible dans le compte rendu.
// SUGGESTION_V2_FIELDS liste les AJOUTS v2 ; le contrat de base (v1) n'y est
// pas, donc on l'ajoute explicitement — sinon `duration`/`width`/`viewCount`
// paraîtraient être des champs inconnus.
const known = new Set([
...SUGGESTION_V2_FIELDS,
'id', 'title', 'url', 'thumbnail', 'uploaderName', 'type',
'duration', 'width', 'height', 'viewCount', 'viewersRaw',
]);
const seen = new Set();
for (const entry of Object.values(data.providers)) {
for (const item of entry.items) for (const k of Object.keys(item)) seen.add(k);
}
const undocumented = [...seen].filter((k) => !known.has(k)).sort();
// `badges` (YouTube) et `uploadedDate` (Dailymotion) sortent du contrat v2 :
// on les liste explicitement comme tolérés, pour ne pas les découvrir par
// hasard dans six mois.
const TOLERATED = new Set(['badges', 'uploadedDate']);
const unexpected = undocumented.filter((k) => !TOLERATED.has(k));
assert.deepEqual(unexpected, [],
`champs émis mais absents du contrat v2 : ${unexpected.join(', ')} — les ajouter à SUGGESTION_V2_FIELDS ou à TOLERATED`);
});
test('8.4 — la couverture gelée est explicite sur ses trous', () => {
// `tw` (sans identifiants) et `ru` (Cloudflare) n'ont pas pu être gelés.
// Un trou de couverture doit être ASSUMÉ dans le fichier, pas passer pour
// une couverture complète.
for (const [id, entry] of Object.entries(data.providers)) {
if (entry.items.length === 0) {
assert.ok(entry.note, `« ${id} » est vide sans note : la couverture manquante serait invisible`);
}
}
});