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