/** * 配置负缓存 TTL 回归测试。 * * 背景(线上真实故障): * 隧道抖动 → 系统库(走 SSH 隧道)读取失败 → 旧实现把「空候选」永久缓存下来 * (入口是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy), * 此后永不重试,直接抛「业务数据库尚未配置」500,**必须重启后端**才能恢复。 * * 修复:失败态记为「带 TTL 的负缓存」,到期自动重新解析。 * 本文件锁定三条语义: * ① TTL 内不重复解析(不必每个请求都去撞系统库的慢超时) * ② TTL 到期后必须重试 * ③ 上游恢复后能自动回到正常态,且成功态不会被 TTL 反复淘汰 */ const test = require('node:test'); const assert = require('node:assert/strict'); // ── 必须在加载被测模块之前完成环境与替身准备 ───────────────────────────── // 1) 先清掉业务库环境变量,稳定走「无环境变量兜底」分支(否则 fallbacks 非空,不会报未配置) for (const key of Object.keys(process.env)) { if (/^(CLOUD_DB|APP_DB|SQLSERVER)_/u.test(key)) { delete process.env[key]; } } // 2) 再缩短负缓存 TTL —— 必须在上面那次清理之后,否则会被同一前缀规则一并删掉 process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS = '150'; // 3) 让「系统库读取」可控:替换 mysql2/promise 的 createPool const MYSQL_PROMISE_PATH = require.resolve('mysql2/promise'); const realMysqlPromise = require(MYSQL_PROMISE_PATH); let systemDbDialAttempts = 0; let systemDbMode = 'down'; // down = 模拟隧道断开;recovered = 模拟隧道已恢复 const systemConfigRow = { name: 'cloud_database', host: '127.0.0.1', port: 59999, // 故意指向无人监听的端口,避免测试真去连业务库 database_name: 'JSReportReplica', username: 'probe', password: 'probe', ssl: 0, driver: 'mssql', use_env_config: 0, is_default: 1, }; require.cache[MYSQL_PROMISE_PATH].exports = Object.assign({}, realMysqlPromise, { createPool: () => { systemDbDialAttempts += 1; return { query: async () => { if (systemDbMode === 'down') { throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:13307'), { code: 'ECONNREFUSED', }); } return [[systemConfigRow]]; }, end: async () => {}, }; }, }); const cloudDatabase = require('../src/config/cloudDatabase'); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const NOT_CONFIGURED = /业务数据库尚未配置/u; test('负缓存带 TTL:TTL 内不重复解析,到期后必须重试', async () => { await cloudDatabase.close(); const base = systemDbDialAttempts; await assert.rejects( () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), NOT_CONFIGURED ); assert.equal(systemDbDialAttempts, base + 1, '首次查询应真正尝试解析配置'); await assert.rejects( () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), NOT_CONFIGURED ); assert.equal(systemDbDialAttempts, base + 1, 'TTL 内应命中负缓存,不重复解析'); await sleep(220); await assert.rejects( () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), NOT_CONFIGURED ); assert.equal( systemDbDialAttempts, base + 2, 'TTL 到期后必须重试 —— 这正是「隧道恢复后无需重启后端」的关键' ); }); test('隧道恢复后自动复原:TTL 到期重试能读回配置,且成功态不被 TTL 淘汰', async () => { await sleep(220); // 越过上一轮的负缓存 TTL systemDbMode = 'recovered'; const base = systemDbDialAttempts; await assert.rejects( () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), (error) => { // 关键断言:不应再是「尚未配置」——说明配置已成功读回, // 失败只是发生在后面连业务库那一层(这里故意指向空端口)。 assert.doesNotMatch( error.message, NOT_CONFIGURED, '配置读取应已恢复,不该再报「业务数据库尚未配置」' ); return true; } ); assert.ok(systemDbDialAttempts > base, '上游恢复后应重新解析配置'); // 成功态 expiresAt = Infinity:后续查询不得再反复解析系统库 const afterRecovery = systemDbDialAttempts; await assert.rejects(() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x')); assert.equal( systemDbDialAttempts, afterRecovery, '成功态不应被 TTL 反复淘汰(否则每个请求都要多打一次系统库)' ); });