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