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

166 lines
9.8 KiB
JavaScript

// Phase 0 §2.6 — Contrat `Suggestion` v2.
//
// Run with: npm run test:contract
//
// Pourquoi ce test existe : avant, les champs `views` / `publishedAt` / `channelId`
// / `uploaderAvatar` étaient bien récupérés par certains providers, puis perdus
// silencieusement en route (anomalies #3, #7). Rien ne le détectait : le code
// « fonctionnait », la grille restait vide. Ce test fige les invariants.
//
// 100% offline : aucun appel réseau. On teste (a) le contrat déclaré, (b) les
// parseurs HTML/JSON en local, (c) la parité serveur <-> front.
import {
SUGGESTION_CONTRACT_VERSION,
SUGGESTION_V2_FIELDS,
providerRegistry,
validateProviders,
} from '../providers/registry.mjs';
import { parseRelativeDate } from '../providers/youtube-innertube.mjs';
import { isShortItem, isLiveItem, itemDurationSec, itemPublishedTs } from '../search-filters.mjs';
import { suggestionShapeErrors } from './lib/suggestion-shape.mjs';
let passed = 0;
function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); }
function eq(actual, expected, msg) {
const a = JSON.stringify(actual); const b = JSON.stringify(expected);
if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`);
}
function ok(msg) { passed++; console.log(` V ${msg}`); }
// ---------- 0.1 Le contrat est versionné ----------
eq(SUGGESTION_CONTRACT_VERSION, 2, 'contract version is 2');
ok('contract is versioned (v=2)');
expect(Array.isArray(SUGGESTION_V2_FIELDS) && SUGGESTION_V2_FIELDS.length > 0, 'SUGGESTION_V2_FIELDS is a non-empty list');
for (const f of SUGGESTION_V2_FIELDS) expect(typeof f === 'string' && f.length > 0, `field "${f}" is a non-empty string`);
ok(`v2 declares ${SUGGESTION_V2_FIELDS.length} optional fields`);
// ---------- 0.2 Les 6 providers sont bien enregistrés ----------
for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) {
expect(providerRegistry[id], `provider "${id}" is registered`);
expect(typeof providerRegistry[id].search === 'function', `provider "${id}" exposes search()`);
expect(typeof providerRegistry[id].label === 'string' && providerRegistry[id].label.length > 0, `provider "${id}" has a label`);
}
ok('all 6 providers registered with search() + label');
eq(validateProviders('').sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt'], 'empty providers -> all 6');
eq(validateProviders('yt,bogus,YT').sort(), ['yt'], 'unknown + duplicates + case are normalized');
ok('validateProviders normalizes unknown/duplicate/case');
// ---------- 0.3-0.6 Invariants sur des Suggestions synthétiques ----------
// Ce sont les règles que TOUT provider doit respecter. On les applique à un objet
// « bien formé » puis à ses variantes dégradées, comme le ferait la grille.
// Validateur de contrat — source unique dans lib/suggestion-shape.mjs, partagee
// avec provider_shapes.test.mjs (phase 8.4). Deux copies du contrat finitissent par
// diverger, et c'est precisement la divergence qu'on cherche a detecter.
const assertSuggestion = (s, label) => suggestionShapeErrors(s, label, expect) && true;
const good = {
title: 'Test', id: 'abc', url: 'https://example.com/v/abc', thumbnail: 'https://example.com/t.jpg',
uploaderName: 'Chaîne', uploaderAvatar: 'https://example.com/a.jpg', duration: 212,
views: 1234, likes: 56, publishedAt: '2026-01-17T10:30:00.000Z', type: 'video',
width: 1080, height: 1920, channelId: 'UC123', channelExternalId: 'UC123', language: 'fr',
tags: ['a', 'b'], isLive: false, hasSubtitles: true, embeddable: true,
};
assertSuggestion(good, 'well-formed');
ok('well-formed Suggestion passes the contract validator');
// Champs absents => strictement absents (pas de 0, pas de '')
const minimal = { title: 'Test', id: 'abc' };
assertSuggestion(minimal, 'minimal');
eq(Object.keys(minimal), ['title', 'id'], 'a minimal Suggestion carries no nullish junk keys');
ok('minimal Suggestion stays minimal (no invented zeroes)');
// `duration: 0` est le bug historique : il rendait la règle
// « verticale sans durée connue => pas un short » inopérante.
const zeroDuration = { title: 'T', id: 'i', duration: 0 };
try { assertSuggestion(zeroDuration, 'zero-duration'); throw new Error('Assertion failed: duration:0 should have been rejected'); }
catch (e) { expect(String(e.message).includes('duration is strictly > 0'), 'duration:0 is rejected with a clear message'); }
ok('duration:0 is rejected (the phase 1/2 bug class)');
// Distinction affinée en phase 8.4, en gelant la sortie réelle des providers :
// un COMPTEUR à 0 est une donnée vraie (« 0 like »), pas un mensonge. PeerTube
// renvoie `likes: 0` legitimately ; le rejeter en `undefined` ferait perdre
// l'information. Ce qui reste interdit, c'est d'inventer un 0 : l'ABSENT doit
// rester absent.
assertSuggestion({ title: 'T', id: 'i', views: 0, likes: 0, dislikes: 0, viewers: 0 }, 'zero-counts');
ok('an explicit 0 on a counter is accepted (real data, not an invented zero)');
const zeroDims = { title: 'T', id: 'i', width: 0, height: 0 };
try { assertSuggestion(zeroDims, 'zero-dims'); throw new Error('Assertion failed: width:0 should have been rejected'); }
catch (e) { expect(String(e.message).includes('is strictly > 0'), 'width:0 is rejected'); }
ok('0 on width/height stays rejected (a 0-sized video does not exist)');
// ---------- 0.7-0.8 Classification : parité des règles ----------
// Une verticale sans durée connue ne doit PAS être classée short.
eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: undefined }), false,
'portrait WITHOUT duration is not a short (absent duration is not evidence)');
eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: 30 }), true,
'portrait + 30 s is a short');
eq(isShortItem({ type: 'video', width: 1920, height: 1080, duration: 30 }), false,
'landscape + 30 s is NOT a short (duration alone is not enough)');
eq(isShortItem({ type: 'video', isShort: true }), true, 'explicit provider isShort wins');
ok('isShortItem: no duration => no short, and portrait is required');
// Règles (a)/(b)/(c) de la tâche 2.3 — ces trois cas doivent être identiques
// côté serveur ET côté front (`isShortVideo`), sinon la pastille SHORT apparaît
// dans la grille puis disparaît au changement d'onglet.
eq(isShortItem({ isShort: true }), true, '(a) native flag + unknown duration => short');
eq(isShortItem({ isShort: true, width: 1080, height: 1080, duration: 60 }), true,
'(b) square 60 s + native flag => short (flag primes over orientation)');
eq(isShortItem({ width: 1080, height: 1080, duration: 60 }), false,
'(b) square 60 s WITHOUT a flag => not a short (duration alone never qualifies)');
eq(isShortItem({ kind: 'clip', type: 'video', duration: 42 }), true,
'(c) kind:clip beats type:video');
eq(isShortItem({ url: 'https://www.youtube.com/shorts/abcdefghijk', duration: 30 }), true,
'/shorts/ URL marker is honoured (parity with the front)');
ok('rules (a)/(b)/(c) hold, including the /shorts/ URL marker');
// itemDurationSec doit distinguer 0 de undefined.
eq(itemDurationSec({ duration: 0 }), undefined, 'itemDurationSec maps 0 -> undefined');
eq(itemDurationSec({ duration: 30 }), 30, 'itemDurationSec keeps a real duration');
eq(itemDurationSec({ durationSec: 42 }), 42, 'itemDurationSec reads durationSec too');
ok('itemDurationSec never leaks a 0 duration');
// Une date relative ne doit jamais arriver jusqu'ici (bug InnerTube 1.9).
eq(itemPublishedTs({ publishedAt: '2 months ago' }), 0, 'relative label yields no timestamp');
eq(itemPublishedTs({ publishedAt: '2026-01-17T10:30:00.000Z' }), Date.parse('2026-01-17T10:30:00.000Z'),
'ISO date yields a timestamp');
ok('itemPublishedTs rejects relative labels (InnerTube regression guard)');
eq(isLiveItem({ type: 'live' }), true, 'type=live is live');
eq(isLiveItem({ type: 'video', isLive: true }), true, 'isLive flag is honoured');
eq(isLiveItem({ type: 'vod', kind: 'vod' }), false, 'a Twitch VOD is not live');
ok('isLiveItem honours both type and the isLive flag');
// ---------- 0.9 parseRelativeDate (tâche 1.9) ----------
const NOW = Date.parse('2026-03-01T12:00:00.000Z');
const day = 864e5;
eq(parseRelativeDate('il y a 2 heures', NOW), new Date(NOW - 2 * 36e5).toISOString(), 'FR relative hours');
eq(parseRelativeDate('il y a 3 jours', NOW), new Date(NOW - 3 * day).toISOString(), 'FR relative days');
eq(parseRelativeDate('il y a 2 semaines', NOW), new Date(NOW - 2 * 6048e5).toISOString(), 'FR relative weeks');
eq(parseRelativeDate('2 months ago', NOW), new Date(NOW - 2 * 26298e6).toISOString(), 'EN relative months');
eq(parseRelativeDate('Streamed 3 weeks ago', NOW), new Date(NOW - 3 * 6048e5).toISOString(), 'EN streamed weeks');
eq(parseRelativeDate('1 an', NOW), new Date(NOW - 315576e5).toISOString(), 'FR relative years');
eq(parseRelativeDate('hier', NOW), new Date(NOW - day).toISOString(), 'FR yesterday');
eq(parseRelativeDate('yesterday', NOW), new Date(NOW - day).toISOString(), 'EN yesterday');
eq(parseRelativeDate("aujourd'hui", NOW), new Date(NOW).toISOString(), 'FR today');
eq(parseRelativeDate('today', NOW), new Date(NOW).toISOString(), 'EN today');
eq(parseRelativeDate('2026-01-17T10:30:00Z', NOW), '2026-01-17T10:30:00.000Z', 'absolute ISO passthrough');
eq(parseRelativeDate('', NOW), undefined, 'empty string -> undefined');
eq(parseRelativeDate(null, NOW), undefined, 'null -> undefined');
eq(parseRelativeDate('Streaming', NOW), undefined, 'unparseable label -> undefined (no invented date)');
ok(`parseRelativeDate handles ${13} FR/EN cases and never invents a date`);
// ---------- 0.10 Cohérence provider <-> contrat ----------
// `id` court attendu par groupe, et un `search` qui dégrade proprement.
for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) {
const mod = providerRegistry[id];
expect(mod.id === id, `provider "${id}" declares id === "${id}"`);
}
ok('each adapter self-declares its own id');
// Résumé
console.log(`\n provider-contract: ${passed} assertions OK (contract v${SUGGESTION_CONTRACT_VERSION})`);