feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
});
|
||||
@@ -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`);
|
||||
@@ -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, []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
@@ -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 };
|
||||
@@ -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 {}
|
||||
});
|
||||
@@ -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 {}
|
||||
});
|
||||
@@ -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})`);
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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');
|
||||
|
||||
@@ -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), {});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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`);
|
||||
Reference in New Issue
Block a user