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.
203 lines
8.2 KiB
JavaScript
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;
|
|
});
|