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