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.
278 lines
10 KiB
JavaScript
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 {}
|
|
}); |