smoke-trend-budget.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. 'use strict';
  2. const assert = require('node:assert/strict');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const { runXiaohongshuTrend } = require('../mcp/src/tools/xiaohongshu-trend-run');
  7. const { runDouyinTrend } = require('../mcp/src/tools/douyin-trend-run');
  8. function jsonResponse(payload, status = 200) {
  9. const text = JSON.stringify(payload);
  10. return {
  11. ok: status >= 200 && status < 300,
  12. status,
  13. json: async () => payload,
  14. text: async () => text
  15. };
  16. }
  17. async function verifyRunner(label, runner, outputName) {
  18. const output = fs.mkdtempSync(path.join(os.tmpdir(), `voc-${outputName}-budget-`));
  19. const fetchCalls = [];
  20. const oldFetch = global.fetch;
  21. global.fetch = async url => {
  22. fetchCalls.push(String(url));
  23. if (!String(url).endsWith('/api/fmode/billing/query')) {
  24. throw new Error(`${label} should stop before gateway call: ${url}`);
  25. }
  26. return jsonResponse({
  27. code: 200,
  28. data: {
  29. balance: { availableQuota: 0 },
  30. estimate: {
  31. quota: 100,
  32. amountCny: 2,
  33. sufficient: false,
  34. shortfallCny: 2,
  35. suggestedRechargeCny: 5,
  36. operations: []
  37. },
  38. usage: { totals: { costCny: 0 } }
  39. }
  40. });
  41. };
  42. const baseInput = {
  43. collectionMode: 'live',
  44. keywords: ['预算关键词一', '预算关键词二'],
  45. keywordLimit: 2,
  46. enforceBudget: true,
  47. newapiToken: 'sk-test',
  48. sessionToken: 'r:smoke-session-token',
  49. baseUrl: 'https://billing.test',
  50. output
  51. };
  52. try {
  53. const first = await runner(baseInput);
  54. assert.equal(first.status, 'needs_recharge', `${label} should expose recharge status`);
  55. assert.ok(first.recharge?.balanceUrl?.includes('balance'), `${label} should return tokenized Balance URL`);
  56. assert.ok(first.checkpoint && fs.existsSync(first.checkpoint), `${label} should write a checkpoint before the first gateway call`);
  57. const checkpoint = JSON.parse(fs.readFileSync(first.checkpoint, 'utf8'));
  58. assert.deepEqual(checkpoint.completedKeywords, [], `${label} should have no completed keywords after first-batch block`);
  59. assert.deepEqual(checkpoint.pendingKeywords, baseInput.keywords, `${label} should persist pending keywords`);
  60. assert.equal(fetchCalls.filter(url => url.endsWith('/api/fmode/billing/query')).length, 1);
  61. checkpoint.completedKeywords = [baseInput.keywords[0]];
  62. checkpoint.pendingKeywords = [baseInput.keywords[1]];
  63. fs.writeFileSync(first.checkpoint, JSON.stringify(checkpoint, null, 2));
  64. fetchCalls.length = 0;
  65. const resumed = await runner({ ...baseInput, resume: true, checkpointPath: first.checkpoint });
  66. assert.equal(resumed.status, 'needs_recharge', `${label} resume should remain blocked while balance is empty`);
  67. assert.equal(resumed.summary.batchBudget.keyword, baseInput.keywords[1], `${label} resume should skip completed keywords and inspect the next pending keyword`);
  68. assert.equal(fetchCalls.some(url => !url.endsWith('/api/fmode/billing/query')), false);
  69. } finally {
  70. global.fetch = oldFetch;
  71. }
  72. }
  73. async function verifyPreflight(label, runner, outputName, searchPayload) {
  74. const output = fs.mkdtempSync(path.join(os.tmpdir(), `voc-${outputName}-estimate-`));
  75. const oldFetch = global.fetch;
  76. let billingCalls = 0;
  77. global.fetch = async url => {
  78. if (String(url).endsWith('/api/fmode/billing/query')) {
  79. billingCalls += 1;
  80. return jsonResponse({
  81. code: 200,
  82. data: {
  83. balance: { availableQuota: 100000 },
  84. catalog: [],
  85. estimate: {
  86. quota: 10,
  87. amountCny: 0.01,
  88. sufficient: true,
  89. operations: []
  90. }
  91. }
  92. });
  93. }
  94. return jsonResponse(searchPayload);
  95. };
  96. try {
  97. const result = await runner({
  98. collectionMode: 'live',
  99. keywords: ['预估回归'],
  100. keywordLimit: 1,
  101. maxCommentPages: 0,
  102. preflightEstimate: true,
  103. newapiToken: 'sk-test',
  104. baseUrl: 'https://billing.test',
  105. output
  106. });
  107. assert.equal(result.status, 'ok', `${label} preflight run should complete with fixture data`);
  108. assert.ok(result.estimate?.plans?.length, `${label} should expose the Agent cost estimate`);
  109. assert.equal(billingCalls, 1, `${label} preflight should use the single billing query`);
  110. } finally {
  111. global.fetch = oldFetch;
  112. }
  113. }
  114. async function main() {
  115. await verifyRunner('xiaohongshu', runXiaohongshuTrend, 'xhs');
  116. await verifyRunner('douyin', runDouyinTrend, 'douyin');
  117. await verifyPreflight('xiaohongshu', runXiaohongshuTrend, 'xhs', { items: [{ note: { id: 'xhs-fixture-1', title: '预估笔记' } }] });
  118. await verifyPreflight('douyin', runDouyinTrend, 'douyin', { business_data: [{ aweme_info: { aweme_id: 'douyin-fixture-1', desc: '预估视频' } }] });
  119. process.stdout.write('trend budget and checkpoint smoke ok\n');
  120. }
  121. main().catch(error => {
  122. process.stderr.write(`${error.stack || error}\n`);
  123. process.exitCode = 1;
  124. });