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

145 lines
6.1 KiB
JavaScript

/**
* Phase 7.3 — provenance des suggestions (`capturedAt` / `source`).
*
* Ce qui est réellement vérifié, c'est la HONNÊTETÉ de la fraîcheur :
* - un résultat vivant est estampillé à l'instant de la collecte ;
* - un résultat servi par le cache conserve l'instant du VRAI appel amont,
* pas l'heure de lecture du cache (sinon « à l'instant » sur du vieux data) ;
* - une entrée de cache écrite par un serveur antérieur à la phase 7.3 (donc
* sans provenance) récupère son `createdAt` en base.
*
* Aucun réseau : `globalThis.fetch` est stubé, et le provider testé est
* `dm` (Dailymotion), l'un de ceux enveloppés par le cache générique.
*/
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';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-prov-'));
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'prov.db');
const db = await import('../db.mjs');
const { providerRegistry, stampProvenance } = await import('../providers/registry.mjs');
const calls = [];
function installFetch() {
const prev = globalThis.fetch;
calls.length = 0;
globalThis.fetch = async (url) => {
calls.push(String(url));
return {
ok: true,
status: 200,
json: async () => ({
page: 1,
total: 1,
list: [{
id: 'x1dm',
created_time: 1700000000,
duration: 300,
views_total: 1234,
owner: { screenname: 'DM User', username: 'dmuser', avatar_720_url: 'https://img/a.png' },
title: 'Vidéo Dailymotion',
description: 'une description',
url: 'https://www.dailymotion.com/video/x1dm',
}],
}),
};
};
return () => { globalThis.fetch = prev; };
}
const dm = providerRegistry.dm;
const OPTS = { limit: 5, page: 1, sort: 'relevance' };
test('7.3 — un résultat vivant porte capturedAt + source', async (t) => {
const restore = installFetch();
t.after(restore);
const before = Date.now();
const items = await dm.search('provenance vivante', OPTS);
assert.ok(items.length > 0, 'la recherche stubée doit produire des résultats');
const at = Number(items[0].capturedAt);
assert.ok(Number.isFinite(at) && at >= before, `capturedAt epoch ms plausible (${at})`);
assert.ok(at <= Date.now(), 'capturedAt ne peut pas être dans le futur');
assert.equal(items[0].source, 'api', 'voie de collecte explicite');
// Provenance sur TOUS les items, pas seulement le premier.
assert.ok(items.every((i) => i.capturedAt && i.source), 'chaque item est estampillé');
});
test('7.3 — un hit de cache garde le capturedAt du VRAI appel amont', async (t) => {
const restore = installFetch();
t.after(restore);
const first = await dm.search('provenance cache', OPTS);
const firstAt = Number(first[0].capturedAt);
const networkCallsAfterFirst = calls.length;
assert.ok(networkCallsAfterFirst > 0, 'le premier appel est bien allé au réseau');
// Deuxième appel identique : servi par le cache, aucun nouvel appel amont.
const second = await dm.search('provenance cache', OPTS);
assert.equal(calls.length, networkCallsAfterFirst, 'aucun appel amont sur un hit de cache');
assert.equal(Number(second[0].capturedAt), firstAt,
'la fraîcheur affichée est celle de la DONNÉE, pas celle de la lecture du cache');
});
test('7.3 — une entrée de cache sans provenance récupère son createdAt', async (t) => {
const restore = installFetch();
t.after(restore);
// Simule une ligne écrite par un serveur antérieur à la phase 7.3 : payload
// sans `capturedAt`/`source`.
const q = 'provenance legacy';
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
const filterSig = '';
const key = `dm|${hashSearchKey(`${q.toLowerCase().trim()}|5|1|relevance|${filterSig}`)}`;
assert.equal(db.setCachedSearch('dm', key, q, [{ id: 'legacy1', title: 'vieux' }], 'api'), true);
const items = await dm.search(q, OPTS);
assert.equal(calls.length, 0, 'la ligne préexistante est servie sans réseau');
assert.equal(items[0].id, 'legacy1');
assert.ok(Number.isFinite(Number(items[0].capturedAt)) && Number(items[0].capturedAt) > 0,
'createdAt en base comble le champ manquant');
assert.equal(items[0].source, 'api', 'source de la ligne de cache');
});
test('7.3 — la voie réelle de l\'adaptateur n\'est jamais réécrite', () => {
// YouTube connaît sa propre voie (`innertube` / `scrape`) : la passe de
// provenance ne doit pas la surcharger avec la valeur par défaut `api`.
const items = stampProvenance([{ id: 'yt1', source: 'innertube', capturedAt: 1700000000000 }], 'api');
assert.equal(items[0].source, 'innertube', 'source existante conservée');
assert.equal(Number(items[0].capturedAt), 1700000000000, 'capturedAt existant conservé');
});
test('7.3 — hit de cache : source `cache` + instant de la lecture', () => {
const items = stampProvenance([{ id: 'a' }, { id: 'b' }], 'cache', 1700000000000);
assert.ok(items.every((i) => i.source === 'cache'), 'tous les items sont marqués');
assert.ok(items.every((i) => Number(i.capturedAt) === 1700000000000), 'instant appliqué à tous');
});
test('7.3 — payload invalide toléré (jamais de 0 ni de date bidon)', () => {
// `0` n'est pas une epoch ms, `''` n'est pas une voie de collecte : les deux
// doivent être remplacés, sinon l'IHB affiche « il y a 0 s » sur du vide.
const items = stampProvenance([
{ id: 'x', capturedAt: 0, source: '' },
{ id: 'y', capturedAt: -1, source: ' ' },
{ id: 'z', capturedAt: NaN },
], 'api', 1700000000000);
for (const it of items) {
assert.equal(Number(it.capturedAt), 1700000000000, `${it.id} : capturedAt replaced`);
assert.equal(it.source, 'api', `${it.id} : source replaced`);
}
});
test('7.3 — entrée vide / non tableau toléré', () => {
assert.deepEqual(stampProvenance([], 'api'), []);
assert.equal(stampProvenance(null, 'api'), null);
assert.equal(stampProvenance(undefined, 'api'), undefined);
});
test.after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});