Files
NewTube/server/tests/twitch_sections.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

203 lines
8.2 KiB
JavaScript

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