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.
178 lines
11 KiB
JavaScript
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`);
|