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