'use strict'; const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { runXiaohongshuTrend } = require('../mcp/src/tools/xiaohongshu-trend-run'); const { runDouyinTrend } = require('../mcp/src/tools/douyin-trend-run'); function jsonResponse(payload, status = 200) { const text = JSON.stringify(payload); return { ok: status >= 200 && status < 300, status, json: async () => payload, text: async () => text }; } async function verifyRunner(label, runner, outputName) { const output = fs.mkdtempSync(path.join(os.tmpdir(), `voc-${outputName}-budget-`)); const fetchCalls = []; const oldFetch = global.fetch; global.fetch = async url => { fetchCalls.push(String(url)); if (!String(url).endsWith('/api/fmode/billing/query')) { throw new Error(`${label} should stop before gateway call: ${url}`); } return jsonResponse({ code: 200, data: { balance: { availableQuota: 0 }, estimate: { quota: 100, amountCny: 2, sufficient: false, shortfallCny: 2, suggestedRechargeCny: 5, operations: [] }, usage: { totals: { costCny: 0 } } } }); }; const baseInput = { collectionMode: 'live', keywords: ['预算关键词一', '预算关键词二'], keywordLimit: 2, enforceBudget: true, newapiToken: 'sk-test', sessionToken: 'r:smoke-session-token', baseUrl: 'https://billing.test', output }; try { const first = await runner(baseInput); assert.equal(first.status, 'needs_recharge', `${label} should expose recharge status`); assert.ok(first.recharge?.balanceUrl?.includes('balance'), `${label} should return tokenized Balance URL`); assert.ok(first.checkpoint && fs.existsSync(first.checkpoint), `${label} should write a checkpoint before the first gateway call`); const checkpoint = JSON.parse(fs.readFileSync(first.checkpoint, 'utf8')); assert.deepEqual(checkpoint.completedKeywords, [], `${label} should have no completed keywords after first-batch block`); assert.deepEqual(checkpoint.pendingKeywords, baseInput.keywords, `${label} should persist pending keywords`); assert.equal(fetchCalls.filter(url => url.endsWith('/api/fmode/billing/query')).length, 1); checkpoint.completedKeywords = [baseInput.keywords[0]]; checkpoint.pendingKeywords = [baseInput.keywords[1]]; fs.writeFileSync(first.checkpoint, JSON.stringify(checkpoint, null, 2)); fetchCalls.length = 0; const resumed = await runner({ ...baseInput, resume: true, checkpointPath: first.checkpoint }); assert.equal(resumed.status, 'needs_recharge', `${label} resume should remain blocked while balance is empty`); assert.equal(resumed.summary.batchBudget.keyword, baseInput.keywords[1], `${label} resume should skip completed keywords and inspect the next pending keyword`); assert.equal(fetchCalls.some(url => !url.endsWith('/api/fmode/billing/query')), false); } finally { global.fetch = oldFetch; } } async function verifyPreflight(label, runner, outputName, searchPayload) { const output = fs.mkdtempSync(path.join(os.tmpdir(), `voc-${outputName}-estimate-`)); const oldFetch = global.fetch; let billingCalls = 0; global.fetch = async url => { if (String(url).endsWith('/api/fmode/billing/query')) { billingCalls += 1; return jsonResponse({ code: 200, data: { balance: { availableQuota: 100000 }, catalog: [], estimate: { quota: 10, amountCny: 0.01, sufficient: true, operations: [] } } }); } return jsonResponse(searchPayload); }; try { const result = await runner({ collectionMode: 'live', keywords: ['预估回归'], keywordLimit: 1, maxCommentPages: 0, preflightEstimate: true, newapiToken: 'sk-test', baseUrl: 'https://billing.test', output }); assert.equal(result.status, 'ok', `${label} preflight run should complete with fixture data`); assert.ok(result.estimate?.plans?.length, `${label} should expose the Agent cost estimate`); assert.equal(billingCalls, 1, `${label} preflight should use the single billing query`); } finally { global.fetch = oldFetch; } } async function main() { await verifyRunner('xiaohongshu', runXiaohongshuTrend, 'xhs'); await verifyRunner('douyin', runDouyinTrend, 'douyin'); await verifyPreflight('xiaohongshu', runXiaohongshuTrend, 'xhs', { items: [{ note: { id: 'xhs-fixture-1', title: '预估笔记' } }] }); await verifyPreflight('douyin', runDouyinTrend, 'douyin', { business_data: [{ aweme_info: { aweme_id: 'douyin-fixture-1', desc: '预估视频' } }] }); process.stdout.write('trend budget and checkpoint smoke ok\n'); } main().catch(error => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });