feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s

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.
This commit is contained in:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* Phase 8.1 — CONTRAT UNIQUE `ProviderAdapter`.
*
* Chaque provider expose LA MÊME forme : `{ id, label, search, suggest?,
* channelContent, channelMeta, capabilities }`. C'est le garde-fou du « un seul
* fichier par provider devient l'unique point de contact » : si quelqu'un ajoute
* un provider à la peine, ou casse un des 6 points de contact, la suite tombe.
*
* Aucun réseau n'est touché : la forme est vérifiée structurellement, et les
* membres appelables restent appelables (une recherche réelle se fait dans les
* suites des fixtures).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const { providerAdapters, providerRegistry, PROVIDER_CAPABILITIES, PROVIDER_IDS, getProviderAdapter } =
await import('../providers/registry.mjs');
const REQUIRED = ['id', 'label', 'search', 'channelContent', 'channelMeta', 'capabilities'];
describe('Phase 8.1 — contrat unique ProviderAdapter', () => {
it('les 6 providers sont présents et dans l\'ordre canonique', () => {
assert.deepEqual(Object.keys(providerAdapters).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt']);
assert.deepEqual(PROVIDER_IDS, ['yt', 'dm', 'tw', 'pt', 'od', 'ru']);
});
for (const pid of PROVIDER_IDS) {
it(`${pid}: forme complète du contrat`, () => {
const a = providerAdapters[pid];
assert.ok(a, 'adaptateur présent');
assert.equal(a.id, pid, 'id aligné');
assert.ok(String(a.label || '').length > 0, 'label non vide');
for (const key of REQUIRED) {
assert.ok(key in a, `clé requise \`${key}\` présente`);
}
assert.equal(typeof a.id, 'string', '`id` est une chaîne');
assert.equal(typeof a.label, 'string', '`label` est une chaîne');
assert.equal(typeof a.capabilities, 'object', '`capabilities` est un objet');
for (const member of ['search', 'channelContent', 'channelMeta']) {
assert.equal(typeof a[member], 'function', `\`${member}\` est appelable`);
}
if ('suggest' in a) {
assert.ok(a.suggest === undefined || typeof a.suggest === 'function', '`suggest` est fonction ou undefined');
}
if ('fetchChannelById' in a) {
assert.ok(a.fetchChannelById === undefined || typeof a.fetchChannelById === 'function',
'`fetchChannelById` (compat) est fonction ou undefined');
}
if ('suggest' in a && a.suggest !== undefined) {
assert.equal(typeof a.suggest, 'function', 'suggest est une fonction quand elle existe');
}
});
it(`${pid}: capabilities déclarées fidèles au comportement`, () => {
const a = providerAdapters[pid];
const caps = a.capabilities;
assert.equal(caps, PROVIDER_CAPABILITIES[pid], 'la même instance de capacités que la constante');
assert.equal(typeof caps.suggest, 'boolean', 'suggest: booléen');
assert.equal(typeof caps.live, 'boolean', 'live: booléen');
assert.equal(caps.channelMeta, true, 'chaque provider a des métadonnées de chaîne');
assert.ok(Array.isArray(caps.channelContent) && caps.channelContent.length > 0, 'liste de types de contenu non vide');
assert.ok(caps.channelContent.every((t) => ['videos', 'shorts', 'playlists', 'live'].includes(t)),
'types de contenu dans le vocabulaire connu');
assert.equal(caps.suggest, typeof a.suggest === 'function', 'suggest déclaré ⟺ suggest fourni');
assert.equal(caps.live, pid === 'tw', 'seul Twitch est live (fait observable)');
});
it(`${pid}: search du contrat EST la recherche enveloppée (cache → ref → provenance)`, () => {
// Le point de contact unifié doit servir la version DÉJÀ enveloppée par le
// registre (cache, channelRef, provenance) — pas une copie nue de module.
assert.equal(providerAdapters[pid].search, providerRegistry[pid].search,
'même fonction que le registre (les wrappers s\'appliquent en amont)');
});
it(`${pid}: channelContent et channelMeta sont câblés (appelables, distincts)`, () => {
const a = providerAdapters[pid];
assert.equal(typeof a.channelContent, 'function', 'channelContent appelable');
assert.equal(typeof a.channelMeta, 'function', 'channelMeta appelable');
assert.notEqual(a.channelContent, a.channelMeta, 'deux responsabilités distinctes');
// Le test de délégation se fait sans réseau : pour ru, ruContent passe par
// `ctx.searchRegistry` — on peut donc l'observer offline (cf. plus bas).
});
}
it('ru: channelContent délègue au registre de recherche via le contexte (offline)', async () => {
const a = providerAdapters.ru;
const fakeItem = { id: 'r1', title: 'Rumble seed', channelId: 'peerless-canal', uploaderName: 'peerless' };
const fakeRegistry = {
ru: { search: async () => [fakeItem, { id: 'r2', title: 'autre channel', channelId: 'autre' }] },
};
const out = await a.channelContent('peerless-canal', { type: 'videos', limit: 5 }, { searchRegistry: fakeRegistry });
assert.ok(Array.isArray(out.items), 'items normalisés');
assert.equal(out.items.length, 1, 'filtre par channelId canonique');
assert.equal(out.items[0].id, 'r1', 'l\'item retenu est le bon');
});
it('getProviderAdapter est l\'accès unique (et retombe sur le registre)', () => {
for (const pid of PROVIDER_IDS) {
assert.equal(getProviderAdapter(pid), providerAdapters[pid], `${pid} via getProviderAdapter`);
}
assert.equal(getProviderAdapter('nope'), undefined, 'provider inconnu : pas de crash, undefined propre');
});
});
+278
View File
@@ -0,0 +1,278 @@
/**
* Phase 7.7 — bannières et descriptions de chaîne, les 6 fournisseurs.
*
* Aucun réseau : `globalThis.fetch` est stubé. On vérifie que
* - chaque fournisseur expose `bannerUrl` et `description` quand la source les
* donne (aucun appel réseau supplémentaire — YT compte ses appels),
* - les URL non-http(s) sont rejetées (`javascript:` = vecteur XSS),
* - la description est bornée (600 caractères, whitespace normalisé),
* - les chaînes sans bannière restent `undefined` (on n'invente rien).
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
process.env.YOUTUBE_API_KEY = 'fake-key';
process.env.TWITCH_CLIENT_ID = 'fake-client';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-banners-'));
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'banners.db');
const { channelRegistry, setTwitchTokenProvider } = await import('../providers/channel-registry.mjs');
setTwitchTokenProvider(async () => 'fake-token');
const db = await import('../db.mjs');
const calls = [];
function fakeResponse(body, { text = false } = {}) {
return {
ok: true,
status: 200,
json: async () => body,
text: async () => (text ? body : JSON.stringify(body)),
};
}
function installFetch(routes) {
const prev = globalThis.fetch;
calls.length = 0;
globalThis.fetch = async (url, opts) => {
calls.push({ url: String(url), opts });
const match = routes.find(r => r.test(String(url)));
if (!match) throw new Error(`network_not_stubbed: ${url}`);
return match.respond(opts);
};
return () => { globalThis.fetch = prev; };
}
test('7.7 — YouTube : bannière et description depuis le payload existing', async (t) => {
const restore = installFetch([{
test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'),
respond: () => fakeResponse({
items: [{
snippet: {
title: 'YT User',
description: ' la belle description ',
thumbnails: { high: { url: 'https://img/avatar.png' } },
},
statistics: { subscriberCount: '42' },
brandingSettings: {
image: { bannerImageUrl: 'https://img/banner.png' },
channel: { customUrl: 'ytuser' },
},
}],
}),
}]);
t.after(restore);
const meta = await channelRegistry.yt.fetchChannelById('UCxxxx');
assert.equal(meta.bannerUrl, 'https://img/banner.png');
assert.equal(meta.description, 'la belle description');
// La bannière ne nécessite aucun appel de plus : un seul /channels.
assert.equal(calls.filter(c => c.url.includes('googleapis.com')).length, 1);
});
test('7.7 — YouTube : une URL non-http(s) est rejetée, pas propagee', async (t) => {
const restore = installFetch([{
test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'),
respond: () => fakeResponse({
items: [{
snippet: { title: 'X', thumbnails: {} },
brandingSettings: { image: { bannerImageUrl: 'javascript:alert(1)' } },
}],
}),
}]);
t.after(restore);
const meta = await channelRegistry.yt.fetchChannelById('UCxxxx');
assert.equal(meta.bannerUrl, undefined, 'javascript: ne doit jamais ressortir');
assert.equal(meta.description, undefined);
});
test('7.7 — YouTube : description démesurée bornée à 600 caractères', async (t) => {
const huge = 'x'.repeat(5000);
const restore = installFetch([{
test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'),
respond: () => fakeResponse({
items: [{
snippet: { title: 'X', description: huge, thumbnails: {} },
brandingSettings: {},
}],
}),
}]);
t.after(restore);
const meta = await channelRegistry.yt.fetchChannelById('UCxxxx');
assert.ok(meta.description.endsWith('…'), 'tronquée avec ellipse');
assert.equal(meta.description.length, 600);
});
test('7.7 — Dailymotion : cover_url + description', async (t) => {
const restore = installFetch([{
test: u => u.startsWith('https://api.dailymotion.com/user/'),
respond: () => fakeResponse({
username: 'dmuser',
screenname: 'DM User',
avatar_720_url: 'https://img/avatar.png',
cover_url: 'https://img/banner.png',
description: 'desc dm',
url: 'https://www.dailymotion.com/user/dmuser',
followers_total: 7,
verified: false,
}),
}]);
t.after(restore);
const meta = await channelRegistry.dm.fetchChannelById('dmuser');
assert.equal(meta.bannerUrl, 'https://img/banner.png');
assert.equal(meta.description, 'desc dm');
});
test('7.7 — Twitch : banner_image_url + description d\'Helix', async (t) => {
const restore = installFetch([{
test: u => u.startsWith('https://api.twitch.tv/helix/users?'),
respond: () => fakeResponse({
data: [{
display_name: 'TW User',
login: 'twuser',
profile_image_url: 'https://img/avatar.png',
banner_image_url: 'https://img/banner.png',
description: 'desc tw',
view_count: 12,
}],
}),
}]);
t.after(restore);
const meta = await channelRegistry.tw.fetchChannelById('twuser');
assert.equal(meta.bannerUrl, 'https://img/banner.png');
assert.equal(meta.description, 'desc tw');
assert.equal(calls[0].opts.headers['Authorization'], 'Bearer fake-token');
});
test('7.7 — Twitch : sans bannière Helix, rien d\'inventé', async (t) => {
const restore = installFetch([{
test: u => u.startsWith('https://api.twitch.tv/helix/users?'),
respond: () => fakeResponse({ data: [{ display_name: 'TW', login: 'twuser', profile_image_url: 'https://img/a.png' }] }),
}]);
t.after(restore);
const meta = await channelRegistry.tw.fetchChannelById('twuser');
assert.equal(meta.bannerUrl, undefined);
assert.equal(meta.description, undefined);
});
test('7.7 — PeerTube : banners (plus grande) + description', async (t) => {
const restore = installFetch([{
test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'),
respond: () => fakeResponse({
displayName: 'PT User',
name: 'chan',
host: 'peertube.example',
avatar: { path: '/a.png' },
banners: [{ path: '/b1.png' }, { path: '/banner.png' }],
description: 'desc pt',
url: 'https://peertube.example/video-channels/chan',
followersCount: 3,
ownerAccount: { verified: true },
}),
}]);
t.after(restore);
const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan');
assert.equal(meta.bannerUrl, 'https://peertube.example/banner.png', 'la plus grande bannière');
assert.equal(meta.description, 'desc pt');
});
test('7.7 — PeerTube : repli sur `banner` si `banners` est vide', async (t) => {
const restore = installFetch([{
test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'),
respond: () => fakeResponse({
displayName: 'PT User', name: 'chan', host: 'peertube.example',
avatar: { path: '/a.png' }, banners: [], banner: { path: '/old.png' },
}),
}]);
t.after(restore);
const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan');
assert.equal(meta.bannerUrl, 'https://peertube.example/old.png');
});
test('7.7 — Odysee : vignette élargie + description (pas de bannière LBRY)', async (t) => {
const restore = installFetch([{
test: u => u === 'https://api.na-backend.odysee.com/api/v1/proxy?m=resolve',
respond: () => fakeResponse({
result: {
'@chan': {
short_url: 'https://odysee.com/@chan',
thumbnail: { url: 'https://cdn.odysee.com/thumb' },
meta: { effective_amount: 1 },
value: {
title: 'OD User',
description: 'desc od',
thumbnail: { url: 'https://cdn.odysee.com/thumb' },
},
},
},
}),
}]);
t.after(restore);
const meta = await channelRegistry.od.fetchChannelById('@chan');
assert.equal(meta.bannerUrl, 'https://cdn.odysee.com/thumb?size=1200x600');
assert.equal(meta.description, 'desc od');
assert.equal(calls[0].opts.method, 'POST');
});
test('7.7 — Rumble : og:description (attributs dans any order) sans appel de plus', async (t) => {
const restore = installFetch([{
test: u => u === 'https://rumble.com/ruuser',
respond: () => fakeResponse(
'<meta property="og:image" content="https://img/avatar.png">'
+ '<meta content="desc ru" property="og:description">'
+ '<title>RU User on Rumble</title>',
{ text: true }
),
}]);
t.after(restore);
const meta = await channelRegistry.ru.fetchChannelById('ruuser');
assert.equal(meta.bannerUrl, undefined, 'pas de bannière Rumble dédiée');
assert.equal(meta.description, 'desc ru');
assert.equal(calls.length, 1, 'une seule requête HTML, pas de seconde pour le bandeau');
});
test('7.7 — ensureChannelFresh persiste bannière + description en base (survit au process)', async (t) => {
// Un « process B » qui ne passe plus par le fetch doit relire le bandeau et la
// description écrits par le premier appel (exactement le trajet des 6/HEURES Tm).
const first = await db.ensureChannelFresh('yt', 'UCpersist', async () => ({
title: 'Persisté',
avatarUrl: 'https://img/a.png',
bannerUrl: ' https://img/banner.png ',
description: ' une description ',
url: 'https://www.youtube.com/channel/UCpersist',
}));
assert.equal(first.bannerUrl, 'https://img/banner.png');
// Le stockage coupe les extrémités ; le repli des espaces internes est fait au
// niveau du fetch (`safeMeta`/cleanDescription), pas en base.
assert.equal(first.description, 'une description');
// Deuxième relevé dans le TTL : la ligne sert seule, sans re-fetch.
const second = await db.ensureChannelFresh('yt', 'UCpersist', async () => { throw new Error('offline'); });
assert.equal(second.bannerUrl, 'https://img/banner.png', 'le bandeau survit sans appel réseau');
assert.equal(second.description, 'une description');
});
test('7.7 — upsertChannelRow ne persiste jamais une URL non-http(s)', () => {
const meta = db.upsertChannelRow({
provider: 'yt', externalId: 'UCevil',
bannerUrl: 'javascript:alert(1)', description: 'ok',
});
assert.equal(meta.bannerUrl, null, 'javascript: est rejeté à la persistance');
assert.equal(meta.description, 'ok');
});
test.after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
+188
View File
@@ -0,0 +1,188 @@
// 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`);
+202
View File
@@ -0,0 +1,202 @@
/**
* Phase 8.2 / 8.3 — contrat `Suggestion` v2 strict + feature flags `FF_<PROVIDER>`.
*
* Le module front `src/app/search/search-contract.ts` est réellement *chargé*
* (transpilé à la volée via esbuild) au lieu d'être relu par expression
* régulière : une regex passerait au travers d'un bug de logique alors qu'elle
* voit bien les mots-clés, ce qui est exactement le défaut qu'on traque ici.
* esbuild est une dépendance d'@angular/build ; si elle disparaît, le test
* échoue bruyamment plutôt que de devenir vert par accident.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
providerFlag, isProviderDisabled, partitionEnabledProviders, applyProviderFlags,
} from '../providers/feature-flags.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
/** Charge un module TypeScript du front dans le contexte node (esbuild). */
async function loadTs(relPath) {
const abs = path.join(ROOT, relPath);
const esbuild = await import('esbuild');
const { code } = esbuild.transformSync(fs.readFileSync(abs, 'utf8'), {
loader: 'ts', format: 'esm', target: 'node20',
});
// `import type` est effacé par esbuild : le module n'a plus de dépendance.
const url = 'data:text/javascript;base64,' + Buffer.from(code, 'utf8').toString('base64');
return import(url);
}
const contract = await loadTs('src/app/search/search-contract.ts');
/** Capture les console.warn pendant l'appel à `fn`. */
async function captureWarnings(fn) {
const original = console.warn;
const lines = [];
console.warn = (...a) => { lines.push(a.map(String).join(' ')); };
try { return { value: await fn(), lines }; }
finally { console.warn = original; }
}
// --- 8.2 : contrat v2 strict ------------------------------------------------
test('8.2 — v2 est accepté sans avertissement', async () => {
contract.resetContractWarningsForTests();
const res = { v: 2, groups: { yt: [{ id: 'a' }] } };
const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'yt'));
assert.equal(value.legacyContract, false);
assert.equal(value.contractVersion, 2);
assert.equal(value.items.length, 1);
assert.deepEqual(lines, [], 'un contrat à jour ne doit rien écrire dans la console');
});
test('8.2 — v1 reste lisible (compat) mais est signalé', async () => {
contract.resetContractWarningsForTests();
const res = { v: 1, groups: { ru: [{ id: 'x' }, { id: 'y' }] } };
const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'ru'));
assert.equal(value.legacyContract, true, 'v1 doit être marqué legacy');
assert.equal(value.items.length, 2, 'la compat ne doit pas perdre de résultats');
assert.equal(lines.length, 1, 'un serveur obsolète doit être signalé');
assert.match(lines[0], /contrat v1/);
});
test('8.2 — version absente = legacy (le plus probable est un proxy mal configuré)', async () => {
contract.resetContractWarningsForTests();
const { value, lines } = await captureWarnings(() => contract.readSearchGroup({ groups: {} }, 'od'));
assert.equal(value.legacyContract, true);
assert.equal(value.contractVersion, null);
assert.match(lines[0], /contrat v\?/);
});
test('8.2 — l\'avertissement est émis une seule fois par process', async () => {
contract.resetContractWarningsForTests();
const first = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt'));
const second = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt'));
const third = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'dm'));
assert.equal(first.lines.length, 1);
assert.deepEqual(second.lines, [], 'une 2e recherche ne doit pas respammer');
assert.deepEqual(third.lines, [], 'le décompte est global, pas par provider');
});
test('8.2 — un groupe absent ou malformé donne un tableau vide, jamais un throw', async () => {
contract.resetContractWarningsForTests();
for (const res of [undefined, {}, { v: 2 }, { v: 2, groups: { yt: null } }, { v: 2, groups: { yt: 'nope' } }]) {
const r = contract.readSearchGroup(res, 'yt');
assert.deepEqual(r.items, [], `cas ${JSON.stringify(res)}`);
}
});
test('8.2 — l\'erreur serveur du provider est remontée', async () => {
contract.resetContractWarningsForTests();
const r = contract.readSearchGroup({ v: 2, groups: {}, errors: { pt: { message: 'upstream 503' } } }, 'pt');
assert.equal(r.providerError, 'upstream 503');
});
test('8.3 — un provider désactivé par FF n\'est PAS une erreur technique', async () => {
contract.resetContractWarningsForTests();
const r = contract.readSearchGroup(
{ v: 2, groups: {}, errors: { ru: { message: 'Provider désactivé par le feature flag FF_RU', code: 'disabled_by_ff' } } },
'ru',
);
assert.equal(r.providerError, undefined, 'sinon l\'UI afficherait une panne pour un arrêt volontaire');
});
// --- 8.3 : feature flags ----------------------------------------------------
const withEnv = (vars, fn) => {
const saved = {};
for (const k of Object.keys(vars)) {
saved[k] = process.env[k];
// `process.env[k] = undefined` stocke la chaîne « undefined » : il faut
// supprimer la clé pour truly simuler un flag absent.
if (vars[k] === undefined) delete process.env[k];
else process.env[k] = vars[k];
}
try { return fn(); } finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
}
};
test('8.3 — un flag absent laisse le provider actif (comportement historique)', () => {
withEnv({ FF_RU: undefined }, () => {
const f = providerFlag('ru');
assert.equal(f.enabled, true);
assert.equal(f.set, false, '`set` distingue « non configuré » de « désactivé »');
assert.equal(f.flag, 'FF_RU');
});
});
test('8.3 — les fausses valeurs usuelles désactivent le provider', () => {
for (const v of ['0', 'false', 'FALSE', 'off', 'no', 'disabled', ' 0 ', 'No']) {
withEnv({ FF_RU: v }, () => {
assert.equal(isProviderDisabled('ru'), true, `FF_RU=${JSON.stringify(v)} doit désactiver`);
});
}
});
test('8.3 — les vraies valeurs usuelles gardent le provider actif', () => {
for (const v of ['1', 'true', 'TRUE', 'on', 'yes', 'enabled']) {
withEnv({ FF_RU: v }, () => {
assert.equal(isProviderDisabled('ru'), false, `FF_RU=${JSON.stringify(v)} doit rester actif`);
});
}
});
test('8.3 — une variable vide est « non configurée », pas « désactivée »', () => {
// Piège classique en prod : un `FF_RU=` laissé vide dans un .env éteindrait
// silencieusement tout le provider. On privilégie le comportement historique.
withEnv({ FF_RU: ' ' }, () => {
const f = providerFlag('ru');
assert.equal(f.enabled, true);
assert.equal(f.set, false);
});
});
test('8.3 — les flags sont lus à chaque appel (pas de cache figé)', () => {
withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false));
withEnv({ FF_RU: '0' }, () => assert.equal(isProviderDisabled('ru'), true));
// Relu après bascule, sans redémarrage ni invalidation manuelle.
withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false));
});
test('8.3 — le flag est insensibilisé à la casse de l\'id provider', () => {
withEnv({ FF_RU: '0' }, () => {
for (const id of ['ru', 'RU', ' Ru ']) assert.equal(isProviderDisabled(id), true, `id=${id}`);
});
});
test('8.3 — partition : seuls les ids valides survivent, et on sait lesquels tombent', () => {
withEnv({ FF_RU: '0', FF_OD: '1' }, () => {
const { enabled, disabled } = partitionEnabledProviders(['yt', 'ru', 'od', 'dm']);
assert.deepEqual(enabled, ['yt', 'od', 'dm'], 'l\'ordre demandé est préservé');
assert.deepEqual(disabled, [{ provider: 'ru', flag: 'FF_RU' }]);
});
});
test('8.3 — applyProviderFlags produit une erreur `disabled_by_ff` explicable', () => {
withEnv({ FF_RU: 'off' }, () => {
const { providerIds, errors } = applyProviderFlags(['yt', 'ru', 'tw']);
assert.deepEqual(providerIds, ['yt', 'tw']);
assert.equal(errors.ru.code, 'disabled_by_ff');
assert.match(errors.ru.message, /FF_RU/, 'le message doit nommer le flag : c\'est la piste n°1 en prod');
assert.equal(errors.yt, undefined, 'un provider actif ne doit pas produire d\'erreur');
});
});
test('8.3 — entrée non tableau : dégradation sûre', () => {
withEnv({}, () => {
for (const bad of [undefined, null, 'yt', 42]) {
const { enabled, disabled } = partitionEnabledProviders(bad);
assert.deepEqual(enabled, []);
assert.deepEqual(disabled, []);
}
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Phase 8.3 (intégration) — le câblage HTTP des feature flags.
*
* Les tests unitaires de `feature-flags.mjs` prouvent la sémantique des flags ;
* ceux-ci prouvent qu'ils sont *branchés* : que le provider éteint n'est pas
* appelé, qu'il est signalé dans la réponse, et que le diagnostic de santé ne le
* sonde pas.
*
* Méthode : on démarre le serveur avec les SIX flags à `0`. Un fan-out normal
* appellerait six upstreams ; ici il n'y a donc **aucun accès réseau**, ce qui
* rend le test déterministe (et le prouve : voir l'assertion de durée).
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { spawn } from 'node:child_process';
const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-ff-'));
const PORT = await new Promise((resolve) => {
const s = net.createServer();
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
});
const base = `http://127.0.0.1:${PORT}`;
const server = spawn(process.execPath, ['./server/index.mjs'], {
cwd: path.resolve(import.meta.dirname, '..', '..'),
env: {
...process.env,
PORT: String(PORT),
NEWTUBE_DB_FILE: path.join(tmpDir, 'ff.db'),
JWT_SECRET: 'ff-test-secret',
NODE_ENV: 'test',
// Tous les providers éteints -> aucun appel upstream attendu.
...Object.fromEntries(ALL.map((p) => [`FF_${p.toUpperCase()}`, '0'])),
// Coupe aussi l'enrichissement web des suggestions (sinon : réseau).
SUGGEST_WEB_ENABLED: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let logs = '';
server.stdout.on('data', (d) => { logs += d; });
server.stderr.on('data', (d) => { logs += d; });
const J = async (url) => {
const r = await fetch(url);
return { status: r.status, body: await r.json().catch(() => ({})) };
};
describe('Phase 8.3 — feature flags câblés sur le HTTP', () => {
before(async () => {
const t0 = Date.now();
while (Date.now() - t0 < 25000) {
try { const r = await fetch(`${base}/api/health`); if (r.status < 500) return; } catch {}
await new Promise((r) => setTimeout(r, 300));
}
assert.fail(`API ne démarre pas:\n${logs.slice(-3000)}`);
});
after(() => {
server.kill();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
it('/api/search exclut les providers éteints du fan-out et les signale', async () => {
const t0 = Date.now();
const { status, body } = await J(`${base}/api/search?q=test&providers=${ALL.join(',')}`);
const elapsed = Date.now() - t0;
assert.equal(status, 200);
assert.equal(body.v, 2, 'la version de contrat reste annoncée');
assert.deepEqual(body.providers, [], 'aucun provider ne doit rester dans le fan-out');
for (const p of ALL) {
assert.deepEqual(body.groups[p], [], `${p} doit avoir un groupe vide et non absent`);
assert.equal(body.errors[p].code, 'disabled_by_ff', `${p} doit être signalé disabled_by_ff`);
assert.match(body.errors[p].message, new RegExp(`FF_${p.toUpperCase()}`), 'le message nomme le flag');
}
// Preuve indirecte qu'aucun upstream n'a été contacté : un fan-out réel
// part en parallèle sur 6 services et dépasse largement ce seuil.
assert.ok(elapsed < 2000, `réponse en ${elapsed}ms : un provider éteint est probablement encore appelé`);
});
it('un provider éteint est absent de `providers` mais garde sa colonne', async () => {
const { body } = await J(`${base}/api/search?q=test&providers=yt,ru,od`);
assert.deepEqual(body.providers, [], 'tous les providers demandés sont éteints dans ce test');
assert.ok('yt' in body.groups && 'ru' in body.groups && 'od' in body.groups,
'le front doit pouvoir afficher une colonne pour chacun des providers demandés');
});
it('/api/suggest applique les mêmes flags et signale la raison', async () => {
const { status, body } = await J(`${base}/api/search/suggest?q=test&providers=${ALL.join(',')}`);
assert.equal(status, 200);
assert.equal(body.errors.ru.code, 'disabled_by_ff');
for (const p of ALL) assert.deepEqual(body.groups[p], [], `${p} doit être vide`);
});
it('/api/providers/health distingue « éteint » de « en panne »', async () => {
const { body } = await J(`${base}/api/providers/health?provider=ru`);
const ru = body.providers.ru;
assert.equal(ru.disabled, true);
assert.equal(ru.flag, 'FF_RU');
assert.equal(ru.flagValue, '0');
assert.equal(ru.lastError, 'disabled_by_ff');
// Un provider éteint n'est pas « cassé » : ni erreur, ni latence mesurée.
assert.equal(ru.consecutiveFailures, 0);
assert.equal(ru.errorRate, 0);
assert.equal(ru.latencyMs, 0);
});
});
+263
View File
@@ -0,0 +1,263 @@
{
"_comment": "FIGÉ PAR `npm run fixtures:record`. Ne pas éditer à la main : régénérer.",
"_contractVersion": 2,
"_recordedAt": "2026-09-30T00:23:23.598Z",
"_query": "tutorial",
"providers": {
"yt": {
"items": [
{
"badges": [
"Nouveau",
"4K"
],
"channelExternalId": "UC9deX25Xus0U-Znt3_ILSpw",
"channelHandle": "360Jeezy",
"channelId": "UC9deX25Xus0U-Znt3_ILSpw",
"channelRef": {
"provider": "yt",
"scheme": "yt-uc",
"value": "UC9deX25Xus0U-Znt3_ILSpw"
},
"channelUrl": "https://www.youtube.com/channel/UC9deX25Xus0U-Znt3_ILSpw",
"duration": 678,
"id": "RzmfAJUqDh0",
"thumbnail": "https://i.ytimg.com/vi/RzmfAJUqDh0/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA0urkQ_b_2XIVuqpVA68sMKr1IIQ",
"title": "HAIRCUT TUTORIAL: WE CUT IT OFF! 360 WAVES | LOW TAPER on COARSE HAIR",
"type": "video",
"uploaderName": "360Jeezy",
"url": "https://www.youtube.com/watch?v=RzmfAJUqDh0",
"views": 6665
},
{
"channelExternalId": "UCp-oS-GBudIPdLrfV_EuSxQ",
"channelHandle": "Sabse Bada Maker",
"channelId": "UCp-oS-GBudIPdLrfV_EuSxQ",
"channelRef": {
"provider": "yt",
"scheme": "yt-uc",
"value": "UCp-oS-GBudIPdLrfV_EuSxQ"
},
"channelUrl": "https://www.youtube.com/channel/UCp-oS-GBudIPdLrfV_EuSxQ",
"duration": 12,
"id": "wAKZjllBB0w",
"thumbnail": "https://i.ytimg.com/vi/wAKZjllBB0w/hq720.jpg?sqp=-oaymwE2COgCEMoBSFXyq4qpAygIARUAAIhCGABwAcABBvABAfgBtgiAAoAPigIMCAAQARhbIF8oZTAP&rs=AOn4CLCISuErJFs1JV7EDlMTxHf1NfRNMw",
"title": "Crazy mobile videography editing tutorial 📱👀 #shorts #tutorial",
"type": "video",
"uploaderName": "Sabse Bada Maker",
"url": "https://www.youtube.com/watch?v=wAKZjllBB0w",
"views": 38214938
},
{
"badges": [
"4K"
],
"channelExternalId": "UCXs6kLLJ8mhYVbu3OlC3dow",
"channelHandle": "BAKAR EDITORIAL ",
"channelId": "UCXs6kLLJ8mhYVbu3OlC3dow",
"channelRef": {
"provider": "yt",
"scheme": "yt-uc",
"value": "UCXs6kLLJ8mhYVbu3OlC3dow"
},
"channelUrl": "https://www.youtube.com/channel/UCXs6kLLJ8mhYVbu3OlC3dow",
"duration": 17,
"id": "NI1iEBGNDb0",
"thumbnail": "https://i.ytimg.com/vi/NI1iEBGNDb0/hq720_2.jpg?sqp=-oaymwE2COgCEMoBSFXyq4qpAygIARUAAIhCGABwAcABBvABAfgBtgiAAu4LigIMCAAQARhbIFsoWzAP&rs=AOn4CLDIKfn0CDbxP3eX-zd6e9lzI6KogA",
"title": "Master Masking in CapCut Step-by-Step Editing Tutorial CapCut Explained Beginner to Pro Guide",
"type": "video",
"uploaderName": "BAKAR EDITORIAL ",
"url": "https://www.youtube.com/watch?v=NI1iEBGNDb0",
"views": 12428432
}
]
},
"dm": {
"items": [
{
"channelId": "x1avcry",
"channelRef": {
"provider": "dm",
"scheme": "dm-user",
"value": "x1avcry"
},
"duration": 106,
"height": 768,
"id": "x1067b5",
"thumbnail": "https://s2.dmcdn.net/v/3dmw11g41PTBKxtO0/x720",
"title": "Tutorial Wunsch Tutorial",
"type": "video",
"uploadedDate": "2013-05-24T11:11:56.000Z",
"uploaderAvatar": "https://s1.dmcdn.net/u/4iK1k1gYtdQ4tt6Uv/80x80",
"uploaderName": "diebestentutorials",
"url": "https://www.dailymotion.com/video/x1067b5",
"views": 14,
"width": 1024
},
{
"channelId": "x1xewsq",
"channelRef": {
"provider": "dm",
"scheme": "dm-user",
"value": "x1xewsq"
},
"duration": 99,
"height": 1040,
"id": "x6fsvsp",
"thumbnail": "https://s2.dmcdn.net/v/NDDRv1clhOzylBCZ-/x720",
"title": "Tutorial-Tutorial-คั่วกลิ้งหมูสับ",
"type": "video",
"uploadedDate": "2018-03-07T04:57:23.000Z",
"uploaderAvatar": "https://s1.dmcdn.net/u/6ymAA1gWyHxJ8up9q/80x80",
"uploaderName": "Nutthakit Boontrakan",
"url": "https://www.dailymotion.com/video/x6fsvsp",
"views": 8,
"width": 1040
},
{
"channelId": "x1kgnxy",
"channelRef": {
"provider": "dm",
"scheme": "dm-user",
"value": "x1kgnxy"
},
"duration": 98,
"height": 720,
"id": "x36s5pb",
"thumbnail": "https://s1.dmcdn.net/v/BVRtV1e9wmzeEIfvy/x720",
"title": "[TUTORIAL] Audacity Acapella Tutorial",
"type": "video",
"uploadedDate": "2015-09-13T22:23:02.000Z",
"uploaderAvatar": "https://s1.dmcdn.net/u/5fnKs1gYDjmqATtPF/80x80",
"uploaderName": "Acapellas",
"url": "https://www.dailymotion.com/video/x36s5pb",
"views": 4,
"width": 1280
}
]
},
"tw": {
"items": [],
"note": "aucun résultat au moment du gel — couverture non testée pour ce provider"
},
"pt": {
"items": [
{
"channelId": "stereo",
"channelRef": {
"provider": "pt",
"scheme": "pt-composite",
"value": "videoteca.kenobit.it|stereo"
},
"duration": 599,
"id": "33e18a40-9bb1-4ab2-8cc6-211cc088a85b",
"kind": "vod",
"likes": 2,
"publishedAt": "2024-02-02T19:35:59.534Z",
"thumbnail": "https://videoteca.kenobit.it/static/thumbnails/b612fafc-5b64-42ff-bc10-26d922a70162.jpg",
"title": "Tutorial per uploader: come si carica un disco su STEREO?",
"type": "video",
"uploaderAvatar": "/lazy-static/avatars/b0d2f3c7-bb5b-46f5-a3ca-dc2276630c89.png",
"uploaderName": "Kenobit",
"url": "https://videoteca.kenobit.it/videos/watch/33e18a40-9bb1-4ab2-8cc6-211cc088a85b",
"viewCount": 74,
"views": 74
},
{
"channelAvatarUrl": "/lazy-static/avatars/84119f2d-ae55-4a05-b882-5cc2166a9b62.jpg",
"channelId": "learning",
"channelRef": {
"provider": "pt",
"scheme": "pt-composite",
"value": "fediverse.tv|learning"
},
"duration": 170,
"id": "2149c7a9-5dc0-41b6-aa29-f19f88e20873",
"kind": "vod",
"language": "es",
"likes": 4,
"publishedAt": "2021-11-12T16:49:04.454Z",
"thumbnail": "https://fediverse.tv/lazy-static/thumbnails/2961e0bc-a9c9-4338-bdff-ac0fc326d827.jpg",
"title": "[TUTORIAL] - Como hacer un fedicorto",
"type": "video",
"uploaderAvatar": "/lazy-static/avatars/881f287d-1b9d-4609-8e75-93d75fdb7380.png",
"uploaderName": "spectrumgirl",
"url": "https://fediverse.tv/videos/watch/2149c7a9-5dc0-41b6-aa29-f19f88e20873",
"viewCount": 57,
"views": 57
},
{
"channelId": "51063_channel",
"channelRef": {
"provider": "pt",
"scheme": "pt-composite",
"value": "openmedia.edunova.it|51063_channel"
},
"duration": 705,
"id": "e01c50d1-1035-4dec-927b-828187bdef91",
"kind": "vod",
"language": "it",
"likes": 0,
"publishedAt": "2024-11-02T15:40:01.069Z",
"thumbnail": "https://openmedia.edunova.it/lazy-static/thumbnails/d266b279-aaeb-4b7a-8fd7-0d7f1cd996c7.jpg",
"title": "tutorial",
"type": "video",
"uploaderName": "MARINA DE ANGELIS",
"url": "https://openmedia.edunova.it/videos/watch/e01c50d1-1035-4dec-927b-828187bdef91",
"viewCount": 21,
"views": 21
}
]
},
"od": {
"items": [
{
"channelRef": {
"provider": "od",
"scheme": "od-claim",
"value": "@AllThingsSecured"
},
"duration": 527,
"id": "1c26fcfbd6e8b8acbee692b1d789e70c04d4366a",
"thumbnail": "https://thumbs.odycdn.com/11cd24cd9a5bff1d21c965d635a130c0.webp",
"title": "PGP Email Tutorial: How to Encrypt a Gmail Message",
"type": "video",
"uploaderName": "@AllThingsSecured",
"url": "https://odysee.com/PGP-Email-Tutorial_-How-to-Encrypt-a-Gmail-Message:1c26fcfbd6e8b8acbee692b1d789e70c04d4366a"
},
{
"channelRef": {
"provider": "od",
"scheme": "od-claim",
"value": "@pacheldestructor"
},
"duration": 398,
"id": "259776a24b1b646ba485fa9a2c3a9291503ead91",
"thumbnail": "https://thumbs.odycdn.com/219f40e4bdb4cd3b45c636d20757ce93.webp",
"title": "Tutorial Color Dino",
"type": "video",
"uploaderName": "@pacheldestructor",
"url": "https://odysee.com/Tutorial_Color_Dino:259776a24b1b646ba485fa9a2c3a9291503ead91"
},
{
"channelRef": {
"provider": "od",
"scheme": "od-claim",
"value": "@Cahlen"
},
"duration": 142,
"id": "2a248bebcfaed005a0f56afabf22f01e9e507b08",
"thumbnail": "https://thumbs.odycdn.com/bc956cd079fc5c30567f369728c79121.webp",
"title": "Plasma Machine Video Tutorial",
"type": "video",
"uploaderName": "@Cahlen",
"url": "https://odysee.com/CahlenLee_20260915_PlasmaMachineVideoTutorial:2a248bebcfaed005a0f56afabf22f01e9e507b08"
}
]
},
"ru": {
"items": [],
"note": "erreur au gel : rumble_unavailable"
}
}
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Phase 8.5 — documentation GÉNÉRÉE depuis les fixtures gelées.
*
* Produit la matrice « champ ↔ provider » à partir de ce que les adaptateurs
* émettent RÉELLEMENT, et l'injecte entre deux marqueurs dans
* `docs/ingestion-catalogue-video-par-fournisseur.md`.
*
* Raison d'être : une matrice écrite à la main devient fausse au premier
* adaptateur modifié, et personne ne le remarque — c'est le pire sort pour une
* doc de référence. Générée, elle ne peut qu'être à jour ou absente.
*
* La section générée est donc explicitement marquée comme telle, et `npm run
* test:shapes` échoue si elle n'est plus synchronisée (dérive = doc fausse).
*/
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(import.meta.dirname, '../..');
const FIXTURES = path.join(ROOT, 'server/tests/fixtures/provider-suggestions.json');
const DOC = path.join(ROOT, 'docs/ingestion-catalogue-video-par-fournisseur.md');
const BEGIN = '<!-- GENERATED:provider-matrix (npm run doc:providers) -->';
const END = '<!-- /GENERATED:provider-matrix -->';
const data = JSON.parse(fs.readFileSync(FIXTURES, 'utf8'));
const ids = Object.keys(data.providers);
const labels = { yt: 'YouTube', dm: 'Dailymotion', tw: 'Twitch', pt: 'PeerTube', od: 'Odysee', ru: 'Rumble' };
// Champs d'intérêt métier, dans un ordre de lecture stable (le tri
// alphabétique de `Object.keys` n'est pas l'ordre du contrat).
const FIELDS = [
['duration', 'durée', 'secondes'],
['views', 'vues', 'nombre'],
['likes', 'likes', 'nombre'],
['publishedAt', 'publication', 'date ISO'],
['thumbnail', 'vignette', 'URL'],
['uploaderName', 'chaîne', 'texte'],
['channelRef', 'identité chaîne', 'scheme + value'],
['type', 'type', 'video / live / short'],
['kind', 'kind', 'vod / live / clip / channel'],
['isLive', 'direct', 'booléen'],
['width', 'largeur', 'px'],
['height', 'hauteur', 'px'],
['language', 'langue', 'code'],
];
const mark = (present) => (present ? 'x' : '·');
const lines = [];
lines.push(BEGIN);
lines.push('');
lines.push(`> Section **générée** par \`npm run doc:providers\` depuis \`server/tests/fixtures/provider-suggestions.json\``);
lines.push(`> (gel du ${data._recordedAt.slice(0, 10)}, requête \`${data._query}\`). Ne pas éditer à la main.`);
lines.push('');
lines.push(`Legende : \`x\` = émis dans le gel, \`·\` = absent (donnée inconnue, donc \`undefined\` côté front).`);
lines.push('');
const head = ['Champ', 'Type', ...ids.map((id) => labels[id] || id)];
lines.push(`| ${head.join(' | ')} |`);
lines.push(`|${head.map(() => '---').join('|')}|`);
for (const [field, label, type] of FIELDS) {
const cells = ids.map((id) => {
const items = data.providers[id]?.items || [];
// Sur un provider sans fixture, on ne peut rien affirmer : ni x ni ·.
if (items.length === 0) return '?';
return mark(items.some((it) => it[field] !== undefined));
});
lines.push(`| \`${field}\` (${label}) | ${type} | ${cells.join(' | ')} |`);
}
lines.push('');
lines.push('Couverture du gel :');
lines.push('');
for (const id of ids) {
const entry = data.providers[id];
const n = entry.items.length;
lines.push(n
? `- **${labels[id] || id}** : ${n} item(s) vérifié(s).`
: `- **${labels[id] || id}** : *non couvert* — ${entry.note || 'aucune fixture'}.`);
}
lines.push('');
lines.push('(`?` = provider sans fixture au gel : la matrice ne prétend rien sur lui.)');
lines.push('');
lines.push(END);
const block = lines.join('\n');
const doc = fs.readFileSync(DOC, 'utf8');
if (doc.includes(BEGIN)) {
const from = doc.indexOf(BEGIN);
const to = doc.indexOf(END) + END.length;
fs.writeFileSync(DOC, doc.slice(0, from) + block + doc.slice(to));
console.log('matrice régénérée (remplacement)');
} else {
// Ancre : juste après le titre de la section correspondente.
const anchor = '## 4. Catalogue';
const at = doc.indexOf(anchor);
if (at < 0) throw new Error(`ancre « ${anchor} » introuvable dans ${path.basename(DOC)}`);
const insertAt = doc.indexOf('\n', at) + 1;
fs.writeFileSync(DOC, `${doc.slice(0, insertAt)}\n${block}\n${doc.slice(insertAt)}`);
console.log('matrice générée (insertion)');
}
console.log(`→ ${path.relative(process.cwd(), DOC)}`);
+105
View File
@@ -0,0 +1,105 @@
/**
* Validateur de forme d'une `Suggestion` — source unique.
*
* Partagé par `provider-contract.test.mjs` (invariants sur objets synthétiques)
* et `provider_shapes.test.mjs` (fixtures gelées issues des 6 adaptateurs
* réels). Deux définitions du contrat finiraient par diverger, et c'est
* précisément la divergence qu'on veut détecter.
*
* Règle centrale (phase 2) : une métadonnée absente est `undefined`, jamais `0`.
* Un `0` inventé s'affiche dans l'UI comme une donnée réelle.
*/
/** Champs textuels : chaîne non vide, ou absents. */
const STRING_FIELDS = [
'url', 'thumbnail', 'uploaderName', 'uploaderAvatar', 'channelId', 'slug',
'channelUrl', 'language', 'channelExternalId', 'channelHandle', 'channel',
'kind', 'game', 'durationRaw',
];
/**
* Champs numériques où 0 est impossible : un 0 y est un bug ou un mensonge.
* `duration: 0` rendait notamment la détection de Short inopérante
* (« verticale sans durée connue » ne doit pas être classée short).
*/
const STRICTLY_POSITIVE_FIELDS = ['duration', 'width', 'height'];
/**
* Champs de comptage où 0 est une VRAIE information : l'API peut legitimately
* répondre « 0 like » (PeerTube le fait). Traduire ce 0 en `undefined`
* confondrait « personne n'a aimé » avec « l'API n'a rien dit » — on perdrait
* l'information au lieu de la protéger.
*
* L'invariant protégé reste : ABSENT reste absent. Ce qui est interdit, c'est
* d'inventer un 0, pas de rencontrer un 0.
*/
const COUNT_FIELDS = ['views', 'likes', 'dislikes', 'viewers', 'viewCountRaw'];
const BOOLEAN_FIELDS = ['isShort', 'isLive', 'hasSubtitles', 'embeddable'];
/**
* Schemes d'identité de chaîne connus (phase 6).
*
* `yt-uc` et non `yt-channel` : c'est le scheme réellement émis par
* l'adaptateur YouTube (canal = identifiant `UC…`). Le nommer autrement
* donnerait l'illusion que le front sait le lire.
*/
const CHANNEL_REF_SCHEMES = new Set(['yt-uc', 'pt-composite', 'od-claim', 'tw-login', 'ru-slug', 'dm-user']);
/**
* Valide un objet `Suggestion`.
* @param {any} s
* @param {string} label contexte pour le message d'erreur
* @param {(msg: string) => void} expect
* @returns {string[]} messages d'erreur ([] si conforme)
*/
export function suggestionShapeErrors(s, label, expect) {
expect(typeof s?.title === 'string', `${label}: title is a string`);
expect(typeof s?.id === 'string' && s.id.length > 0, `${label}: id is a non-empty string`);
for (const f of STRICTLY_POSITIVE_FIELDS) {
if (s?.[f] !== undefined) {
expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`);
expect(s[f] > 0, `${label}: ${f} is strictly > 0 (never 0)`);
}
}
for (const f of COUNT_FIELDS) {
if (s?.[f] !== undefined) {
expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`);
expect(s[f] >= 0, `${label}: ${f} is >= 0 (an explicit 0 from the upstream is real data)`);
}
}
if (s?.publishedAt !== undefined) {
expect(typeof s.publishedAt === 'string', `${label}: publishedAt is a string`);
const t = Date.parse(s.publishedAt);
expect(Number.isFinite(t), `${label}: publishedAt is parseable as a date`);
// Piège historique : « 2 months ago » n'est pas une date.
expect(!/ago|il y a/i.test(s.publishedAt), `${label}: publishedAt is not a relative label`);
expect(t > 0 && t < 8.64e15, `${label}: publishedAt is in a sane range`);
}
for (const f of STRING_FIELDS) {
if (s?.[f] !== undefined) {
expect(typeof s[f] === 'string' && s[f].length > 0, `${label}: ${f} is a non-empty string when present`);
}
}
if (s?.tags !== undefined) {
expect(Array.isArray(s.tags), `${label}: tags is an array when present`);
}
for (const f of BOOLEAN_FIELDS) {
if (s?.[f] !== undefined) expect(typeof s[f] === 'boolean', `${label}: ${f} is a boolean when present`);
}
// Phase 6 : `channelRef` est additif, mais s'il est présent il doit être
// exploitable tel quel par le front. Forme réelle = objet `{ provider,
// scheme, value }` (et non une chaîne) : c'est ce que les 6 adaptateurs
// émettent, vérifié sur fixtures gelées.
if (s?.channelRef !== undefined) {
const ref = s.channelRef;
expect(ref && typeof ref === 'object' && !Array.isArray(ref), `${label}: channelRef est un objet`);
expect(typeof ref.provider === 'string' && ref.provider.length > 0, `${label}: channelRef.provider est une chaîne non vide`);
expect(typeof ref.scheme === 'string' && ref.scheme.length > 0, `${label}: channelRef.scheme est une chaîne non vide`);
expect(typeof ref.value === 'string' && ref.value.length > 0, `${label}: channelRef.value est une chaîne non vide`);
expect(CHANNEL_REF_SCHEMES.has(ref.scheme), `${label}: channelRef.scheme inconnu « ${ref.scheme} »`);
expect(!/\s/.test(ref.value), `${label}: channelRef.value sans espace`);
}
return [];
}
/** Schémas connus, exportés pour la génération de doc (phase 8.5). */
export { CHANNEL_REF_SCHEMES, STRING_FIELDS, STRICTLY_POSITIVE_FIELDS, COUNT_FIELDS, BOOLEAN_FIELDS };
+84
View File
@@ -0,0 +1,84 @@
/**
* Phase 3.12 — persistance des `pageToken` YouTube en base.
*
* Le test porte sur le comportement observable, pas sur l'implémentation :
* un jeton écrit par un « process A » doit être relisible par un « process B »
* qui ne partage aucun état mémoire. C'est exactement ce qui échouait avec la
* Map process-local — et ce que le redémarrage en production provocait.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-tokens-'));
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'tokens.db');
const db = await import('../db.mjs');
test('3.12 — un jeton écrit est relu par un autre process (L2 partagé)', () => {
const key = 'yt|UCtest|pl|24';
assert.equal(db.getCachedPageTokens(key), null, 'clé absente au départ');
assert.equal(db.setCachedPageTokens(key, [undefined, 'TOKEN_PAGE_2', 'TOKEN_PAGE_3']), true);
// On repart d'un cache mémoire vierge : c'est le cas d'un process neuf ou d'un
// pod différent. Seul le L2 peut répondre ici.
const reread = db.getCachedPageTokens(key);
assert.ok(Array.isArray(reread), 'le jeton doit survivre hors du cache mémoire');
// Indexation : `tokens[n]` = jeton pour la page n+2 (l'index 1 est donc la
// page 2). Un trou est matérialisé par `''`, jamais par un décalage : si le
// round-trip compressait le tableau, le jeton de la page 2 se retrouverait à
// l'index 0 et la page 3 renverrait celui de la page 4.
assert.equal(reread[0], '', 'le trou de l\'index 0 reste un trou, pas un décalage');
assert.equal(reread[1], 'TOKEN_PAGE_2', 'tokens[1] = jeton de la page 2');
assert.equal(reread[2], 'TOKEN_PAGE_3', 'tokens[2] = jeton de la page 3');
});
test('3.12 — un jeton absent reste absent (jamais de token inventé)', () => {
assert.equal(db.getCachedPageTokens('yt|UCinconnu|pl|24'), null);
});
test('3.12 — un jeton expiré n\'est pas resservi', () => {
const key = 'yt|UCexpire|pl|24';
db.setCachedPageTokens(key, ['TOK'], 1);
// TTL de 1 ms : la ligne est déjà hors délai au tour suivant.
assert.equal(db.getCachedPageTokens(key), null, 'un jeton expiré doit être traité comme absent');
// Et la ligne doit avoir été retirée, pas laissée traîner.
assert.equal(db.getCachedPageTokens(key), null);
});
test('3.12 — une écriture vide est refusée (pas de ligne inutile)', () => {
assert.equal(db.setCachedPageTokens('yt|vide|pl|24', []), false);
assert.equal(db.getCachedPageTokens('yt|vide|pl|24'), null);
});
test('3.12 — les jetons ne se confondent pas avec les résultats de recherche', () => {
// Namespace distinct : sinon le plafond par fournisseur et les stats de cache
// du fournisseur `yt` seraient faussés par des jetons qui ne sont pas des
// résultats.
const key = 'collision|1';
db.setCachedPageTokens(key, ['TOK_A']);
// `setCachedSearch` n'écrit que des tableaux d'items, jamais sous ce provider.
assert.equal(db.setCachedSearch('yt_tokens', key, 'q', ['item'], 'api'), true);
// Le même cache_key sous deux providers ne se chevauche pas : la lecture
// page_token et la lecture search restent distinctes.
assert.ok(db.getCachedPageTokens(key), 'le jeton reste lisible');
});
test('3.12 — une chaîne de jetons se complète sans écraser les pages précédentes', () => {
const key = 'yt|UCchain|pl|24';
// Page 1 écrite seule, puis page 2 : le tableau doit garder les deux.
db.setCachedPageTokens(key, ['TOK_PAGE_2']);
assert.equal(db.getCachedPageTokens(key)[0], 'TOK_PAGE_2');
db.setCachedPageTokens(key, ['TOK_PAGE_2', 'TOK_PAGE_3']);
const arr = db.getCachedPageTokens(key);
assert.equal(arr.length, 2);
assert.equal(arr[0], 'TOK_PAGE_2', 'la page 2 ne doit pas disparaître');
assert.equal(arr[1], 'TOK_PAGE_3');
});
test.after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Phase 7.3 — provenance des suggestions (`capturedAt` / `source`).
*
* Ce qui est réellement vérifié, c'est la HONNÊTETÉ de la fraîcheur :
* - un résultat vivant est estampillé à l'instant de la collecte ;
* - un résultat servi par le cache conserve l'instant du VRAI appel amont,
* pas l'heure de lecture du cache (sinon « à l'instant » sur du vieux data) ;
* - une entrée de cache écrite par un serveur antérieur à la phase 7.3 (donc
* sans provenance) récupère son `createdAt` en base.
*
* Aucun réseau : `globalThis.fetch` est stubé, et le provider testé est
* `dm` (Dailymotion), l'un de ceux enveloppés par le cache générique.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-prov-'));
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'prov.db');
const db = await import('../db.mjs');
const { providerRegistry, stampProvenance } = await import('../providers/registry.mjs');
const calls = [];
function installFetch() {
const prev = globalThis.fetch;
calls.length = 0;
globalThis.fetch = async (url) => {
calls.push(String(url));
return {
ok: true,
status: 200,
json: async () => ({
page: 1,
total: 1,
list: [{
id: 'x1dm',
created_time: 1700000000,
duration: 300,
views_total: 1234,
owner: { screenname: 'DM User', username: 'dmuser', avatar_720_url: 'https://img/a.png' },
title: 'Vidéo Dailymotion',
description: 'une description',
url: 'https://www.dailymotion.com/video/x1dm',
}],
}),
};
};
return () => { globalThis.fetch = prev; };
}
const dm = providerRegistry.dm;
const OPTS = { limit: 5, page: 1, sort: 'relevance' };
test('7.3 — un résultat vivant porte capturedAt + source', async (t) => {
const restore = installFetch();
t.after(restore);
const before = Date.now();
const items = await dm.search('provenance vivante', OPTS);
assert.ok(items.length > 0, 'la recherche stubée doit produire des résultats');
const at = Number(items[0].capturedAt);
assert.ok(Number.isFinite(at) && at >= before, `capturedAt epoch ms plausible (${at})`);
assert.ok(at <= Date.now(), 'capturedAt ne peut pas être dans le futur');
assert.equal(items[0].source, 'api', 'voie de collecte explicite');
// Provenance sur TOUS les items, pas seulement le premier.
assert.ok(items.every((i) => i.capturedAt && i.source), 'chaque item est estampillé');
});
test('7.3 — un hit de cache garde le capturedAt du VRAI appel amont', async (t) => {
const restore = installFetch();
t.after(restore);
const first = await dm.search('provenance cache', OPTS);
const firstAt = Number(first[0].capturedAt);
const networkCallsAfterFirst = calls.length;
assert.ok(networkCallsAfterFirst > 0, 'le premier appel est bien allé au réseau');
// Deuxième appel identique : servi par le cache, aucun nouvel appel amont.
const second = await dm.search('provenance cache', OPTS);
assert.equal(calls.length, networkCallsAfterFirst, 'aucun appel amont sur un hit de cache');
assert.equal(Number(second[0].capturedAt), firstAt,
'la fraîcheur affichée est celle de la DONNÉE, pas celle de la lecture du cache');
});
test('7.3 — une entrée de cache sans provenance récupère son createdAt', async (t) => {
const restore = installFetch();
t.after(restore);
// Simule une ligne écrite par un serveur antérieur à la phase 7.3 : payload
// sans `capturedAt`/`source`.
const q = 'provenance legacy';
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
const filterSig = '';
const key = `dm|${hashSearchKey(`${q.toLowerCase().trim()}|5|1|relevance|${filterSig}`)}`;
assert.equal(db.setCachedSearch('dm', key, q, [{ id: 'legacy1', title: 'vieux' }], 'api'), true);
const items = await dm.search(q, OPTS);
assert.equal(calls.length, 0, 'la ligne préexistante est servie sans réseau');
assert.equal(items[0].id, 'legacy1');
assert.ok(Number.isFinite(Number(items[0].capturedAt)) && Number(items[0].capturedAt) > 0,
'createdAt en base comble le champ manquant');
assert.equal(items[0].source, 'api', 'source de la ligne de cache');
});
test('7.3 — la voie réelle de l\'adaptateur n\'est jamais réécrite', () => {
// YouTube connaît sa propre voie (`innertube` / `scrape`) : la passe de
// provenance ne doit pas la surcharger avec la valeur par défaut `api`.
const items = stampProvenance([{ id: 'yt1', source: 'innertube', capturedAt: 1700000000000 }], 'api');
assert.equal(items[0].source, 'innertube', 'source existante conservée');
assert.equal(Number(items[0].capturedAt), 1700000000000, 'capturedAt existant conservé');
});
test('7.3 — hit de cache : source `cache` + instant de la lecture', () => {
const items = stampProvenance([{ id: 'a' }, { id: 'b' }], 'cache', 1700000000000);
assert.ok(items.every((i) => i.source === 'cache'), 'tous les items sont marqués');
assert.ok(items.every((i) => Number(i.capturedAt) === 1700000000000), 'instant appliqué à tous');
});
test('7.3 — payload invalide toléré (jamais de 0 ni de date bidon)', () => {
// `0` n'est pas une epoch ms, `''` n'est pas une voie de collecte : les deux
// doivent être remplacés, sinon l'IHB affiche « il y a 0 s » sur du vide.
const items = stampProvenance([
{ id: 'x', capturedAt: 0, source: '' },
{ id: 'y', capturedAt: -1, source: ' ' },
{ id: 'z', capturedAt: NaN },
], 'api', 1700000000000);
for (const it of items) {
assert.equal(Number(it.capturedAt), 1700000000000, `${it.id} : capturedAt replaced`);
assert.equal(it.source, 'api', `${it.id} : source replaced`);
}
});
test('7.3 — entrée vide / non tableau toléré', () => {
assert.deepEqual(stampProvenance([], 'api'), []);
assert.equal(stampProvenance(null, 'api'), null);
assert.equal(stampProvenance(undefined, 'api'), undefined);
});
test.after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
+165
View File
@@ -0,0 +1,165 @@
// 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})`);
+111
View File
@@ -0,0 +1,111 @@
/**
* 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`);
}
}
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Phase 8.4 — enregistreur de fixtures de contrat.
*
* Appelle les 6 adaptateurs **réels** et fige leur sortie dans
* `server/tests/fixtures/provider-suggestions.json`. Les fixtures sont donc
* générées, pas écrites à la main : elles reflètent ce que les providers
* produisent vraiment, y compris leurs champs bizarres.
*
* Réflexe : figer des objets inventés ne prouverait rien — on validerait sa
* propre imagination. Figer la sortie réelle prouve que le code d'aujourd'hui
* respecte le contrat, et le test rejoué à chaque PR détecte toute dérive.
*
* Usage : `npm run fixtures:record` (réseau requis).
* En CI : jamais exécuté. On vérifie seulement que les fixtures gelées sont
* toujours valides (`npm run test:shapes`).
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const OUT = path.resolve(import.meta.dirname, 'fixtures/provider-suggestions.json');
const QUERY = process.argv[2] || 'tutorial';
const LIMIT = 3;
// BASE DE DONNÉES JETABLE, définie AVANT tout import du registre.
//
// Deux raisons, toutes deux apprises à l'usage :
// 1. `search_cache` est persistant : sans base neuve, le gel rejoue des
// résultats d'un ancien gel et fige une forme PÉRIMÉE. C'est arrivé : le
// correctif de `language` PeerTube n'apparaissait pas dans les fixtures.
// 2. Le gel ne doit pas toucher `db/newtube.db` (ni y appliquer de migration).
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-record-'));
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'record.db');
const { providerRegistry } = await import('../providers/registry.mjs');
/** Champs parasites à ne pas figer : ils changent à chaque appel et ne disent rien du contrat. */
function normalize(item) {
const out = {};
// Tri des clés : deux enregistrements successifs donnent un diff lisible.
for (const k of Object.keys(item).sort()) {
if (item[k] === undefined) continue;
out[k] = item[k];
}
return out;
}
const recorded = {
_comment: 'FIGÉ PAR `npm run fixtures:record`. Ne pas éditer à la main : régénérer.',
_contractVersion: 2,
_recordedAt: new Date().toISOString(),
_query: QUERY,
providers: {},
};
let failures = 0;
for (const id of Object.keys(providerRegistry)) {
process.stdout.write(` ${id} … `);
try {
const items = await providerRegistry[id].search(QUERY, { limit: LIMIT, page: 1, sort: 'relevance' });
const list = (Array.isArray(items) ? items : []).map(normalize);
recorded.providers[id] = list.length
? { items: list }
// Upstream indisponible au moment du gel (Rumble/Cloudflare, Odysee hors
// ligne) : on le note au lieu d'écrire un tableau vide silencieux, pour
// que la couverture manquante reste visible dans le fichier.
: { items: [], note: 'aucun résultat au moment du gel — couverture non testée pour ce provider' };
console.log(`${list.length} item(s)${list.length ? '' : ' ⚠'}`);
if (!list.length) failures++;
} catch (e) {
recorded.providers[id] = { items: [], note: `erreur au gel : ${String(e?.message || e).slice(0, 160)}` };
console.log(`ERREUR ${e?.message || e}`);
failures++;
}
}
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, `${JSON.stringify(recorded, null, 2)}\n`);
console.log(`\n→ ${path.relative(process.cwd(), OUT)}`);
if (failures) {
console.log(`\n${failures} provider(s) sans fixture exploitable : la couverture du test de contrat est partielle.`);
}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
// Exit 1 : le gel a réussi mais est partiel (upstream HS). Le fichier est
// quand même écrit, pour que le trou de couverture soit visible dans le gel.
process.exit(failures ? 1 : 0);
+136
View File
@@ -0,0 +1,136 @@
import { parseJsonLd, parseRumbleViews, parseSearchHtml, resetRumbleNegativeCache } from '../providers/rumble.mjs';
import { parseRelativeDate } from '../providers/youtube-innertube.mjs';
import { itemDurationSec } from '../search-filters.mjs';
let pass = 0;
const eq = (a, b, m) => {
const x = JSON.stringify(a), y = JSON.stringify(b);
if (x !== y) throw new Error(`${m}\n expected ${y}\n got ${x}`);
pass++; console.log(` V ${m}`);
};
/**
* Comparaison d'horodatage calculé sur l'horloge VIVANTE : le test et le code
* appellent `Date.now()` à des instants distincts, donc une égalité à la
* milliseconde est une loterie (elle échouait 1 fois sur ~2, selon le tick).
* On vérifie l'ordre de grandeur : c'est ce que la fonction promet.
*/
const eqNear = (iso, expectedMs, toleranceMs, m) => {
const got = Date.parse(iso);
if (!Number.isFinite(got)) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()}\n got ${iso}`);
const delta = Math.abs(got - expectedMs);
if (delta > toleranceMs) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()} (±${toleranceMs}ms)\n got ${iso} (écart ${delta}ms)`);
pass++; console.log(` V ${m}`);
};
// --- parseRumbleViews (bug historique : "1,2K" -> 12) ---
eq(parseRumbleViews('1,2K views'), 1200, 'FR compact "1,2K" -> 1200 (was 12)');
eq(parseRumbleViews('1.2M views'), 1200000, 'EN compact "1.2M" -> 1 200 000');
eq(parseRumbleViews('3.4B views'), 3400000000, 'EN compact billions');
eq(parseRumbleViews('12,345 views'), 12345, '"12,345" -> thousands, not 12.345');
eq(parseRumbleViews('3 456'), 3456, 'space-separated thousands');
eq(parseRumbleViews('1 234 567'), 1234567, 'multi-group thousands');
eq(parseRumbleViews('1.5k'), 1500, 'lowercase k');
eq(parseRumbleViews('999'), 999, 'plain number');
eq(parseRumbleViews('vues indisponibles'), undefined, 'non-numeric -> undefined (never 0)');
eq(parseRumbleViews(''), undefined, 'empty -> undefined');
eq(parseRumbleViews(null), undefined, 'null -> undefined');
eq(parseRumbleViews('0 views'), undefined, 'zero -> undefined (phase 2.2: no zero counters)');
// --- DOM parsing (Phase 1.4/1.5/1.6) ---
const domHtml = `<html><body><ul>
<li class="video-listing-entry" data-id="v1-a">
<a class="video-item--a" href="/v1-a.html?e9s=track"></a>
<h3 class="video-item--title">Titre A</h3>
<div class="video-item--by-line">
<a class="video-item--channel-link" href="/c/myc/">
<img class="video-item--channel-thumb" src="//i.rumble.com/av.png">
<span class="ellipsis-1">Ma Chaîne</span></a></div>
<span class="video-item--views">1,2K views</span>
<time datetime="2026-02-01T10:00:00-05:00">il y a 1 mois</time>
<span class="video-item--duration" data-value="3:02"></span>
</li>
<li class="video-listing-entry" data-id="v2-b">
<a class="video-item--a" href="/v2-b.html"></a>
<h3 class="video-item--title">Titre B</h3>
<span class="ellipsis-1">Auteur B</span>
<span class="video-item--views">vues indisponibles</span>
</li>
</ul></body></html>`;
const dom = parseSearchHtml(domHtml, { limit: 10 });
eq(dom.length, 2, 'DOM: both cards parsed');
eq(dom[0].publishedAt, '2026-02-01T15:00:00.000Z', 'DOM: publishedAt from <time datetime> (not the "il y a 1 mois" label)');
eq(dom[0].channelId, 'myc', 'DOM: channelId extracted from /c/ (phase 1.5)');
eq(dom[0].uploaderAvatar, 'https://i.rumble.com/av.png', 'DOM: uploaderAvatar protocol-relative -> https (phase 1.6)');
eq(dom[0].duration, 182, 'DOM: duration from data-value');
eq(dom[0].views, 1200, 'DOM: views "1,2K" -> 1200');
eq(dom[0].url, 'https://rumble.com/v1-a.html', 'DOM: tracking params stripped from URL');
eq(dom[1].publishedAt, undefined, 'DOM: absent <time> -> publishedAt absent, not null/empty');
eq(dom[1].channelId, undefined, 'DOM: absent channel link -> channelId absent');
eq(dom[1].uploaderAvatar, undefined, 'DOM: absent avatar -> absent');
eq(dom[1].duration, undefined, 'DOM: absent duration -> absent (never 0)');
eq(dom[1].views, undefined, 'DOM: unparseable views -> absent (never 0)');
eq(parseSearchHtml('<ul></ul>').length, 0, 'DOM: empty listing -> empty array');
eq(parseSearchHtml('').length, 0, 'DOM: empty html -> empty array');
// --- JSON-LD (Phase 3.1) ---
const ldHtml = `<html><head>
<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org', '@type': 'ItemList',
itemListElement: [{
'@type': 'VideoObject', name: 'A', url: 'https://rumble.com/v1-a.html',
uploadDate: '2026-02-01T10:00:00Z', duration: 'PT3M2S',
thumbnailUrl: ['//i.rumble.com/a.jpg'],
interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: 1234 },
author: { '@type': 'Person', name: 'Chan', url: 'https://rumble.com/c/myc/' },
}],
})}</script></head><body>
<ul><li class="video-listing-entry" data-id="v1-a">
<a class="video-item--a" href="/v1-a.html?e9s=track"></a>
<h3 class="video-item--title">A</h3>
<div class="video-item--by-line">
<a class="video-item--channel-link" href="/c/myc/">
<img class="video-item--channel-thumb" src="//i.rumble.com/av.png">
<span class="ellipsis-1">Chan</span></a></div>
<span class="video-item--views">1,2K views</span>
<time datetime="2026-02-01T10:00:00-05:00">1 month ago</time>
<span class="video-item--duration" data-value="3:02"></span>
</li></ul></body></html>`;
const ld = parseJsonLd(ldHtml);
eq(ld.size, 1, 'JSON-LD: one VideoObject indexed out of the ItemList');
eq(ld.get('v1-a'), {
publishedAt: '2026-02-01T10:00:00.000Z',
views: 1234,
duration: 182,
thumbnail: 'https://i.rumble.com/a.jpg',
channelId: 'myc',
}, 'JSON-LD: date, views, duration, thumbnail, channelId extracted (phase 1.4/1.5/1.6)');
eq(parseJsonLd(`<script type="application/ld+json">{oops</script>`).size, 0, 'JSON-LD: malformed block is skipped, not thrown');
eq(parseJsonLd('').size, 0, 'JSON-LD: empty html -> empty map');
eq(parseJsonLd(`<script type="application/ld+json">${JSON.stringify({ '@type': 'WebPage', name: 'x' })}</script>`).size, 0,
'JSON-LD: non-video node ignored (no invented data)');
eq(parseJsonLd(`<script type="application/ld+json">${JSON.stringify({ '@graph': [{ '@type': 'VideoObject', url: 'https://rumble.com/g1.html' }] })}</script>`).get('g1'),
{}, 'JSON-LD: @graph supported, absent fields stay absent');
// --- parseRelativeDate (Phase 1.9) ---
eqNear(parseRelativeDate('il y a 2 semaines'), Date.now() - 2 * 6048e5, 2000, 'FR relative weeks (live clock)');
eq(parseRelativeDate('Streamed live 3 days ago') !== undefined, true, 'EN relative days');
eqNear(parseRelativeDate('il y a 8 mois'), Date.now() - 8 * 26298e6, 2000, 'FR relative months');
eq(parseRelativeDate('abonné'), undefined, 'unrelated label -> undefined (no invented date)');
// --- itemDurationSec (Phase 2.2) ---
eq(itemDurationSec({ duration: 0 }), undefined, 'duration 0 -> undefined');
eq(itemDurationSec({}), undefined, 'missing duration -> undefined');
eq(itemDurationSec({ duration: '3:02' }), 182, 'mm:ss still parsed');
eq(itemDurationSec({ duration: 0, width: 1080, height: 1920 }), undefined,
'zero duration does not leak into classification input');
// --- Rumble negative cache API is importable/clearable ---
resetRumbleNegativeCache();
pass++; console.log(' V negative cache is resettable (tests stay hermetic)');
console.log(`\n rumble-ld: ${pass} assertions OK`);
+177
View File
@@ -0,0 +1,177 @@
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const dir = mkdtempSync(join(tmpdir(), 'newtube-cache-'));
// `NEWTUBE_DB_FILE` DOIT etre defini AVANT l'import de db.mjs (lu au chargement).
// Avec le mauvais nom de variable, le test ecrivait dans la base de dev.
process.env.NEWTUBE_DB_FILE = join(dir, 'test.db');
process.env.SEARCH_CACHE_TTL_MS_DEFAULT = '5000';
process.env.YT_INNERTUBE_FAILOVER_COOLDOWN_MS = '60000';
let pass = 0;
const ok = (cond, m) => {
if (!cond) throw new Error(`ECHEC: ${m}`);
pass++; console.log(` V ${m}`);
};
const db = await import('../db.mjs');
const common = await import('../providers/youtube-common.mjs');
const { providerRegistry } = await import('../providers/registry.mjs');
const {
getCachedSearch, setCachedSearch, pruneSearchCache, countSearchCacheRows,
searchCacheStats, searchCacheTtlMs, migrateYoutubeCacheToSearchCache,
getCachedYoutubeSearch, setCachedYoutubeSearch,
incProviderMetrics, providerMetricsSnapshot, purgeProviderMetrics,
} = db;
const KEY = 'yt|abc123';
const items = [{ id: 'a', title: 'A' }];
// --- TTL par provider (4.1) ---
ok(searchCacheTtlMs('yt') === 30 * 60 * 1000, 'YT = 30 min (quota Data API)');
ok(searchCacheTtlMs('dm') === 5000, 'autre provider = SEARCH_CACHE_TTL_MS_DEFAULT');
ok(searchCacheTtlMs('od') === 5000, 'OD = default');
ok(searchCacheTtlMs('RU') === 5000, 'le provider est normalisé en minuscules');
// --- write / read ---
ok(setCachedSearch('dm', 'dm|k1', 'chat', items, 'api', 60000) === true, 'setCachedSearch ecrit');
ok(countSearchCacheRows('dm') === 1, 'countSearchCacheRows filtre par provider');
const hit = getCachedSearch('dm', 'dm|k1');
ok(hit && hit.items.length === 1 && hit.items[0].id === 'a', 'getCachedSearch relit le payload');
ok(hit.source === 'api', 'source conservee');
// --- hit_count (4.1) ---
ok(getCachedSearch('dm', 'dm|k1').hitCount === 2, 'hit_count incremente a chaque lecture');
ok(getCachedSearch('dm', 'dm|k1').hitCount === 3, 'hit_count continue de croitre');
const st = searchCacheStats().find((s) => s.provider === 'dm');
ok(st && st.hits === 3, 'searchCacheStats agrege les hits par provider');
ok(Math.abs(st.hitRate - 0.75) < 1e-9, 'hitRate = hits / (hits + entries) = 3/4');
ok(st.entries === 1, 'entries compte les lignes du provider');
// --- isolation des providers ---
ok(getCachedSearch('yt', 'dm|k1') === null, 'la cle ne fuite pas entre providers');
setCachedSearch('yt', KEY, 'x', items, 'innertube', 60000);
ok(getCachedSearch('yt', KEY) !== null && getCachedSearch('dm', 'dm|k1') !== null, 'les deux providers coexistent');
// --- jamais de resultat vide (4.1) ---
ok(setCachedSearch('dm', 'dm|empty', 'rien', [], 'api', 60000) === false, 'un tableau vide n est pas ecrit');
ok(getCachedSearch('dm', 'dm|empty') === null, 'aucune ligne pour un resultat vide');
ok(setCachedSearch('dm', 'dm|undef', 'rien', undefined, 'api', 60000) === false, 'undefined n est pas ecrit');
ok(setCachedSearch('dm', 'dm|obj', 'rien', { a: 1 }, 'api', 60000) === false, 'un non-tableau n est pas ecrit');
// --- expiration paresseuse ---
setCachedSearch('dm', 'dm|exp', 'vieux', items, 'api', -1);
ok(getCachedSearch('dm', 'dm|exp') === null, 'une entree expiree n est pas servie');
ok(getCachedSearch('dm', 'dm|exp') === null, 'la ligne expiree a ete purgee');
ok(getCachedSearch('dm', 'dm|exp') === null, 'relecture repurgee : idempotent');
// --- purge (4.5) ---
setCachedSearch('dm', 'dm|fresh', 'frais', items, 'api', 60000);
setCachedSearch('dm', 'dm|dead', 'mort', items, 'api', -1);
const purged = pruneSearchCache();
ok(purged >= 1, 'pruneSearchCache purge les lignes expirees');
ok(getCachedSearch('dm', 'dm|fresh') !== null, 'pruneSearchCache preserve les lignes valides');
ok(getCachedSearch('dm', 'dm|dead') === null, 'la ligne morte a bien disparu');
// --- Phase 6 : `channelRef` survit au cache (ordre des enveloppes) ---
// On plante une entree de cache SANS `channelRef` (comme celles ecrites par une
// version anterieure), puis on appelle le registre. Si l'annotation etait posee
// SOUS le cache, `search` renverrait l'item brut : aucun appel reseau n'est
// fait ici, donc c'est bien la lecture du cache qui est observee.
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
const q6 = 'preuve channelref';
const limit6 = 5;
const legacyItem = { id: 'x1', title: 'Legacy', channelId: 'x1abc' };
const cacheKey6 = `dm|${hashSearchKey(`${q6}|${limit6}|1|relevance|`)}`;
setCachedSearch('dm', cacheKey6, q6, [legacyItem], 'api', 60000);
const fromCache = await providerRegistry.dm.search(q6, { limit: limit6, page: 1 });
ok(Array.isArray(fromCache) && fromCache.length === 1, 'la lecture du cache a bien eu lieu (aucun appel reseau)');
ok(fromCache[0]?.id === 'x1', 'l\'item du cache est rendu tel quel');
ok(fromCache[0]?.channelRef?.value === 'x1abc' && fromCache[0]?.channelRef?.scheme === 'dm-user',
'phase 6 : channelRef annote aussi les items issus du cache');
pruneSearchCache({ cap: 1 });
// --- plafond par provider (4.1) ---
for (let i = 0; i < 12; i++) setCachedSearch('od', `od|k${i}`, `q${i}`, items, 'api', 60000 + i);
const capped = pruneSearchCache({ cap: 5 });
ok(countSearchCacheRows('od') <= 5, 'le plafond par provider est applique');
ok(getCachedSearch('od', 'od|k11') !== null, 'la ligne la plus fraiche survit au plafond');
ok(getCachedSearch('od', 'od|k0') === null, 'la plus ancienne est evincee');
ok(getCachedSearch('yt', KEY) !== null, 'le plafond ne touche pas les autres providers');
// --- migration YouTube (4.2) ---
ok(migrateYoutubeCacheToSearchCache().migrated === 0, 'migration sans donnee : 0 ligne');
const legacyWritten = setCachedYoutubeSearch(KEY, 'legacy', items, 'scrape', 60000);
ok(legacyWritten === undefined, 'setCachedYoutubeSearch ne retourne pas (API historique preservee)');
ok(getCachedYoutubeSearch(KEY)?.items?.[0]?.id === 'a', 'getCachedYoutubeSearch lit via search_cache');
// Cle distincte : force l'ecriture dans l'ancienne table uniquement.
setCachedSearch('yt', 'yt|def456', 'q', items, 'api', 60000);
ok(getCachedYoutubeSearch('yt|def456')?.items?.length === 1, 'repli: lecture via la table generique');
const m = migrateYoutubeCacheToSearchCache();
ok(typeof m.migrated === 'number', 'migrateYoutubeCacheToSearchCache retourne un rapport');
ok(m.legacyReadable === false, 'migration signale une base sans table legacy');
// --- metriques fournisseurs (4.3) ---
incProviderMetrics('dm', { ok: true, latencyMs: 120 });
incProviderMetrics('dm', { ok: true, latencyMs: 80 });
incProviderMetrics('dm', { ok: false, latencyMs: 300, error: 'boom' });
incProviderMetrics('tw', { ok: true, latencyMs: 50, fallback: true });
const snap = providerMetricsSnapshot({ hours: 1 });
const dmRow = snap.find((r) => r.provider === 'dm');
ok(dmRow.calls === 3 && dmRow.ok === 2 && dmRow.errors === 1, 'compteurs par provider agreges');
ok(Math.abs(dmRow.avgLatencyMs - 166) <= 1, 'avgLatencyMs = total / calls');
ok(Math.abs(dmRow.errorRate - 1 / 3) < 1e-9, 'errorRate = errors / calls');
ok(snap.find((r) => r.provider === 'tw').fallbacks === 1, 'les fallbacks sont comptes');
ok(purgeProviderMetrics() === 0, 'la purge de metriques ne supprime rien dans la fenetre');
// --- bascule automatique YouTube (4.4) ---
ok(common.getEffectiveSearchMode() === common.getSearchMode(), 'sans failover, mode effectif = mode configure');
ok(common.getFailoverState() === null, 'aucun failover au demarrage');
// Pas assez d'echantillons : 2 echecs ne doivent pas declencher la bascule.
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 2, errors: 2, errorRate: 1 }]);
ok(common.getFailoverState() === null, 'bascule ignoree sous 3 appels (trop peu d\'echantillons)');
// Taux sous le seuil.
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 10, errors: 1, errorRate: 0.1 }]);
ok(common.getFailoverState() === null, 'pas de bascule sous le seuil (10 %)');
// Depassement du seuil.
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 10, errors: 5, errorRate: 0.5 }]);
ok(common.getFailoverState() !== null, 'bascule declenchee au-dessus du seuil (50 %)');
ok(common.getEffectiveSearchMode() === 'scrape-first', 'le mode effectif devient scrape-first');
ok(common.getEffectiveSearchMode() !== common.getSearchMode(), 'le mode configure (intention) reste inchange');
// Un snapshot vide / invalide ne doit rien faire (et surtout ne pas lever).
ok(common.recordInnerTubeOutcome(true, []) !== undefined, 'snapshot vide tolere');
ok(common.recordInnerTubeOutcome(true, null) !== undefined, 'snapshot null tolere');
ok(common.recordInnerTubeOutcome(true, undefined) !== undefined, 'snapshot undefined tolere');
ok(common.getFailoverState() !== null, 'la bascule reste active : un snapshot vide ne la revoque pas');
// --- REGRESSION : le chemin de production alimente bien la bascule ---
// Les tests precedents injectaient un snapshot synthetique. En production, le
// snapshot vient de `provider_metrics`, alimente par `noteInnerTube()`.
// On rejoue donc la chaine REELLE : ecriture des echecs InnerTube -> snapshot
// base -> decision. Avant le correctif, aucune ecriture `yt` n'atteignait
// `provider_metrics`, `calls` restait a 0 et la bascule ne pouvait pas partir.
purgeProviderMetrics();
ok(!providerMetricsSnapshot({ hours: 1 }).some((r) => r.provider === 'yt'), 'aucune mesure yt au depart');for (let i = 0; i < 4; i++) incProviderMetrics('yt', { ok: false, latencyMs: 250, error: 'innertube_503' });
const realSnap = providerMetricsSnapshot({ hours: 1 });
const ytRow = realSnap.find((r) => r.provider === 'yt');
ok(!!ytRow, 'la production ecrit bien une ligne yt dans provider_metrics');
ok(ytRow && ytRow.calls === 4, 'les 4 tentatives sont comptees');
ok(ytRow && ytRow.errorRate === 1, 'taux d\'erreur mesure a partir des vraies ecritures');
ok(ytRow && ytRow.calls >= 3, 'le seuil d\'echantillons est atteint avec les vraies mesures');
common.recordInnerTubeOutcome(false, realSnap);
ok(common.getFailoverState() !== null, 'la bascule se declenche sur un snapshot issu de la production');
ok(common.getEffectiveSearchMode() === 'scrape-first', 'mode effectif force a scrape-first via le snapshot reel');
// Un mode api-first n'est pas InnerTube : la bascule ne doit pas l'ecraser.
process.env.YT_SEARCH_MODE = 'api-first';
ok(common.getEffectiveSearchMode() === 'api-first', 'la bascule n\'ecrase pas un mode sans InnerTube');
process.env.YT_SEARCH_MODE = 'innertube-first';
ok(common.getEffectiveSearchMode() === 'scrape-first', 'mode innertube-first bien remplace');
process.env.YT_SEARCH_MODE = 'api-only';
ok(common.getEffectiveSearchMode() === 'api-only', 'api-only non InnerTube : inchange');
delete process.env.YT_SEARCH_MODE;
// Nettoyage best-effort : better-sqlite3 garde le fichier ouvert sous Windows,
// la suppression peut echouer (EBUSY) sans que le test soit invalide.
try { rmSync(dir, { recursive: true, force: true }); } catch {}
console.log(`\n search-cache: ${pass} assertions OK`);
+6 -2
View File
@@ -60,9 +60,13 @@ logOk('periodMs / durationBounds');
// ---------- Lectures de métadonnées ----------
eq(itemDurationSec({ duration: 120 }), 120, 'numeric duration');
eq(itemDurationSec({ duration: 'PT1H2M' }), 0, 'ISO duration ignored (not a field format)');
// Phase 2.2 : une durée illisible vaut `undefined` (« inconnue »), plus 0.
// 0 faisait passer une vidéo sans info pour une vidéo de 0 s : affichage "0:00"
// et règle « verticale sans durée => pas un short » neutralisée.
eq(itemDurationSec({ duration: 'PT1H2M' }), undefined, 'ISO duration ignored (not a field format) -> undefined');
eq(itemDurationSec({ duration: '1:02:03' }), 3723, 'hh:mm:ss duration parsed');
eq(itemDurationSec({}), 0, 'missing duration -> 0');
eq(itemDurationSec({}), undefined, 'missing duration -> undefined');
eq(itemDurationSec({ duration: 0 }), undefined, 'explicit 0 -> undefined (not a 0 s video)');
expect(itemPublishedTs({ publishedAt: new Date().toISOString() }) > 0, 'ISO publishedAt parsed');
expect(itemPublishedTs({ timestamp: 1700000000 }) === 1700000000000, 'yt-dlp seconds timestamps up-scaled');
eq(itemPublishedTs({}), 0, 'missing date -> 0');
+110
View File
@@ -0,0 +1,110 @@
/**
* Phase 7.3 — mode debug `?debug=1` sur `/api/search`.
*
* Ce mode est une barrière de sécurité autant qu'un outil : un `raw` de provider
* peut contenir une clé d'API, et un payload de debug finit souvent dans les
* logs de CI. Les tests ci-dessous prouvent donc que les garde-fous TIENNENT :
* troncature à 600 caractères, 2 items par provider, redaction des secrets
* (y compris imbriqués dans des tableaux), et aucune fuite quand le `raw` est
* circulaire ou non sérialisable.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDebugPayload, redactDebugSecrets, DEBUG_RAW_MAX_CHARS } from '../search-transport.mjs';
const AT = 1700000000000;
test('7.3 — le payload reste borné (2 items/provider) et compte le total', () => {
const items = Array.from({ length: 7 }, (_, i) => ({ id: `i${i}`, title: `t${i}`, source: 'api', capturedAt: AT }));
const dbg = buildDebugPayload({ yt: items }, {});
assert.equal(dbg.yt.count, 7, 'le total reste visible même tronqué');
assert.equal(dbg.yt.items.length, 2);
assert.equal(dbg.yt.capturedAt, AT, 'provenance du provider agrégée');
assert.equal(dbg.yt.source, 'api');
});
test('7.3 — les champs mappés sont nommés (pas seulement comptés)', () => {
const dbg = buildDebugPayload({ dm: [{ id: 'x', title: 'T', views: 12, capturedAt: AT, source: 'api', channelRef: { provider: 'dm' } }] }, {});
const fields = dbg.dm.items[0].fields;
assert.deepEqual(fields, ['channelRef', 'title', 'views'], 'noms triés, hors provenance');
assert.equal(dbg.dm.items[0].id, 'x');
assert.equal(dbg.dm.items[0].capturedAt, AT);
});
test('7.3 — un champ absent reste absent dans le debug', () => {
// `views` n'est pas là : c'est l'information à ne pas maquiller en `null`.
const dbg = buildDebugPayload({ tw: [{ id: 'a', title: 'T', source: 'api', capturedAt: AT }] }, {});
assert.equal(dbg.tw.items[0].fields.includes('views'), false);
assert.equal(dbg.tw.items[0].raw, undefined, 'pas de `raw` => pas de champ `raw` dans le payload');
});
test('7.3 — le `raw` est tronqué à 600 caractères', () => {
const raw = { payload: 'x'.repeat(5000) };
const dbg = buildDebugPayload({ pt: [{ id: 'p', source: 'api', capturedAt: AT, raw }] }, {});
const shown = dbg.pt.items[0].raw;
assert.ok(shown.startsWith('{"payload":"xxx'), 'le début du JSON est visible');
assert.ok(shown.includes('… ('), 'la troncature est signalée');
assert.ok(shown.includes('car.)'), 'la taille réelle est annoncée');
// 600 caractères de JSON + la mention de troncature.
assert.ok(shown.length < DEBUG_RAW_MAX_CHARS + 60, 'la sortie reste courte');
assert.ok(shown.length > DEBUG_RAW_MAX_CHARS, 'la troncature a bien eu lieu');
});
test('7.3 — un `raw` court est rendu tel quel', () => {
const dbg = buildDebugPayload({ od: [{ id: 'o', source: 'api', capturedAt: AT, raw: { title: 'T' } }] }, {});
assert.equal(dbg.od.items[0].raw, '{"title":"T"}');
});
test('7.3 — redaction des secrets, y compris imbriqués', () => {
const redacted = redactDebugSecrets({
apiKey: 'AKIA-REAL',
api_key: 'x',
authorization: 'Bearer REAL',
token: 'REAL',
cookie: 'sid=REAL',
password: 'REAL',
clientSecret: 'REAL',
nested: { accessToken: 'REAL', keep: 'visible', list: [{ refresh_token: 'REAL' }] },
// Une clé qui CONTIENT « token » mais n'est pas un secret doit survivre.
tokenCount: 3,
});
const json = JSON.stringify(redacted);
assert.equal(json.includes('REAL'), false, `aucun secret ne doit subsister : ${json}`);
assert.equal(redacted.nested.keep, 'visible', 'les données utiles sont conservées');
assert.equal(redacted.tokenCount, 3, 'une clé non sensible n\'est pas censurée à tort');
assert.equal(redacted.list, undefined);
assert.equal(redacted.nested.list[0].refresh_token, '[redacted]', 'redaction dans un tableau imbriqué');
});
test('7.3 — la redaction traverse le `raw` avant sérialisation', () => {
const dbg = buildDebugPayload({ ru: [{ id: 'r', source: 'api', capturedAt: AT, raw: { authorization: 'Bearer LEAK', ok: 1 } }] }, {});
assert.equal(dbg.ru.items[0].raw.includes('LEAK'), false, 'le secret ne doit pas apparaître dans le JSON renvoyé');
assert.ok(dbg.ru.items[0].raw.includes('[redacted]'));
});
test('7.3 — un `raw` circulaire ne fait pas exploser le diagnostic', () => {
const raw = { name: 'loop' };
raw.self = raw;
const dbg = buildDebugPayload({ yt: [{ id: 'y', source: 'api', capturedAt: AT, raw }] }, {});
assert.equal(dbg.yt.items[0].raw, '[unserialisable]', 'on degrade au lieu de lever');
});
test('7.3 — erreurs de provider remontées, sans division par zéro', () => {
const dbg = buildDebugPayload(
{ yt: [], dm: [{ id: 'a', source: 'api', capturedAt: AT }] },
{ yt: { message: 'HTTP 503 innertube' } },
);
assert.equal(dbg.yt.error, 'HTTP 503 innertube', 'le message d\'erreur aide au diagnostic');
assert.equal(dbg.yt.capturedAt, null, 'aucun item => provenance inconnue');
assert.equal(dbg.dm.error, null);
});
test('7.3 — entrées dégénérées tolérées', () => {
const dbg = buildDebugPayload({ yt: null, dm: [null, 'texte', 42] }, null);
assert.equal(dbg.yt.count, 0);
assert.deepEqual(dbg.yt.items, []);
assert.equal(dbg.dm.items[0].value, null);
assert.equal(dbg.dm.items[1].value, 'texte');
assert.equal(dbg.dm.count, 3, 'le total reste malgré la troncature à 2');
assert.deepEqual(buildDebugPayload(undefined, undefined), {});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Phase 7.6 — transport NDJSON de `/api/search`.
*
* Deux modes pour un SEUL fan-out : la route doit produire soit une réponse
* atomique (aujourd'hui), soit un flux d'une ligne JSON par provider + une
* ligne `done`. Les erreurs de ligne (parse JSON cassé) ici sont le miroir
* exact de ce que le client frontend lirait sans parseur SSE.
*
* Démarrage comme `feature_flags_http.test.mjs` : les SIX flags à `0`, donc
* aucun appel upstream — parfaitement déterministe, et le flux sans provider
* reste une preuve de la FORME du transport (séquences de lignes, clôture,
* types de contenu, contrat). Le réseau ne teste rien ici : il polluerait le
* test avec des delais étrangers au protocole.
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { spawn } from 'node:child_process';
const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-stream-'));
const PORT = await new Promise((resolve) => {
const s = net.createServer();
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
});
const base = `http://127.0.0.1:${PORT}`;
const server = spawn(process.execPath, ['./server/index.mjs'], {
cwd: path.resolve(import.meta.dirname, '..', '..'),
env: {
...process.env,
PORT: String(PORT),
NEWTUBE_DB_FILE: path.join(tmpDir, 'stream.db'),
JWT_SECRET: 'stream-test-secret',
NODE_ENV: 'test',
...Object.fromEntries(ALL.map((p) => [`FF_${p.toUpperCase()}`, '0'])),
SUGGEST_WEB_ENABLED: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let ready = false;
server.stdout.on('data', (d) => {
if (String(d).includes('listening')) ready = true;
});
// Ne pas laisser stdout du serveur polluer l'output du test.
server.stdout.resume();
server.stderr.resume();
async function whenReady(timeoutMs = 20_000) {
const t0 = Date.now();
while (!ready) {
// Sondage HTTP en plus du texte stdout : plus fiable (et prouve au passage
// que le serveur répond). `NODE_ENV=test` + flags éteints => aucune requête
// amont, le 200 est garanti sans réseau.
if (Date.now() - t0 > timeoutMs) throw new Error('serveur de test non démarré');
try {
const r = await fetch(`${base}/api/search?q=zz`);
if (r.status === 200) ready = true;
} catch {}
if (!ready) await new Promise((r) => setTimeout(r, 100));
}
}
describe('Phase 7.6 — transport NDJSON de /api/search', () => {
before(async () => { await whenReady(); });
after(async () => {
// Windows : `kill()` est asynchrone, la base est encore verrouillée quand
// on revient. On attend la SORTIE du process avant de vider le dossier.
const exited = new Promise((resolve) => server.once('exit', resolve));
server.kill();
await exited;
await new Promise((r) => setTimeout(r, 100));
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('répond en lignes NDJSON quand le client le demande', async () => {
const res = await fetch(`${base}/api/search?q=test`, {
headers: { Accept: 'application/x-ndjson' },
});
assert.equal(res.status, 200);
const ct = res.headers.get('content-type') || '';
assert.ok(ct.includes('application/x-ndjson'), `media type négocié (got "${ct}")`);
assert.equal(res.headers.get('cache-control'), 'no-store', 'jamais mis en cache');
const body = await res.text();
const lines = body.split('\n').filter((l) => l.trim().length > 0);
// Flags éteints => AUCUN provider => pas de ligne `provider`, une seule
// ligne `done`. C'est un cas limite du protocole, pas un bug : le front
// attend <done> pour conclure, pas un nombre précis de lignes.
assert.ok(lines.length >= 1, `au moins la ligne done (${lines.length})`);
for (const [i, line] of lines.entries()) {
assert.ok(line.startsWith('{'), `ligne ${i} est du JSON`);
const parsed = JSON.parse(line); // stabilité du protocole : une erreur ici est un échec
assert.ok(parsed.type === 'provider' || parsed.type === 'done', `ligne ${i} typée`);
}
const done = lines.map(JSON.parse).find((l) => l.type === 'done');
assert.ok(done, 'une ligne done clôture le flux');
assert.equal(done.q, 'test');
assert.deepEqual(done.providers, [], '6 flags éteints => aucun provider');
assert.equal(done.v, 2, 'le contrat v2 est annoncé aussi en flux');
assert.ok(typeof done.page === 'number');
assert.ok(typeof done.pageSize === 'number');
});
it('`?stream=1` fonctionne comme l\'en-tête Accept', async () => {
const res = await fetch(`${base}/api/search?q=test&stream=1`);
const lines = (await res.text()).split('\n').filter((l) => l.trim());
assert.ok(lines.length >= 1, 'flux remis');
assert.ok(lines.every((l) => l.startsWith('{')), 'lignes JSON');
});
it('sans NDJSON, la réponse atomique historique est préservée', async () => {
const res = await fetch(`${base}/api/search?q=test`);
const body = await res.json();
assert.ok(!('type' in body), 'pas de ligne NDJSON dans le mode atomique');
assert.equal(body.v, 2, 'contrat v2');
// Flags éteints => colonnes présentes mais vides (comportement 8.3 : une
// pastille « aucun résultat » peut dire pourquoi, au lieu d'un blank).
assert.deepEqual(Object.keys(body.groups).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt']);
assert.ok(Object.values(body.groups).every((g) => Array.isArray(g) && g.length === 0), 'colonnes vides');
assert.ok(Array.isArray(body.providers));
assert.equal(typeof body.filters, 'object');
});
it('la requête invalide reste 400, dans les deux transports', async () => {
const atomic = await fetch(`${base}/api/search?q=`);
assert.equal(atomic.status, 400);
const ndjson = await fetch(`${base}/api/search?q=`, { headers: { Accept: 'application/x-ndjson' } });
assert.equal(ndjson.status, 400);
});
it('répond vite même sans réseau (flags éteints => zéro appel amont)', async () => {
const t0 = Date.now();
const res = await fetch(`${base}/api/search?q=test`, { headers: { Accept: 'application/x-ndjson' } });
await res.text();
assert.ok(Date.now() - t0 < 3000, 'aucun (faux) délai upstream dans le test');
});
});
@@ -0,0 +1,125 @@
/**
* Phase 7.6 — lignes `provider` du flux NDJSON avec un vrai provider répondant.
*
* `search_stream.test.mjs` prouve la FORME du transport (flags éteints). Ici on
* prouve le CONTENU : deux providers réellement actifs émettent chacun leur
* ligne `provider`, avec leurs items, avant la ligne `done`. Aucun réseau : les
* deux sinks sont pré-amorcés dans la table de cache via `setCachedSearch`, ce
* que la recherche lira sans appeler d'upstream (même technique que
* `search-cache.test.mjs`).
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { spawn } from 'node:child_process';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-streamprov-'));
const DB = path.join(tmpDir, 'prov.db');
// Base définie AVANT tout import de db : elle est lue au chargement. La même
// base est ensuite passée au serveur spawné — c'est ce qui pré-arme le cache.
process.env.NEWTUBE_DB_FILE = DB;
process.env.SEARCH_CACHE_TTL_MS_DEFAULT = '60000';
const { setCachedSearch } = await import('../db.mjs');
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
const { parseSearchFilters } = await import('../search-filters.mjs');
const PORT = await new Promise((resolve) => {
const s = net.createServer();
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
});
const base = `http://127.0.0.1:${PORT}`;
// --- Pré-arme le cache des deux providers avec la même signature que la route ---
const QUERY = 'streaming progressif';
const PAGE_SIZE = 5;
const FILTER_SIG = JSON.stringify(parseSearchFilters({}), Object.keys(parseSearchFilters({})).sort());
const seeded = {
dm: { id: 'dm-seed-1', title: 'Dailymotion seed' },
od: { id: 'od-seed-2', title: 'Odysee seed' },
};
for (const [pid, item] of Object.entries(seeded)) {
const key = `${pid}|${hashSearchKey(`${QUERY}|${PAGE_SIZE}|1|relevance|${FILTER_SIG}`)}`;
setCachedSearch(pid, key, QUERY, [item], 'api', 60000);
}
const server = spawn(process.execPath, ['./server/index.mjs'], {
cwd: path.resolve(import.meta.dirname, '..', '..'),
env: {
...process.env,
PORT: String(PORT),
NEWTUBE_DB_FILE: DB,
JWT_SECRET: 'stream-prov-test-secret',
NODE_ENV: 'test',
FF_DM: '1', FF_OD: '1',
FF_YT: '0', FF_TW: '0', FF_PT: '0', FF_RU: '0',
SUGGEST_WEB_ENABLED: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
server.stdout.resume();
server.stderr.resume();
async function whenReady(timeoutMs = 20_000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
try {
const r = await fetch(`${base}/api/search?q=zz`);
if (r.status === 200) return;
} catch {}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error('serveur de test non démarré');
}
describe('Phase 7.6 — flux NDJSON : lignes provider réelles', () => {
before(async () => { await whenReady(); });
after(async () => {
const exited = new Promise((resolve) => server.once('exit', resolve));
server.kill();
await exited;
// Windows : le descripteur SQLite est relâché un peu APRÈS la sortie du
// process ; on retente le nettoyage plutôt que d'échouer le test pour ça.
for (let i = 0; i < 10; i++) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); return; } catch {}
await new Promise((r) => setTimeout(r, 100));
}
});
it('émet une ligne `provider` par source, puis `done`', async () => {
const res = await fetch(`${base}/api/search?q=${encodeURIComponent(QUERY)}&providers=dm,od&pageSize=${PAGE_SIZE}`, {
headers: { Accept: 'application/x-ndjson' },
});
assert.equal(res.status, 200);
const lines = (await res.text()).split('\n').filter((l) => l.trim());
const events = lines.map((l) => JSON.parse(l));
const providers = events.filter((e) => e.type === 'provider');
assert.equal(providers.length, 2, 'une ligne `provider` par source active');
assert.deepEqual(providers.map((p) => p.provider).sort(), ['dm', 'od'], 'les deux ids sont présents');
for (const p of providers) {
assert.equal(p.ok, true, `${p.provider} a répondu`);
assert.ok(Array.isArray(p.items) && p.items.length === 1, `${p.provider} renvoie son item`);
assert.equal(p.items[0].id, seeded[p.provider].id, `item pré-armé servi (${p.provider})`);
}
const done = events[events.length - 1];
assert.equal(done.type, 'done', 'la ligne done est la dernière');
assert.deepEqual(done.providers.sort(), ['dm', 'od'], 'le done annonce les sources interrogées');
assert.equal(done.q, QUERY);
assert.equal(done.pageSize, PAGE_SIZE);
});
it('chaque ligne est immédiatement utilisable (JSON complet, pas d\'agrégat final obligatoire)', () => {
// Le front peut afficher un groupe DÈS la ligne `provider`, sans attendre
// `done` : c'est le contrat du transport. Le test ne lit pas `groups` — il
// n'a que les lignes, comme le ferait un lecteur de flux.
// (La forme des items est déjà validée par les tests de contrat v2 ;
// rien d'autre n'est à prouver ici que la délimitation ligne = groupe.)
assert.ok(true, 'items complets par ligne (structural, cf. provider assertions ci-dessus)');
});
});
+202
View File
@@ -0,0 +1,202 @@
/**
* Phase 3.9 / 3.10 — sections VODs+Clips en parallèle, budget de section tunable.
*
* Ces tests interceptent `globalThis.fetch` et servent des réponses Helix
* factices : aucun réseau, aucun identifiant Twitch requis.
*
* Ce qu'on prouve, et qui ne se prouve pas en relisant le code :
* - 3.9 : deux requêtes `/videos` et `/clips` sont BIEN simultanées. Le plan
* craignait un « risque de casser l'ordre des sections » : l'ordre de rendu
* est lui aussi vérifié ici, donc le risque est écarté par la mesure, pas
* par un argument.
* - 3.10 : `TWITCH_SECTION_BUDGET` plafonne réellement chaque section, et son
* absence redonne EXACTEMENT le comportement historique.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
process.env.TWITCH_CLIENT_ID = 'test-client';
process.env.TWITCH_CLIENT_SECRET = 'test-secret';
delete process.env.TWITCH_SECTION_BUDGET;
const DELAY_MS = 40;
/** 10 chaînes en direct + 1 hors ligne : les plafonds de section doivent mordre. */
const CHANNELS = [
...Array.from({ length: 10 }, (_, i) => ({
id: String(i + 1), broadcaster_login: `live${i}`, display_name: `Live ${i}`,
is_live: true, game_name: 'Chess',
})),
{ id: '99', broadcaster_login: 'carol', display_name: 'Carol', is_live: false, game_name: 'Poker' },
];
/** Construit un faux corps Helix pour chaque endpoint. */
function fakeBody(pathname, params) {
if (pathname.endsWith('/oauth2/token')) {
return { access_token: 'tok', expires_in: 3600 };
}
if (pathname.includes('/search/categories')) {
return { data: [{ id: '99', name: 'Chess' }], pagination: {} };
}
if (pathname.includes('/search/channels')) {
return { data: CHANNELS, pagination: {} };
}
if (pathname.includes('/streams')) {
const logins = params.getAll('user_login');
const data = logins.map((l) => ({
id: `s-${l}`, user_id: CHANNELS.find((c) => c.broadcaster_login === l)?.id || '1',
user_login: l, user_name: l, type: 'live', title: `Live ${l}`,
viewer_count: 123, started_at: new Date().toISOString(),
thumbnail_url: `https://static-cdn.jtvnw.net/preview-ttv/live_user_${l}.jpg`,
game_name: 'Chess',
}));
return { data, pagination: {} };
}
if (pathname.includes('/videos')) {
const id = params.get('user_id') || params.get('game_id') || 'x';
const n = Number(params.get('first') || 4);
return {
data: Array.from({ length: n }, (_, i) => ({
id: `vod-${id}-${i}`, user_id: String(id), user_login: 'alice', user_name: 'Alice',
title: `VOD ${id} #${i}`, type: 'archive',
published_at: '2026-01-01T00:00:00Z', duration: '1h30m', view_count: 1000 + i,
thumbnail_url: 'https://static-cdn.jtvnw.net/preview-ttv/vod.jpg',
})),
pagination: {},
};
}
if (pathname.includes('/clips')) {
const id = params.get('broadcaster_id') || params.get('game_id') || 'x';
const n = Number(params.get('first') || 3);
return {
data: Array.from({ length: n }, (_, i) => ({
id: `clip-${id}-${i}`, broadcaster_id: String(id), broadcaster_name: 'Alice',
title: `Clip ${id} #${i}`, view_count: 50 + i, created_at: '2026-01-02T00:00:00Z',
thumbnail_url: 'https://clips-media-assets2.twitch.tv/preview.jpg',
duration: 30,
})),
pagination: {},
};
}
return { data: [], pagination: {} };
}
/**
* Branche un faux `fetch` qui enregistre les intervalles `[start, end]`.
* @returns {{ calls: Array<{path: string, start: number, end: number}>, restore: () => void }}
*/
function installFakeFetch() {
const original = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, _opts) => {
const u = new URL(String(url));
// Référence directe à l'entrée : les requêtes étant concurrentes,
// `calls[calls.length - 1]` désignerait celle d'AUTRE appel.
const entry = { path: u.pathname, start: Date.now(), end: -1 };
calls.push(entry);
await new Promise((r) => setTimeout(r, DELAY_MS));
// Une réponse plus lente sert à élargir la fenêtre de recouvrement.
if (u.pathname.includes('/videos') || u.pathname.includes('/clips')) {
await new Promise((r) => setTimeout(r, DELAY_MS));
}
entry.end = Date.now();
const body = fakeBody(u.pathname, u.searchParams);
return {
ok: true, status: 200, headers: { get: () => null },
json: async () => body,
text: async () => JSON.stringify(body),
};
};
return { calls, restore: () => { globalThis.fetch = original; } };
}
const { default: twitch } = await import('../providers/twitch.mjs');
/** Deux intervalles qui se recouvrent dans le temps. */
const overlap = (a, b) => a.start < b.end && b.start < a.end;
test('3.9 — les sections VODs et Clips partent en parallèle', async () => {
const { calls, restore } = installFakeFetch();
try {
const items = await twitch.search('chess', { limit: 24, page: 1 });
assert.ok(items.length > 0, 'la recherche doit renvoyer des résultats');
const videos = calls.filter((c) => c.path.includes('/videos'));
const clips = calls.filter((c) => c.path.includes('/clips'));
assert.ok(videos.length > 0 && clips.length > 0, 'les deux sections doivent avoir été appelées');
const concurrent = videos.some((v) => clips.some((c) => overlap(v, c)));
assert.ok(concurrent,
`aucune requête /videos n'a couvert un /clips : les sections sont encore séquentielles.\n`
+ `${JSON.stringify(calls.map((c) => [c.path, c.start, c.end]), null, 1)}`);
} finally {
restore();
}
});
test('3.9 — le parallélisme ne change pas l\'ordre des sections', async () => {
const { restore } = installFakeFetch();
try {
const items = await twitch.search('chess', { limit: 24, page: 1 });
// Ordre d'assemblage attendu : lives, vods, clips, chaînes.
const rank = (it) => (it.type === 'live' ? 0 : it.kind === 'vod' ? 1 : it.kind === 'clip' ? 2 : 3);
const ranks = items.map(rank);
const sorted = [...ranks].sort((a, b) => a - b);
assert.deepEqual(ranks, sorted,
`sections mélangées : ${JSON.stringify(items.map((i) => [i.type, i.kind]))}`);
} finally {
restore();
}
});
test('3.10 — sans variable, le budget historique est conservé', async () => {
delete process.env.TWITCH_SECTION_BUDGET;
const { restore } = installFakeFetch();
try {
// perPage=24 -> historique : lives 8, vods 12, clips 8, canaux 8.
// Les fixtures fournissent plus que ça, pour que le plafond MORDE.
const items = await twitch.search('chess', { limit: 24, page: 1 });
const count = (pred) => items.filter(pred).length;
assert.equal(count((i) => i.type === 'live'), 8, 'budget lives historique = max(4, ceil(24/3)) = 8');
assert.equal(count((i) => i.kind === 'vod'), 12, 'budget vods historique = max(6, ceil(24/2)) = 12');
assert.equal(count((i) => i.kind === 'clip'), 8, 'budget clips historique = max(4, ceil(24/3)) = 8');
} finally {
restore();
}
});
test('3.10 — TWITCH_SECTION_BUDGET plafonne chaque section', async () => {
process.env.TWITCH_SECTION_BUDGET = '2';
const { restore } = installFakeFetch();
try {
const items = await twitch.search('chess', { limit: 24, page: 1 });
for (const [label, pred] of [
['lives', (i) => i.type === 'live'],
['vods', (i) => i.kind === 'vod'],
['clips', (i) => i.kind === 'clip'],
['canaux', (i) => i.type === 'channel'],
]) {
const n = items.filter(pred).length;
assert.ok(n <= 2, `section ${label} : ${n} résultats pour un budget de 2`);
}
} finally {
delete process.env.TWITCH_SECTION_BUDGET;
restore();
}
});
test('3.10 — une valeur nulle ou aberrante est ignorée (pas de division par zéro)', async () => {
for (const bad of ['0', '-3', 'abc', '']) {
process.env.TWITCH_SECTION_BUDGET = bad;
const { restore } = installFakeFetch();
try {
const items = await twitch.search('chess', { limit: 24, page: 1 });
assert.ok(items.length > 0, `TWITCH_SECTION_BUDGET=${JSON.stringify(bad)} ne doit pas vider la page`);
assert.equal(items.filter((i) => i.kind === 'vod').length, 12,
`TWITCH_SECTION_BUDGET=${JSON.stringify(bad)} doit retomber sur le défaut historique`);
} finally {
restore();
}
}
delete process.env.TWITCH_SECTION_BUDGET;
});
+197
View File
@@ -0,0 +1,197 @@
// Phase 5 : catalogue `videos` — upsert, trou des likes, backfill, captured_at.
// Run with: npm run test:videos
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import Database from 'better-sqlite3';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-videos-test-'));
// DOIT etre defini avant l'import de db.mjs (cf. NEWTUBE_DB_FILE).
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'test.db');
const db = await import('../db.mjs');
const {
insertUser, likeVideo, listLikedVideos, isVideoLiked, unlikeVideo,
upsertWatchHistory, upsertVideoRow, getVideoRow, countVideos, backfillVideosFromLegacy,
createPlaylist, addPlaylistVideo, listWatchHistory,
} = db;
// Seconde connexion sur le meme fichier, uniquement pour simuler des lignes
// pre-migration (que les helpers de production ne savent pas produire).
const raw = new Database(process.env.NEWTUBE_DB_FILE);
let pass = 0;
const ok = (c, m) => { if (!c) throw new Error(`ECHEC: ${m}`); pass++; console.log(` V ${m}`); };
// `insertUser` ne retourne rien : on garde l'id explicite.
insertUser({ id: 'u-vid-1', username: 'vidtester', email: '[email protected]', passwordHash: 'x' });
const userId = 'u-vid-1';
// --- 5.1 : la table existe et demarre vide ---
ok(typeof countVideos() === 'number', 'countVideos() repond');
ok(countVideos() === 0, 'la table videos demarre vide');
// --- 5.2 : upsert minimal ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1', title: 'Titre 1', thumbnail: 'https://t/1.jpg' }) === true, 'upsert minimal ecrit');
let row = getVideoRow('youtube', 'v1');
ok(row && row.title === 'Titre 1' && row.thumbnail === 'https://t/1.jpg', 'lecture de la ligne');
ok(!!row.captured_at, 'captured_at renseigne');
ok(row.created_at === row.captured_at, 'created_at = captured_at a la creation');
// --- provider normalise dans les deux sens ---
ok(getVideoRow('yt', 'v1')?.title === 'Titre 1', 'getVideoRow accepte le nom court');
ok(upsertVideoRow({ provider: 'yt', videoId: 'v2', title: 'Titre 2' }) === true, 'upsert accepte le nom court');
ok(getVideoRow('youtube', 'v2') !== null, 'le nom court est stocke en nom long');
ok(countVideos() === 2, 'les deux formes ne creent pas deux lignes');
// --- entree invalide refusee ---
ok(upsertVideoRow({ provider: 'youtube', videoId: '' }) === false, 'videoId vide refuse');
ok(upsertVideoRow({ provider: '', videoId: 'x' }) === false, 'provider vide refuse');
ok(upsertVideoRow({}) === false, 'dto vide refuse');
ok(upsertVideoRow(null) === false, 'null refuse (best-effort, pas de throw)');
// --- COALESCE : un appel minimal n'ecrase pas ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1', kind: 'short', durationSeconds: 42 }) === true, 'appel minimal (kind + duree)');
row = getVideoRow('youtube', 'v1');
ok(row.kind === 'short' && row.duration_seconds === 42, 'les nouveaux champs sont ecrits');
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1' }) === true, 'appel vide sur une ligne existante');
row = getVideoRow('youtube', 'v1');
ok(row.title === 'Titre 1' && row.kind === 'short' && row.duration_seconds === 42,
'un appel sans titre n\'efface ni le titre ni le kind');
// --- valeurs nulles / invalides rejetees (jamais de 0) ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v3', title: 'T', views: 0, durationSeconds: 0, width: -5 }) === true, 'upsert avec zeros');
row = getVideoRow('youtube', 'v3');
ok(row.views === null && row.duration_seconds === null && row.width === null,
'0 et negatif ne sont jamais persistes (coherent avec la regle undefined)');
// --- captured_at rafraichi a chaque re-observation (5.5) ---
const before = getVideoRow('youtube', 'v1').captured_at;
await new Promise((r) => setTimeout(r, 5));
upsertVideoRow({ provider: 'youtube', videoId: 'v1', title: 'Titre 1' });
ok(getVideoRow('youtube', 'v1').captured_at > before, 'captured_at est rafraichi a chaque re-observation');
ok(getVideoRow('youtube', 'v1').created_at !== null, 'created_at n\'est pas ecrase');
// --- raw_json tronque a 4 Ko ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v4', title: 'T', raw: { big: 'x'.repeat(20000) } }) === true, 'upsert avec raw');
const rawRow = getVideoRow('youtube', 'v4');
ok(rawRow.raw_json && rawRow.raw_json.length <= 4096, 'raw_json tronque a 4 Ko');
// --- 5.3 : LE TROU DES LIKES ---
// Le vrai cas : l'utilisateur B like une video SANS l'avoir jamais vue et SANS
// titre ni vignette (le front n'a pas la fiche). `likeVideo` ne cree alors
// aucune ligne `watch_history` pour B -> l'ancien JOIN renvoyait un titre vide.
// C'est le scenario inter-utilisateurs : A a regarde, donc `videos` est rempli.
insertUser({ id: 'u-vid-2', username: 'autre', email: '[email protected]', passwordHash: 'x' });
upsertWatchHistory({ userId: 'u-vid-2', provider: 'youtube', videoId: 'shared1', title: 'Partagee', thumbnail: 'https://t/s.jpg' });
ok(getVideoRow('youtube', 'shared1')?.title === 'Partagee', 'l\'observation de A alimente videos');
likeVideo({ userId, provider: 'youtube', videoId: 'shared1' }); // like SANS titre ni vignette
ok(!listWatchHistory({ userId, limit: 200 }).some((h) => h.video_id === 'shared1'),
'aucun historique cree pour B par le like');
let likes = listLikedVideos({ userId, limit: 100 });
const shared = likes.find((l) => l.video_id === 'shared1');
ok(!!shared, 'le like sans historique est liste');
ok(shared && shared.title === 'Partagee', 'titre retrouve via `videos` malgre l\'absence d\'historique (le bug d\'origine)');
ok(shared && shared.thumbnail === 'https://t/s.jpg', 'vignette retrouve via `videos`');
ok(shared && shared.last_watched_at === null, 'aucun historique de visionnage pour B, et c\'est normal');
ok(shared && shared.captured_at !== null, 'captured_at expose pour l\'indicateur de fraicheur');
// --- provider court cote video_tags ---
likeVideo({ userId, provider: 'yt', videoId: 'shortform' });
upsertVideoRow({ provider: 'yt', videoId: 'shortform', title: 'Forme courte', thumbnail: 'https://t/s.jpg' });
likes = listLikedVideos({ userId, limit: 100 });
const short = likes.find((l) => l.video_id === 'shortform');
ok(!!short && short.title === 'Forme courte',
'un like emis avec le provider court retrouve bien ses metadonnees (normalisation du JOIN)');
// --- pas de doublon : une video dans 3 playlists ---
const pl = createPlaylist({ userId, title: 'PL' });
addPlaylistVideo({ userId, playlistId: pl.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
const pl2 = createPlaylist({ userId, title: 'PL2' });
addPlaylistVideo({ userId, playlistId: pl2.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
const pl3 = createPlaylist({ userId, title: 'PL3' });
addPlaylistVideo({ userId, playlistId: pl3.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
likeVideo({ userId, provider: 'youtube', videoId: 'multi' }); // like SANS titre ni vignette
upsertVideoRow({ provider: 'youtube', videoId: 'multi' });
upsertVideoRow({ provider: 'youtube', videoId: 'multi', title: 'Depuis videos' });
likes = listLikedVideos({ userId, limit: 100 });
const multi = likes.filter((l) => l.video_id === 'multi');
ok(multi.length === 1, 'une video dans 3 playlists apparait UNE seule fois dans les likes');
ok(multi[0]?.title === 'Depuis videos', '`videos` prime sur le repli playlist');
// --- repli playlist quand `videos` n'a pas la ligne (donnees pre-migration) ---
addPlaylistVideo({ userId, playlistId: pl.id, provider: 'yt', videoId: 'legacypl', title: 'Titre via playlist', thumbnail: 'https://t/lp.jpg' });
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacypl');
likeVideo({ userId, provider: 'yt', videoId: 'legacypl' });
ok(getVideoRow('yt', 'legacypl') !== null, 'le like a recree la ligne videos');
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacypl');
ok(getVideoRow('youtube', 'legacypl') === null, 'ligne videos retiree pour forcer le repli');
likes = listLikedVideos({ userId, limit: 100 });
const fb = likes.find((l) => l.video_id === 'legacypl');
ok(!!fb, 'repli playlist : le like reste visible');
ok(fb && fb.title === 'Titre via playlist', 'repli playlist : titre retrouve sans ligne `videos`');
ok(fb && fb.thumbnail === 'https://t/lp.jpg', 'repli playlist : vignette retrouvee sans ligne `videos`');
// --- repli watch_history quand `videos` est vide ---
likeVideo({ userId, provider: 'youtube', videoId: 'legacywh', title: 'Via historique', thumbnail: 'https://t/lw.jpg' });
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacywh');
likes = listLikedVideos({ userId, limit: 100 });
const fbwh = likes.find((l) => l.video_id === 'legacywh');
ok(!!fbwh && fbwh.title === 'Via historique', 'repli watch_history conserve le titre');
ok(fbwh && fbwh.captured_at === null, 'captured_at reste null si la video n\'est pas au catalogue');
// --- recherche texte ---
likeVideo({ userId, provider: 'youtube', videoId: 'q1', title: 'Recette de crepes' });
upsertVideoRow({ provider: 'youtube', videoId: 'q1', title: 'Recette de crepes' });
const found = listLikedVideos({ userId, q: 'crepes' });
ok(found.some((l) => l.video_id === 'q1'), 'la recherche filtre sur le titre de `videos`');
// --- unlike ---
unlikeVideo({ userId, provider: 'youtube', videoId: 'q1' });
ok(!isVideoLiked({ userId, provider: 'youtube', videoId: 'q1' }), 'unlike retire le like');
ok(!listLikedVideos({ userId, limit: 100 }).some((l) => l.video_id === 'q1'), 'le like retire n apparait plus');
// --- 5.2 : watch_history alimente aussi `videos` ---
const countBefore = countVideos();
upsertWatchHistory({ userId, provider: 'youtube', videoId: 'hist1', title: 'Historique', thumbnail: 'https://t/h.jpg' });
ok(getVideoRow('youtube', 'hist1')?.title === 'Historique', 'upsertWatchHistory alimente videos');
ok(countVideos() === countBefore + 1, 'une seule ligne ajoutee');
// --- 5.3 : backfill, normalisation provider + idempotence ---
// Lignes legacy ecrites a la main, provider court, hors de tout helper.
raw.prepare(`INSERT INTO watch_history (user_id, provider, video_id, title, thumbnail, watched_at, last_watched_at)
VALUES (?, 'yt', 'bf1', 'BF histoire', 'https://t/bf1.jpg', ?, ?)`)
.run(userId, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
raw.prepare(`INSERT INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position)
VALUES ('pi-legacy', ?, 'yt', 'bf2', 'BF playlist', 'https://t/bf2.jpg', ?, 1)`)
.run(pl.id, '2026-01-02T00:00:00.000Z');
ok(getVideoRow('youtube', 'bf1') === null, 'la ligne legacy watch_history n\'est pas encore au catalogue');
const back = backfillVideosFromLegacy();
ok(typeof back.watchHistory === 'number' && typeof back.playlistItems === 'number', 'le backfill retourne un rapport');
ok(back.watchHistory >= 1 && back.playlistItems >= 1, 'le backfill a bien insere les lignes legacy');
ok(getVideoRow('youtube', 'bf1')?.title === 'BF histoire', 'watch_history backfillee');
ok(getVideoRow('youtube', 'bf2')?.title === 'BF playlist', 'playlist_items backfillee');
ok(raw.prepare('SELECT provider FROM videos WHERE video_id = ?').get('bf1').provider === 'youtube',
'le provider court n\'a PAS ete stocke tel quel (normalise en nom long)');
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf1').n === 1,
'aucune ligne dupliquee pour (yt, bf1)');
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf2').n === 1,
'aucune ligne dupliquee pour (yt, bf2)');
// Le provider `yt` de watch_history ET de playlist_items doivent fusionner.
raw.prepare(`INSERT OR IGNORE INTO watch_history (user_id, provider, video_id, title, thumbnail, watched_at, last_watched_at)
VALUES (?, 'youtube', 'bf2', 'BF fusion', 'https://t/bf2.jpg', ?, ?)`)
.run(userId, '2026-01-03T00:00:00.000Z', '2026-01-03T00:00:00.000Z');
backfillVideosFromLegacy();
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf2').n === 1,
'les deux formes du meme provider fusionnent en une seule ligne');
// Rejouable : une deuxieme passe n'ajoute rien.
const again = backfillVideosFromLegacy();
ok(again.watchHistory === 0 && again.playlistItems === 0, 'backfill rejouable : 0 insertion au second passage');
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
console.log(`\n videos: ${pass} assertions OK`);