/** * 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`); } } });