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

178 lines
11 KiB
JavaScript

const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const dir = mkdtempSync(join(tmpdir(), 'newtube-cache-'));
// `NEWTUBE_DB_FILE` DOIT etre defini AVANT l'import de db.mjs (lu au chargement).
// Avec le mauvais nom de variable, le test ecrivait dans la base de dev.
process.env.NEWTUBE_DB_FILE = join(dir, 'test.db');
process.env.SEARCH_CACHE_TTL_MS_DEFAULT = '5000';
process.env.YT_INNERTUBE_FAILOVER_COOLDOWN_MS = '60000';
let pass = 0;
const ok = (cond, m) => {
if (!cond) throw new Error(`ECHEC: ${m}`);
pass++; console.log(` V ${m}`);
};
const db = await import('../db.mjs');
const common = await import('../providers/youtube-common.mjs');
const { providerRegistry } = await import('../providers/registry.mjs');
const {
getCachedSearch, setCachedSearch, pruneSearchCache, countSearchCacheRows,
searchCacheStats, searchCacheTtlMs, migrateYoutubeCacheToSearchCache,
getCachedYoutubeSearch, setCachedYoutubeSearch,
incProviderMetrics, providerMetricsSnapshot, purgeProviderMetrics,
} = db;
const KEY = 'yt|abc123';
const items = [{ id: 'a', title: 'A' }];
// --- TTL par provider (4.1) ---
ok(searchCacheTtlMs('yt') === 30 * 60 * 1000, 'YT = 30 min (quota Data API)');
ok(searchCacheTtlMs('dm') === 5000, 'autre provider = SEARCH_CACHE_TTL_MS_DEFAULT');
ok(searchCacheTtlMs('od') === 5000, 'OD = default');
ok(searchCacheTtlMs('RU') === 5000, 'le provider est normalisé en minuscules');
// --- write / read ---
ok(setCachedSearch('dm', 'dm|k1', 'chat', items, 'api', 60000) === true, 'setCachedSearch ecrit');
ok(countSearchCacheRows('dm') === 1, 'countSearchCacheRows filtre par provider');
const hit = getCachedSearch('dm', 'dm|k1');
ok(hit && hit.items.length === 1 && hit.items[0].id === 'a', 'getCachedSearch relit le payload');
ok(hit.source === 'api', 'source conservee');
// --- hit_count (4.1) ---
ok(getCachedSearch('dm', 'dm|k1').hitCount === 2, 'hit_count incremente a chaque lecture');
ok(getCachedSearch('dm', 'dm|k1').hitCount === 3, 'hit_count continue de croitre');
const st = searchCacheStats().find((s) => s.provider === 'dm');
ok(st && st.hits === 3, 'searchCacheStats agrege les hits par provider');
ok(Math.abs(st.hitRate - 0.75) < 1e-9, 'hitRate = hits / (hits + entries) = 3/4');
ok(st.entries === 1, 'entries compte les lignes du provider');
// --- isolation des providers ---
ok(getCachedSearch('yt', 'dm|k1') === null, 'la cle ne fuite pas entre providers');
setCachedSearch('yt', KEY, 'x', items, 'innertube', 60000);
ok(getCachedSearch('yt', KEY) !== null && getCachedSearch('dm', 'dm|k1') !== null, 'les deux providers coexistent');
// --- jamais de resultat vide (4.1) ---
ok(setCachedSearch('dm', 'dm|empty', 'rien', [], 'api', 60000) === false, 'un tableau vide n est pas ecrit');
ok(getCachedSearch('dm', 'dm|empty') === null, 'aucune ligne pour un resultat vide');
ok(setCachedSearch('dm', 'dm|undef', 'rien', undefined, 'api', 60000) === false, 'undefined n est pas ecrit');
ok(setCachedSearch('dm', 'dm|obj', 'rien', { a: 1 }, 'api', 60000) === false, 'un non-tableau n est pas ecrit');
// --- expiration paresseuse ---
setCachedSearch('dm', 'dm|exp', 'vieux', items, 'api', -1);
ok(getCachedSearch('dm', 'dm|exp') === null, 'une entree expiree n est pas servie');
ok(getCachedSearch('dm', 'dm|exp') === null, 'la ligne expiree a ete purgee');
ok(getCachedSearch('dm', 'dm|exp') === null, 'relecture repurgee : idempotent');
// --- purge (4.5) ---
setCachedSearch('dm', 'dm|fresh', 'frais', items, 'api', 60000);
setCachedSearch('dm', 'dm|dead', 'mort', items, 'api', -1);
const purged = pruneSearchCache();
ok(purged >= 1, 'pruneSearchCache purge les lignes expirees');
ok(getCachedSearch('dm', 'dm|fresh') !== null, 'pruneSearchCache preserve les lignes valides');
ok(getCachedSearch('dm', 'dm|dead') === null, 'la ligne morte a bien disparu');
// --- Phase 6 : `channelRef` survit au cache (ordre des enveloppes) ---
// On plante une entree de cache SANS `channelRef` (comme celles ecrites par une
// version anterieure), puis on appelle le registre. Si l'annotation etait posee
// SOUS le cache, `search` renverrait l'item brut : aucun appel reseau n'est
// fait ici, donc c'est bien la lecture du cache qui est observee.
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
const q6 = 'preuve channelref';
const limit6 = 5;
const legacyItem = { id: 'x1', title: 'Legacy', channelId: 'x1abc' };
const cacheKey6 = `dm|${hashSearchKey(`${q6}|${limit6}|1|relevance|`)}`;
setCachedSearch('dm', cacheKey6, q6, [legacyItem], 'api', 60000);
const fromCache = await providerRegistry.dm.search(q6, { limit: limit6, page: 1 });
ok(Array.isArray(fromCache) && fromCache.length === 1, 'la lecture du cache a bien eu lieu (aucun appel reseau)');
ok(fromCache[0]?.id === 'x1', 'l\'item du cache est rendu tel quel');
ok(fromCache[0]?.channelRef?.value === 'x1abc' && fromCache[0]?.channelRef?.scheme === 'dm-user',
'phase 6 : channelRef annote aussi les items issus du cache');
pruneSearchCache({ cap: 1 });
// --- plafond par provider (4.1) ---
for (let i = 0; i < 12; i++) setCachedSearch('od', `od|k${i}`, `q${i}`, items, 'api', 60000 + i);
const capped = pruneSearchCache({ cap: 5 });
ok(countSearchCacheRows('od') <= 5, 'le plafond par provider est applique');
ok(getCachedSearch('od', 'od|k11') !== null, 'la ligne la plus fraiche survit au plafond');
ok(getCachedSearch('od', 'od|k0') === null, 'la plus ancienne est evincee');
ok(getCachedSearch('yt', KEY) !== null, 'le plafond ne touche pas les autres providers');
// --- migration YouTube (4.2) ---
ok(migrateYoutubeCacheToSearchCache().migrated === 0, 'migration sans donnee : 0 ligne');
const legacyWritten = setCachedYoutubeSearch(KEY, 'legacy', items, 'scrape', 60000);
ok(legacyWritten === undefined, 'setCachedYoutubeSearch ne retourne pas (API historique preservee)');
ok(getCachedYoutubeSearch(KEY)?.items?.[0]?.id === 'a', 'getCachedYoutubeSearch lit via search_cache');
// Cle distincte : force l'ecriture dans l'ancienne table uniquement.
setCachedSearch('yt', 'yt|def456', 'q', items, 'api', 60000);
ok(getCachedYoutubeSearch('yt|def456')?.items?.length === 1, 'repli: lecture via la table generique');
const m = migrateYoutubeCacheToSearchCache();
ok(typeof m.migrated === 'number', 'migrateYoutubeCacheToSearchCache retourne un rapport');
ok(m.legacyReadable === false, 'migration signale une base sans table legacy');
// --- metriques fournisseurs (4.3) ---
incProviderMetrics('dm', { ok: true, latencyMs: 120 });
incProviderMetrics('dm', { ok: true, latencyMs: 80 });
incProviderMetrics('dm', { ok: false, latencyMs: 300, error: 'boom' });
incProviderMetrics('tw', { ok: true, latencyMs: 50, fallback: true });
const snap = providerMetricsSnapshot({ hours: 1 });
const dmRow = snap.find((r) => r.provider === 'dm');
ok(dmRow.calls === 3 && dmRow.ok === 2 && dmRow.errors === 1, 'compteurs par provider agreges');
ok(Math.abs(dmRow.avgLatencyMs - 166) <= 1, 'avgLatencyMs = total / calls');
ok(Math.abs(dmRow.errorRate - 1 / 3) < 1e-9, 'errorRate = errors / calls');
ok(snap.find((r) => r.provider === 'tw').fallbacks === 1, 'les fallbacks sont comptes');
ok(purgeProviderMetrics() === 0, 'la purge de metriques ne supprime rien dans la fenetre');
// --- bascule automatique YouTube (4.4) ---
ok(common.getEffectiveSearchMode() === common.getSearchMode(), 'sans failover, mode effectif = mode configure');
ok(common.getFailoverState() === null, 'aucun failover au demarrage');
// Pas assez d'echantillons : 2 echecs ne doivent pas declencher la bascule.
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 2, errors: 2, errorRate: 1 }]);
ok(common.getFailoverState() === null, 'bascule ignoree sous 3 appels (trop peu d\'echantillons)');
// Taux sous le seuil.
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 10, errors: 1, errorRate: 0.1 }]);
ok(common.getFailoverState() === null, 'pas de bascule sous le seuil (10 %)');
// Depassement du seuil.
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 10, errors: 5, errorRate: 0.5 }]);
ok(common.getFailoverState() !== null, 'bascule declenchee au-dessus du seuil (50 %)');
ok(common.getEffectiveSearchMode() === 'scrape-first', 'le mode effectif devient scrape-first');
ok(common.getEffectiveSearchMode() !== common.getSearchMode(), 'le mode configure (intention) reste inchange');
// Un snapshot vide / invalide ne doit rien faire (et surtout ne pas lever).
ok(common.recordInnerTubeOutcome(true, []) !== undefined, 'snapshot vide tolere');
ok(common.recordInnerTubeOutcome(true, null) !== undefined, 'snapshot null tolere');
ok(common.recordInnerTubeOutcome(true, undefined) !== undefined, 'snapshot undefined tolere');
ok(common.getFailoverState() !== null, 'la bascule reste active : un snapshot vide ne la revoque pas');
// --- REGRESSION : le chemin de production alimente bien la bascule ---
// Les tests precedents injectaient un snapshot synthetique. En production, le
// snapshot vient de `provider_metrics`, alimente par `noteInnerTube()`.
// On rejoue donc la chaine REELLE : ecriture des echecs InnerTube -> snapshot
// base -> decision. Avant le correctif, aucune ecriture `yt` n'atteignait
// `provider_metrics`, `calls` restait a 0 et la bascule ne pouvait pas partir.
purgeProviderMetrics();
ok(!providerMetricsSnapshot({ hours: 1 }).some((r) => r.provider === 'yt'), 'aucune mesure yt au depart');for (let i = 0; i < 4; i++) incProviderMetrics('yt', { ok: false, latencyMs: 250, error: 'innertube_503' });
const realSnap = providerMetricsSnapshot({ hours: 1 });
const ytRow = realSnap.find((r) => r.provider === 'yt');
ok(!!ytRow, 'la production ecrit bien une ligne yt dans provider_metrics');
ok(ytRow && ytRow.calls === 4, 'les 4 tentatives sont comptees');
ok(ytRow && ytRow.errorRate === 1, 'taux d\'erreur mesure a partir des vraies ecritures');
ok(ytRow && ytRow.calls >= 3, 'le seuil d\'echantillons est atteint avec les vraies mesures');
common.recordInnerTubeOutcome(false, realSnap);
ok(common.getFailoverState() !== null, 'la bascule se declenche sur un snapshot issu de la production');
ok(common.getEffectiveSearchMode() === 'scrape-first', 'mode effectif force a scrape-first via le snapshot reel');
// Un mode api-first n'est pas InnerTube : la bascule ne doit pas l'ecraser.
process.env.YT_SEARCH_MODE = 'api-first';
ok(common.getEffectiveSearchMode() === 'api-first', 'la bascule n\'ecrase pas un mode sans InnerTube');
process.env.YT_SEARCH_MODE = 'innertube-first';
ok(common.getEffectiveSearchMode() === 'scrape-first', 'mode innertube-first bien remplace');
process.env.YT_SEARCH_MODE = 'api-only';
ok(common.getEffectiveSearchMode() === 'api-only', 'api-only non InnerTube : inchange');
delete process.env.YT_SEARCH_MODE;
// Nettoyage best-effort : better-sqlite3 garde le fichier ouvert sous Windows,
// la suppression peut echouer (EBUSY) sans que le test soit invalide.
try { rmSync(dir, { recursive: true, force: true }); } catch {}
console.log(`\n search-cache: ${pass} assertions OK`);