码桶

发现社区成员的开源项目

main
NodeNanny/test-manual-source.js
test-manual-source.js9.3 KB
'use strict';

// 2026-07-14新增:验证"手动种子来源"(创始人自己挑的第三方订阅链接,比如"旺财"这类)
// 能正确接入 refreshPool() 的多来源调度、并且跟GitHub发现来源共用同一套试用期/
// 信任状态机(不是像legacy aggregator那样直接判永久信任)。
//
// 同样用process.on('exit')做真实data目录的备份/恢复保护,写法照抄test-pool-e2e.js
// 已经验证过是正确的那一套,不重新发明。

const fs = require('fs');
const path = require('path');

let pass = 0, fail = 0;
function check(name, cond) {
  if (cond) { pass++; console.log(`[OK] ${name}`); }
  else { fail++; console.log(`[FAIL] ${name}`); }
}

const TEST_DATA_DIR = path.join(__dirname, 'data');
const TEST_DATA_BACKUP_DIR = TEST_DATA_DIR + '.manualsrc-pretest-backup-' + process.pid;
let realDataWasBackedUp = false;
if (fs.existsSync(TEST_DATA_DIR)) {
  fs.renameSync(TEST_DATA_DIR, TEST_DATA_BACKUP_DIR);
  realDataWasBackedUp = true;
}
process.on('exit', () => {
  try {
    if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
    if (realDataWasBackedUp) fs.renameSync(TEST_DATA_BACKUP_DIR, TEST_DATA_DIR);
  } catch (restoreErr) {
    console.error('[test-manual-source] 恢复真实data目录时出错,请手动检查:', restoreErr.message);
  }
});

const MANUAL_SUB_CONTENT_B64 = Buffer.from(
  'vless://[email protected]:443?security=tls&type=tcp&sni=b.com#manual1', 'utf-8'
).toString('base64');

global.fetch = async (url) => {
  if (url === 'https://example.com/manual-sub-test') {
    return { ok: true, status: 200, text: async () => MANUAL_SUB_CONTENT_B64 };
  }
  if (url === 'https://example.com/manual-sub-broken') {
    return { ok: false, status: 500, text: async () => '' };
  }
  if (url === 'https://example.com/manual-sub-empty') {
    return { ok: true, status: 200, text: async () => '' };
  }
  return { ok: false, status: 404, text: async () => '' };
};

// 沙盒没有真实sing-box,用受控假实现替换pool-checker.js,只验证数据流转,
// 不重复验证真实检测逻辑(那部分批次一已经真机验证过)。
const poolCheckerPath = require.resolve('./core/pool-checker');
require.cache[poolCheckerPath] = {
  id: poolCheckerPath, filename: poolCheckerPath, loaded: true,
  exports: {
    checkNode: async () => ({ outcome: 'ok', layers: {} }),
    checkNodes: async (links) => links.map(() => ({ outcome: 'ok', layers: {} })),
    _internal: { classifyProbeError: () => 'check_error' }
  }
};

const pool = require('./core/pool');
const sourceTrust = require('./core/source-trust');

async function main() {
  const baseConfig = {
    pool: {
      enabled: true,
      aggregatorDir: '/tmp/nodenanny-test-nonexistent-agg-dir', // 故意让legacy路径失败,只看manualSources
      checker: { enabled: true, concurrency: 2 },
      maxNodes: 10,
      checkCandidateLimit: 10,
      discovery: { enabled: false }, // 关掉GitHub发现,只测manualSources这条独立路径
      sourceWeighting: { aggregatorWeight: 1 },
      manualSources: [
        { id: 'wangcai-test', name: '测试用旺财', url: 'https://example.com/manual-sub-test' }
      ]
    }
  };

  const DATA_DIR = path.join(__dirname, 'data');
  fs.mkdirSync(DATA_DIR, { recursive: true });

  const result = await pool.refreshPool(baseConfig);

  check('refreshPool 没有抛异常,正常返回结果', result && typeof result === 'object');
  check('手动来源用manual:前缀的sourceId,不跟GitHub格式(owner/repo)冲突',
    result.sources.some((s) => s.sourceId === 'manual:wangcai-test'));
  check('手动来源这一轮抓到并测出1个通过的节点',
    result.sources.some((s) => s.sourceId === 'manual:wangcai-test' && s.passed === 1));
  check('refreshPool最终结果里count>0(手动来源的节点真的进了池子)', result.ok === true && result.count > 0);

  const state = sourceTrust.getSourceState('manual:wangcai-test');
  check('source-trust.js 正确记录了这个手动来源的状态', state !== null);
  check('手动来源第一轮默认是trial状态(不是像legacy aggregator那样直接永久信任)',
    state && state.status === 'trial');
  check('trial状态的权重被锁在低上限内(不是1.0那种跟legacy aggregator同等的权重)',
    state && state.weight < 0.1);

  // 验证resolveNodeTier/getStarmapData不会把手动来源误判成跟legacy aggregator一样的
  // "default"长期来源——这是本次改动最容易出错的地方,因为最初legacy aggregator是
  // 用sourceId特判的,如果代码里哪里漏改,手动来源可能被错误当成永久信任处理。
  const internal = pool._internal;
  check('resolveNodeTier(manual来源) 正确识别为trial,不是default(这是防止误判永久信任的关键断言)',
    internal.resolveNodeTier('manual:wangcai-test') === 'trial');

  const starmap = pool.getStarmapData(420, 320);
  const manualStar = starmap.stars.find((s) => s.sourceId === 'manual:wangcai-test');
  check('星图数据里手动来源被正确标记成trial(试用期)状态', manualStar && manualStar.status === 'trial');

  console.log(`[信息] manual:wangcai-test 记录状态: status=${state.status}, passRate=${state.passRate}, weight=${state.weight}`);

  // 第二轮:测试配置不完整(缺url)和请求失败两种情况不会让整轮刷新崩溃
  fs.writeFileSync(path.join(DATA_DIR, 'pool.json'), JSON.stringify({ updatedAt: null, count: 0, nodes: [], lastError: null, sources: [] }));
  const brokenConfig = JSON.parse(JSON.stringify(baseConfig));
  brokenConfig.pool.manualSources = [
    { id: 'no-url-test' }, // 缺url,应该被跳过,不报错
    { id: 'broken-test', name: '会挂的订阅', url: 'https://example.com/manual-sub-broken' },
    { id: 'empty-test', name: '空订阅', url: 'https://example.com/manual-sub-empty' }
  ];
  let secondRunThrew = false;
  let secondResult;
  try {
    secondResult = await pool.refreshPool(brokenConfig);
  } catch (err) {
    secondRunThrew = true;
    console.error('第二轮refreshPool不应该抛异常:', err);
  }
  check('配置不完整(缺url)/请求失败/空订阅这几种情况都不会让refreshPool抛异常', !secondRunThrew);
  check('缺url的来源被跳过,没有出现在sources摘要里',
    secondResult && !secondResult.sources.some((s) => s.sourceId === 'manual:no-url-test'));
  check('请求失败(HTTP 500)的来源被正确记成error而不是崩溃',
    secondResult && secondResult.sources.some((s) => s.sourceId === 'manual:broken-test' && s.error));
  check('空订阅(候选0条)的来源被正确记录,candidateCount为0',
    secondResult && secondResult.sources.some((s) => s.sourceId === 'manual:empty-test' && s.candidateCount === 0));

  // 2026-08-03新增:复现并锁定本次修复——频道这一轮解析不到今天的订阅链接时,
  // 不应该借用"manual:<频道id>"这个旧命名空间下可能存在的历史拉黑记录来报状态。
  // 先手工在sources.json里种一条"manual:tg-resolve-fail-test"的已拉黑记录,模拟
  // "域名信任隔离上线前遗留下来的旧记录",再验证:即使这条旧记录是blacklisted,
  // 频道本轮解析失败时返回的status也不应该是'blacklisted',而应该是中性的
  // 'no_link_this_round',weight锁定为0,且不去读那条旧记录。
  sourceTrust.recordCheckResult('manual:tg-resolve-fail-test', { totalChecked: 1, passed: 0 });
  sourceTrust.recordCheckResult('manual:tg-resolve-fail-test', { totalChecked: 1, passed: 0 });
  sourceTrust.recordCheckResult('manual:tg-resolve-fail-test', { totalChecked: 1, passed: 0 });
  sourceTrust.recordCheckResult('manual:tg-resolve-fail-test', { totalChecked: 1, passed: 0 });
  const staleState = sourceTrust.getSourceState('manual:tg-resolve-fail-test');
  check('测试前置条件:手工种的旧记录确实已经是blacklisted状态(模拟历史遗留)',
    staleState && staleState.status === 'blacklisted');

  const telegramFetchPath = require.resolve('./core/telegram-fetch');
  require.cache[telegramFetchPath] = {
    id: telegramFetchPath, filename: telegramFetchPath, loaded: true,
    exports: {
      isTelegramChannelUrl: () => true,
      normalizeToPreviewUrl: (u) => u,
      fetchLatestFileUrl: async () => ({ ok: false, error: '模拟:今天频道里没有找到带链接的消息' }),
      extractRawNodeLinks: () => []
    }
  };
  delete require.cache[require.resolve('./core/pool')];
  const poolReloaded = require('./core/pool');
  const tgFailResult = await poolReloaded._internal.fetchFromManualSource(
    { id: 'tg-resolve-fail-test', name: '模拟频道', url: 'https://t.me/some-test-channel' },
    baseConfig.pool.checker,
    8000
  );
  check('频道本轮解析不到订阅链接时,status是中性的no_link_this_round,不是借用旧记录的blacklisted',
    tgFailResult.status === 'no_link_this_round');
  check('频道本轮解析不到订阅链接时,weight锁定为0',
    tgFailResult.weight === 0);
  const staleStateAfter = sourceTrust.getSourceState('manual:tg-resolve-fail-test');
  check('旧的manual:<id>记录本身没有被这次调用改动(既不读也不写,纯粹不再牵扯)',
    staleStateAfter && staleStateAfter.status === 'blacklisted' &&
    staleStateAfter.lastUpdated === staleState.lastUpdated);

  console.log(`\n总计: ${pass} 通过, ${fail} 失败`);
  process.exit(fail > 0 ? 1 : 0);
}

main().catch((err) => {
  console.error('测试脚本本身抛出异常:', err);
  process.exit(1);
});