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

189 lines
9.5 KiB
JavaScript

// Phase 6 §8.2 — identité de chaîne normalisée (`channelRef`).
// Run with: npm run test:channelref
//
// 100 % offline. Vérifie :
// (a) les 3 conventions historiques se reconvertissent SANS PERTE,
// (b) `pt` et `od` produisent exactement la valeur que le front produisait hier
// (critère d'acceptation 8.2) — implémentation historique rejouée ici,
// (c) les listes de schemes serveur <-> front ne divergent pas,
// (d) rien n'est inventé quand l'identifiant est absent.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CHANNEL_REF_SCHEMES,
buildChannelRef,
buildPeerTubeComposite,
withChannelRefs,
parsePeerTubeComposite,
normalizeOdyseeClaim,
odyseeClaimToSlug,
odyseeClaimToResolveArg,
} from '../providers/channel-ref.mjs';
import { SUGGESTION_V2_FIELDS } from '../providers/registry.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
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}`); }
// ---------- 6.1 Le contrat expose bien channelRef ----------
expect(SUGGESTION_V2_FIELDS.includes('channelRef'), 'channelRef est déclaré dans le contrat v2');
ok('channelRef est un champ du contrat Suggestion v2');
// ---------- 6.1 Un scheme par provider, pour les 6 ----------
eq(Object.keys(CHANNEL_REF_SCHEMES).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt'], 'les 6 providers ont un scheme');
eq(CHANNEL_REF_SCHEMES, {
yt: 'yt-uc', dm: 'dm-user', tw: 'tw-login', pt: 'pt-composite', od: 'od-claim', ru: 'ru-slug',
}, 'les schemes sont ceux du plan §8.1');
ok('scheme unique et explicite pour les 6 providers');
// ---------- 8.2 (a) Les 3 conventions historiques, sans perte ----------
// (1) YouTube : `UC…`
const ytRef = buildChannelRef('yt', { channelExternalId: 'UCabcdefghijklmnopqrstuv' });
eq(ytRef, { provider: 'yt', scheme: 'yt-uc', value: 'UCabcdefghijklmnopqrstuv' }, 'yt : convention UC conservée');
eq(ytRef.value, 'UCabcdefghijklmnopqrstuv', 'yt : la valeur est bit-pour-bit identique');
ok('convention historique (1) YouTube `UC…` : sans perte');
// (2) PeerTube : `instance|channel`
const ptRef = buildChannelRef('pt', { url: 'https://framatube.org/videos/watch/abc', channelId: 'toto' });
eq(ptRef, { provider: 'pt', scheme: 'pt-composite', value: 'framatube.org|toto' }, 'pt : composite construit depuis l\'URL');
eq(parsePeerTubeComposite(ptRef.value), { instance: 'framatube.org', channel: 'toto' }, 'pt : le composite se redécoupe');
ok('convention historique (2) PeerTube `instance|channel` : sans perte');
// (3) Odysee : claim LBRY
eq(buildChannelRef('od', { uploaderName: '@MaChaine#a' }),
{ provider: 'od', scheme: 'od-claim', value: '@MaChaine#a' }, 'od : claim conservé tel quel');
// Forme SANS `@` (arrivait par d'autres chemins) : la valeur canonique porte le `@`.
eq(buildChannelRef('od', { uploaderName: 'MaChaine#a' }),
{ provider: 'od', scheme: 'od-claim', value: '@MaChaine#a' }, 'od : le @ manquant est restauré');
ok('convention historique (3) Odysee claim LBRY : sans perte, forme canonique unique');
// Odysee : les deux usages du claim (resolve vs URL) passent par la source unique.
eq(odyseeClaimToResolveArg('MaChaine#a'), '@MaChaine#a', 'od : argument de resolve préfixé @');
eq(odyseeClaimToResolveArg('@MaChaine#a'), '@MaChaine#a', 'od : déjà préfixé, inchange');
eq(odyseeClaimToSlug('@MaChaine#a'), 'MaChaine#a', 'od : slug d\'URL sans @');
eq(odyseeClaimToSlug('MaChaine#a'), 'MaChaine#a', 'od : slug d\'URL tolère l\'absence de @');
eq(normalizeOdyseeClaim(normalizeOdyseeClaim('x')), '@x', 'od : normalisation idempotente (pas de @@)');
ok('Odysee : un seul jeu de règles pour resolve et pour l\'URL');
// ---------- 8.2 (b) Parité stricte avec le front d'aujourd'hui ----------
// On rejoue ICI les règles historiques écrites dans les adaptateurs front avant
// la phase 6. Si le serveur produit autre chose, le front (qui préfère désormais
// `channelRef`) afficherait un lien de chaîne différent d'avant : régression.
function frontPtExternalId(it) {
let channelExternalId;
try {
const host = it?.url ? new URL(String(it.url)).hostname : '';
if (host && it?.channelId) channelExternalId = `${host}|${it.channelId}`;
} catch {}
return channelExternalId;
}
function frontOdExternalId(it) { return it.uploaderName || undefined; }
function frontYtExternalId(it) { return it.channelExternalId || it.channelId || undefined; }
function frontDmExternalId(it) { return it.channelId || undefined; }
function frontTwExternalId(it) { return it.channelExternalId || it.channelHandle || undefined; }
const fixtures = {
pt: [
{ url: 'https://framatube.org/videos/watch/abc', channelId: 'toto' },
{ url: 'https://tube.example.org/w/xyz', channelId: 'channel-42' },
{ url: 'https://miamutube.net/videos/watch/9', channelId: 'a.b_c' },
// Cas dégradés : pas d'URL, pas de channelId → les deux côtés doivent ne rien inventer.
{ url: 'https://x.org/w/1' },
{ channelId: 'orphelin' },
{ url: 'pas-une-url', channelId: 'c' },
{},
],
od: [
{ uploaderName: '@MaChaine#a' },
{ uploaderName: '@1234' },
{ uploaderName: '' },
{ uploaderName: undefined },
{},
],
yt: [
{ channelExternalId: 'UC1' },
{ channelId: 'UC2' },
{ channelExternalId: 'UC3', channelId: 'autre' },
{},
],
dm: [{ channelId: 'x1abc' }, { channelId: '42' }, {}],
tw: [{ channelExternalId: 'streamer' }, { channelHandle: 'zz' }, { channelExternalId: 'a', channelHandle: 'b' }, {}],
ru: [{ channelExternalId: 'some-channel' }, { channelId: 'slug2' }, {}],
};
const fronts = { pt: frontPtExternalId, od: frontOdExternalId, yt: frontYtExternalId, dm: frontDmExternalId, tw: frontTwExternalId, ru: frontYtExternalId };
for (const [pid, list] of Object.entries(fixtures)) {
for (const it of list) {
const ref = buildChannelRef(pid, it);
const front = fronts[pid](it);
if (!ref) {
eq(front ?? undefined, undefined, `${pid} : le front ne produisait rien, le serveur non plus (${JSON.stringify(it)})`);
continue;
}
// Odysee : le front d'hier renvoyait le claim BRUT. Il porte déjà `@` dans la
// réponse LBRY, donc la valeur canonique doit lui être identique.
expect(ref.value === front,
`${pid} : valeur serveur ≠ front pour ${JSON.stringify(it)} (serveur ${ref.value}, front ${front})`);
}
}
ok('pt / od / yt / dm / tw / ru : channelRef.value strictement identique au front d\'hier (33 fixtures)');
// Le cas dégénéré `od` sans `@` est le SEUL écart volontaire, et il est
// idempotent : une 2ᵉ passe ne change plus rien.
eq(buildChannelRef('od', { uploaderName: 'MaChaine#a' }).value, normalizeOdyseeClaim('MaChaine#a'),
'od : l\'écart se stabilise après normalisation');
ok('od : l\'écart de normalisation est stable (idempotent)');
// ---------- (c) Parité des schemes serveur <-> front ----------
const tsPath = path.join(__dirname, '../../src/app/shared/providers/channel-ref.ts');
const ts = fs.readFileSync(tsPath, 'utf8');
const tsBlock = ts.match(/CHANNEL_REF_SCHEMES[^=]*=\s*\{([\s\S]*?)\}/);
expect(!!tsBlock, 'le front déclare CHANNEL_REF_SCHEMES');
const tsSchemes = {};
for (const m of tsBlock[1].matchAll(/(\w+)\s*:\s*'([\w-]+)'/g)) tsSchemes[m[1]] = m[2];
eq(tsSchemes, { ...CHANNEL_REF_SCHEMES }, 'les schemes front et serveur sont identiques');
ok('parité serveur <-> front des schemes vérifiée sur la source TypeScript');
// Le type du contrat doit exposer les mêmes 6 schemes que l'implémentation.
const union = ts.match(/type ChannelRefScheme\s*=\s*([^;]+);/);
expect(!!union, 'ChannelRefScheme est une union de littéraux');
const unionSchemes = [...union[1].matchAll(/'([\w-]+)'/g)].map((m) => m[1]).sort();
eq(unionSchemes, Object.values(CHANNEL_REF_SCHEMES).sort(), 'l\'union TypeScript couvre les 6 schemes');
ok('l\'union TypeScript `ChannelRefScheme` couvre exactement les 6 schemes');
// ---------- (d) Aucune donnée inventée ----------
eq(buildChannelRef('yt', {}), undefined, 'aucun identifiant => pas de ref');
eq(buildChannelRef('yt', null), undefined, 'item null => pas de ref');
eq(buildChannelRef('xx', { channelId: 'a' }), undefined, 'provider inconnu => pas de ref');
eq(buildChannelRef('pt', { url: 'https://x.org/w/1' }), undefined, 'pt sans channelId => pas de ref composite bancal');
eq(buildChannelRef('od', { uploaderName: ' ' }), undefined, 'claim vide => pas de ref');
const noRef = { title: 'T', id: '1' };
withChannelRefs('yt', [noRef]);
eq(Object.keys(noRef), ['title', 'id'], 'aucune clé channelRef ajoutée quand il n\'y a rien à dire');
ok('rien n\'est inventé : pas de clé vide, pas de valeur devinée');
// ---------- withChannelRefs : pose le ref, ne casse rien ----------
const items = withChannelRefs('yt', [
{ id: 'a', channelExternalId: 'UCa' },
{ id: 'b' },
{ id: 'c', channelId: 'UCc' },
]);
eq(items[0].channelRef, { provider: 'yt', scheme: 'yt-uc', value: 'UCa' }, 'ref posé sur l\'item 1');
eq('channelRef' in items[1], false, 'item sans identité : aucune clé ajoutée');
eq(items[2].channelRef.value, 'UCc', 'ref posé depuis channelId');
eq(withChannelRefs('yt', null), null, 'withChannelRefs tolère un non-tableau');
ok('withChannelRefs annote sans muter la forme des items sans identité');
console.log(`\n channel-ref: ${passed} assertions OK`);