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`);