Files
NewTube/server/tests/channel_banner.test.mjs
T
bruno 665a0f0ebd
CI / build-and-test (push) Successful in 14m43s
feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
2026-09-30 07:57:11 -04:00

278 lines
10 KiB
JavaScript

/**
* 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 {}
});