smoke-test.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. #!/usr/bin/env node
  2. const assert = require('assert');
  3. const http = require('http');
  4. const fs = require('fs');
  5. const os = require('os');
  6. const path = require('path');
  7. const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-main-smoke-'));
  8. process.env.QIWEI_OUTPUTS_DIR = smokeRoot;
  9. const { outputsRoot } = require('../mcp/src/core/output-paths');
  10. const { setActiveQiweiContext, qiweiAccountKey } = require('../mcp/src/core/credentials');
  11. setActiveQiweiContext({ userId: 'main-smoke-user' });
  12. const accountOutputKey = qiweiAccountKey();
  13. const { loadCatalog, searchEndpoints, findEndpoint } = require('../mcp/src/core/api-catalog');
  14. const { searchQiweiApis, getQiweiApiDoc, callQiweiApi } = require('../mcp/src/tools/qiwei-api-catalog-run');
  15. const {
  16. qiweiLoginStatus,
  17. qiweiLoginStart,
  18. qiweiLoginCheck,
  19. qiweiLoginVerify
  20. } = require('../mcp/src/tools/qiwei-login-run');
  21. const { stopLoginFlowServer } = require('../mcp/src/core/login-flow-server');
  22. const { stopLoginFallbackServer } = require('../mcp/src/core/login-fallback-server');
  23. const {
  24. qiweiSubscriptionStatus,
  25. qiweiSubscribe,
  26. qiweiSubscriptionAutoRenew
  27. } = require('../mcp/src/tools/qiwei-subscription-run');
  28. const {
  29. qiweiCheckFriendStatus,
  30. qiweiGetCustomerProfile
  31. } = require('../mcp/src/tools/qiwei-customer-ops-run');
  32. const {
  33. qiweiSyncExternalGroups,
  34. qiweiListExternalGroups,
  35. qiweiAnalyzeGroupMembers,
  36. qiweiConfirmExternalGroup,
  37. qiweiAddExternalGroup,
  38. qiweiConfigureGroupKeywords,
  39. qiweiRejectExternalGroup
  40. } = require('../mcp/src/tools/qiwei-group-management-run');
  41. const {
  42. qiweiPrepareCustomerPortrait,
  43. qiweiUpdateCustomerPortrait,
  44. qiweiSaveCustomerPortrait,
  45. qiweiAddCustomerTags,
  46. qiweiListCustomerTags,
  47. qiweiSyncPersonalLabels,
  48. qiweiCreatePersonalLabel
  49. } = require('../mcp/src/tools/qiwei-portrait-tags-run');
  50. const {
  51. qiweiDistillBroker,
  52. qiweiPrepareBrokerPlaybook,
  53. qiweiSaveBrokerPlaybook,
  54. qiweiGetBrokerPlaybook
  55. } = require('../mcp/src/tools/qiwei-broker-playbook-run');
  56. const {
  57. qiweiPreviewTransferPackage,
  58. qiweiExecuteTransfer
  59. } = require('../mcp/src/tools/qiwei-customer-transfer-run');
  60. const { qiweiTranscribeVoice } = require('../mcp/src/tools/qiwei-voice-run');
  61. const {
  62. qiweiWebhookStatus,
  63. qiweiWebhookAutoSetup,
  64. connectRelayThroughFmode
  65. } = require('../mcp/src/tools/qiwei-webhook-relay-run');
  66. const { getProductMode } = require('../mcp/src/core/product-mode');
  67. const { qiweiProductModeStatus } = require('../mcp/src/tools/qiwei-product-mode-run');
  68. const PRIVATE_PROVIDER_NAME = ['private', 'provider'].join('-');
  69. const PRIVATE_PROVIDER_HOST = ['private', 'provider', 'invalid'].join('.');
  70. const PRIVATE_CREDENTIAL_KEY = ['token', 'Id'].join('');
  71. function assertProviderHidden(value) {
  72. const output = JSON.stringify(value);
  73. assert(!output.includes(PRIVATE_PROVIDER_NAME));
  74. assert(!output.includes(PRIVATE_PROVIDER_HOST));
  75. assert(!output.includes('server-only-token'));
  76. }
  77. function readBody(req) {
  78. return new Promise(resolve => {
  79. let body = '';
  80. req.setEncoding('utf8');
  81. req.on('data', chunk => {
  82. body += chunk;
  83. });
  84. req.on('end', () => resolve(body ? JSON.parse(body) : undefined));
  85. });
  86. }
  87. async function startMockGateway({ subscribed = true } = {}) {
  88. const requests = [];
  89. let subscriptionActive = subscribed;
  90. const server = http.createServer(async (req, res) => {
  91. const body = await readBody(req);
  92. const url = new URL(req.url, 'http://localhost');
  93. requests.push({
  94. method: req.method,
  95. path: url.pathname,
  96. query: Object.fromEntries(url.searchParams.entries()),
  97. authorization: req.headers.authorization,
  98. body
  99. });
  100. let data;
  101. if (url.pathname === '/doApi') {
  102. if (body.method === '/contact/searchContact') {
  103. data = {
  104. searchStatus: 2,
  105. wxUserInfo: { userId: 'wx-u-1', nickname: '测试客户' },
  106. workUserInfo: null
  107. };
  108. } else if (body.method === '/contact/getWxContactList') {
  109. data = {
  110. currentSeq: 0,
  111. contactCount: 1,
  112. hasMore: false,
  113. contactList: [{ userId: 'wx-u-1', nickname: '测试客户' }]
  114. };
  115. } else if (body.method === '/room/getRoomList') {
  116. data = {
  117. hasMore: false,
  118. nextStartIndex: 0,
  119. roomCount: 2,
  120. roomList: [
  121. { roomId: 'r-1', roomName: '张三客户服务群', roomMemberCount: 3 },
  122. { roomId: 'r-2', roomName: 'random-group', roomMemberCount: 2 }
  123. ]
  124. };
  125. } else if (body.method === '/session/getSessionPage') {
  126. data = {
  127. hasMore: false,
  128. currentSeq: 0,
  129. sessionCount: 1,
  130. sessionList: [
  131. { roomId: 'r-3', sessionName: '潜在VIP群', sessionType: 1, roomMemberCount: 4 }
  132. ]
  133. };
  134. } else if (body.method === '/room/batchGetRoomDetail') {
  135. data = {
  136. roomList: (body.params.roomIdList || []).map(roomId => ({
  137. roomId,
  138. roomName: roomId === 'r-1' ? '张三客户服务群' : '其他群',
  139. roomMemberCount: roomId === 'r-1' ? 3 : 2
  140. }))
  141. };
  142. } else if (body.method === '/label/syncLabelList') {
  143. data = { currentSeq: 0, hasMore: false, labelCount: 1, labelList: [{ labelId: 'l-1', labelName: '高意向' }] };
  144. } else if (body.method === '/label/editLabel') {
  145. data = { success: true };
  146. } else if (body.method === '/label/contactEditLabel') {
  147. data = { success: true };
  148. } else {
  149. data = {
  150. nickname: '测试账号',
  151. echoedMethod: body.method,
  152. guid: 'server-only-guid',
  153. [PRIVATE_CREDENTIAL_KEY]: 'server-only-token',
  154. provider: PRIVATE_PROVIDER_NAME,
  155. providerName: PRIVATE_PROVIDER_NAME,
  156. providerHost: PRIVATE_PROVIDER_HOST,
  157. supportUrl: `https://${PRIVATE_PROVIDER_HOST}/help`,
  158. diagnostic: `See https://${PRIVATE_PROVIDER_HOST}/diagnostic`
  159. };
  160. }
  161. } else if (url.pathname === '/login/status') {
  162. data = { configured: true, online: true, statusCode: 2 };
  163. } else if (url.pathname === '/login/start') {
  164. data = { uid: body.uid, guid: 'server-only-guid', loginQrcodeBase64Data: Buffer.from('mock-png').toString('base64') };
  165. } else if (url.pathname === '/login/check') {
  166. data = { uid: body.uid, guid: 'server-only-guid', status: 2, detail: { nickname: '测试账号', userId: 'u-1', guid: 'server-only-guid' } };
  167. } else if (url.pathname === '/login/verify') {
  168. data = { accepted: true };
  169. } else if (url.pathname === '/subscribe/status') {
  170. data = {
  171. subscribed: subscriptionActive,
  172. price: 500,
  173. seats: 2,
  174. usedSeats: 1,
  175. expireAt: '2026-08-10T00:00:00.000Z',
  176. autoRenew: true,
  177. balance: 1000
  178. };
  179. } else if (url.pathname === '/subscribe') {
  180. subscriptionActive = true;
  181. data = {
  182. seats: body.seats || 1,
  183. months: body.months || 1,
  184. amount: (body.seats || 1) * (body.months || 1) * 500,
  185. expireAt: '2026-08-10T00:00:00.000Z',
  186. autoRenew: true
  187. };
  188. } else if (url.pathname === '/subscribe/autoRenew') {
  189. data = { autoRenew: body.autoRenew };
  190. } else if (url.pathname === '/relay/connect') {
  191. data = { connected: true, uid: body.uid };
  192. } else if (url.pathname === '/product/enterprise/quote') {
  193. data = {
  194. sku: 'qiwei-enterprise-relay',
  195. amount: 0,
  196. currency: 'CNY',
  197. billingCycle: 'month',
  198. pricingStatus: 'placeholder',
  199. chargeOnQuote: false
  200. };
  201. } else {
  202. res.writeHead(404, { 'Content-Type': 'application/json' });
  203. res.end(JSON.stringify({ code: 404, mess: 'not found' }));
  204. return;
  205. }
  206. res.writeHead(200, { 'Content-Type': 'application/json' });
  207. res.end(JSON.stringify({ code: 200, data }));
  208. });
  209. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  210. const address = server.address();
  211. return {
  212. requests,
  213. apiBase: `http://127.0.0.1:${address.port}`,
  214. close: () => new Promise(resolve => server.close(resolve))
  215. };
  216. }
  217. async function startMockVerifyGateway() {
  218. const requests = [];
  219. let verified = false;
  220. const server = http.createServer(async (req, res) => {
  221. const body = await readBody(req);
  222. const url = new URL(req.url, 'http://localhost');
  223. requests.push({
  224. method: req.method,
  225. path: url.pathname,
  226. query: Object.fromEntries(url.searchParams.entries()),
  227. authorization: req.headers.authorization,
  228. body
  229. });
  230. let data;
  231. if (url.pathname === '/login/status') {
  232. data = { configured: true, online: false, statusCode: -1 };
  233. } else if (url.pathname === '/login/start') {
  234. data = { uid: body.uid, guid: 'server-only-guid', loginQrcodeBase64Data: Buffer.from('mock-png').toString('base64') };
  235. } else if (url.pathname === '/login/check') {
  236. if (verified) {
  237. data = { uid: body.uid, status: 2, detail: { nickname: '测试账号', userId: 'u-1', guid: 'server-only-guid' } };
  238. } else {
  239. data = { uid: body.uid, status: 10, detail: {} };
  240. }
  241. } else if (url.pathname === '/login/verify') {
  242. verified = true;
  243. data = { accepted: true };
  244. } else if (url.pathname === '/subscribe/status') {
  245. data = { subscribed: true, seats: 2, usedSeats: 1, expireAt: '2026-08-10T00:00:00.000Z', autoRenew: true, balance: 1000 };
  246. } else {
  247. res.writeHead(404, { 'Content-Type': 'application/json' });
  248. res.end(JSON.stringify({ code: 404, mess: 'not found' }));
  249. return;
  250. }
  251. res.writeHead(200, { 'Content-Type': 'application/json' });
  252. res.end(JSON.stringify({ code: 200, data }));
  253. });
  254. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  255. const address = server.address();
  256. return {
  257. requests,
  258. apiBase: `http://127.0.0.1:${address.port}`,
  259. close: () => new Promise(resolve => server.close(resolve))
  260. };
  261. }
  262. async function main() {
  263. const catalog = loadCatalog();
  264. assert(catalog.endpoints.length >= 100, 'catalog should contain 100+ endpoints');
  265. assert(catalog.gateway.baseUrl.includes('server.fmode.cn/api/qiwei'));
  266. assert(catalog.endpoints.every(endpoint => !Object.hasOwn(endpoint, 'docUrl')));
  267. assertProviderHidden(catalog);
  268. console.log(`[ok] catalog loaded: ${catalog.endpoints.length} endpoints`);
  269. const hits = searchEndpoints({ query: '发送 文本' });
  270. assert(hits.length > 0 && hits[0].module === 'msg');
  271. assert(findEndpoint('/login/getLoginQrcode'));
  272. const searchResult = await searchQiweiApis({ query: '朋友圈' });
  273. assert(searchResult.status === 'ok' && searchResult.data.endpoints.length > 0);
  274. assertProviderHidden(searchResult);
  275. const doc = await getQiweiApiDoc({ id: 'user.getProfile' });
  276. assert(doc.status === 'ok' && doc.data.callTemplate);
  277. assertProviderHidden(doc);
  278. assert(!JSON.stringify(doc).includes('"guid"'));
  279. console.log('[ok] catalog search and docs');
  280. const gateway = await startMockGateway();
  281. const common = {
  282. authToken: 'sk-test-relay-token',
  283. uid: 'qiwei-smoke-device',
  284. apiBase: gateway.apiBase,
  285. productMode: 'personal',
  286. persistConfig: false
  287. };
  288. try {
  289. const productStatus = await qiweiProductModeStatus(common);
  290. assert.strictEqual(productStatus.status, 'ok');
  291. assert.strictEqual(productStatus.summary.mode, 'personal');
  292. assert.strictEqual(productStatus.summary.enterpriseUpgrade.amount, 0);
  293. assert.strictEqual(productStatus.summary.enterpriseUpgrade.source, 'server');
  294. const quoteRequest = gateway.requests.at(-1);
  295. assert.strictEqual(quoteRequest.path, '/product/enterprise/quote');
  296. assert.strictEqual(quoteRequest.method, 'GET');
  297. assert.strictEqual(quoteRequest.authorization, 'Bearer sk-test-relay-token');
  298. assert.strictEqual(quoteRequest.body, undefined);
  299. console.log('[ok] product mode uses the server enterprise quote without charging');
  300. const relayConnection = await connectRelayThroughFmode(common);
  301. assert.strictEqual(relayConnection.connected, true);
  302. const relayConnectRequest = gateway.requests.at(-1);
  303. assert.strictEqual(relayConnectRequest.path, '/relay/connect');
  304. assert.strictEqual(relayConnectRequest.authorization, 'Bearer sk-test-relay-token');
  305. assert.deepStrictEqual(relayConnectRequest.body, { uid: 'qiwei-smoke-device' });
  306. assert(!JSON.stringify(relayConnectRequest.body).match(/callback|secret|guid/i));
  307. console.log('[ok] relay connection uses the dedicated server endpoint without client callback credentials');
  308. const call = await callQiweiApi({ ...common, id: 'user.getProfile', params: {} });
  309. assert.strictEqual(call.status, 'ok');
  310. const apiRequest = gateway.requests.at(-1);
  311. assert.strictEqual(apiRequest.path, '/doApi');
  312. assert.strictEqual(apiRequest.authorization, 'Bearer sk-test-relay-token');
  313. assert.deepStrictEqual(apiRequest.body, {
  314. uid: 'qiwei-smoke-device',
  315. method: '/user/getProfile',
  316. params: {}
  317. });
  318. assert(!JSON.stringify(apiRequest.body).includes('tokenId'));
  319. assert(!JSON.stringify(apiRequest.body).includes('guid'));
  320. assertProviderHidden(call);
  321. console.log('[ok] qiwei_api_call uses the Fmode gateway envelope without private credentials');
  322. const blocked = await callQiweiApi({ ...common, rawMethod: '/login/manualLogin', params: {} });
  323. assert.strictEqual(blocked.status, 'error');
  324. assert.strictEqual(gateway.requests.at(-1), apiRequest);
  325. const subscription = await qiweiSubscriptionStatus(common);
  326. assert.strictEqual(subscription.status, 'ok');
  327. assert.strictEqual(subscription.summary.subscribed, true);
  328. const subscribed = await qiweiSubscribe({ ...common, seats: 3, months: 6 });
  329. assert.strictEqual(subscribed.status, 'ok');
  330. assert.strictEqual(gateway.requests.at(-1).body.seats, 3);
  331. assert.strictEqual(gateway.requests.at(-1).body.months, 6);
  332. const autoRenew = await qiweiSubscriptionAutoRenew({ ...common, autoRenew: false });
  333. assert.strictEqual(autoRenew.status, 'ok');
  334. assert.strictEqual(gateway.requests.at(-1).body.autoRenew, false);
  335. console.log('[ok] subscription tools use Fmode gateway routes');
  336. const status = await qiweiLoginStatus(common);
  337. assert.strictEqual(status.status, 'ok');
  338. assert.strictEqual(gateway.requests.at(-1).query.uid, common.uid);
  339. const start = await qiweiLoginStart({ ...common, openBrowser: false });
  340. assert.strictEqual(start.status, 'ok');
  341. assertProviderHidden(start);
  342. const check = await qiweiLoginCheck(common);
  343. assert.strictEqual(check.status, 'ok');
  344. assert.strictEqual(check.summary.loggedIn, true);
  345. assertProviderHidden(check);
  346. const verify = await qiweiLoginVerify({ ...common, code: '123456' });
  347. assert.strictEqual(verify.status, 'ok');
  348. assert.deepStrictEqual(gateway.requests.at(-1).body, { uid: common.uid, code: '123456' });
  349. console.log('[ok] login tools use uid-based Fmode gateway routes and hide internal identifiers');
  350. stopLoginFlowServer();
  351. const checkoutGateway = await startMockGateway({ subscribed: false });
  352. try {
  353. const checkoutStart = await qiweiLoginStart({
  354. ...common,
  355. apiBase: checkoutGateway.apiBase,
  356. flowPort: 4312,
  357. openBrowser: false
  358. });
  359. assert.strictEqual(checkoutStart.status, 'ok');
  360. const checkoutUrl = checkoutStart.summary.flowUrl;
  361. const checkoutPageRes = await fetch(checkoutUrl);
  362. assert.strictEqual(checkoutPageRes.status, 200);
  363. const checkoutPage = await checkoutPageRes.text();
  364. assert.ok(!checkoutPage.includes(common.authToken), 'dynamic checkout page must not expose the gateway token');
  365. assert.ok(!checkoutPage.includes(checkoutGateway.apiBase), 'dynamic checkout page must not expose the gateway URL');
  366. const tokenMatch = checkoutPage.match(/const CHECKOUT_TOKEN = "([^"]+)";/);
  367. assert.ok(tokenMatch && tokenMatch[1], 'dynamic checkout page should carry a local one-time confirmation');
  368. const checkoutState = await fetch(`${checkoutUrl}flow/state`).then(response => response.json());
  369. assert.strictEqual(checkoutState.monthlyPrice, 500);
  370. const checkoutBody = {
  371. seats: 3,
  372. months: 6,
  373. idempotencyKey: 'flow-smoke-order-1',
  374. checkoutToken: tokenMatch[1]
  375. };
  376. const rejectedCheckout = await fetch(`${checkoutUrl}flow/subscribe`, {
  377. method: 'POST',
  378. headers: { 'Content-Type': 'application/json' },
  379. body: JSON.stringify(checkoutBody)
  380. });
  381. assert.strictEqual(rejectedCheckout.status, 403);
  382. const acceptedCheckout = await fetch(`${checkoutUrl}flow/subscribe`, {
  383. method: 'POST',
  384. headers: { 'Content-Type': 'application/json', Origin: new URL(checkoutUrl).origin },
  385. body: JSON.stringify(checkoutBody)
  386. });
  387. assert.strictEqual(acceptedCheckout.status, 200);
  388. assert.deepStrictEqual(checkoutGateway.requests.at(-1).body, {
  389. seats: 3,
  390. months: 6,
  391. idempotencyKey: 'flow-smoke-order-1'
  392. });
  393. const replayedCheckout = await fetch(`${checkoutUrl}flow/subscribe`, {
  394. method: 'POST',
  395. headers: { 'Content-Type': 'application/json', Origin: new URL(checkoutUrl).origin },
  396. body: JSON.stringify(checkoutBody)
  397. });
  398. assert.strictEqual(replayedCheckout.status, 403);
  399. console.log('[ok] dynamic checkout hides gateway credentials, submits months once and rejects cross-origin/replayed payment');
  400. } finally {
  401. stopLoginFlowServer();
  402. await checkoutGateway.close();
  403. }
  404. const verifyGateway = await startMockVerifyGateway();
  405. try {
  406. const verifyCommon = {
  407. authToken: 'sk-test-relay-token',
  408. uid: 'qiwei-verify-device',
  409. apiBase: verifyGateway.apiBase,
  410. persistConfig: false
  411. };
  412. const fallbackStart = await qiweiLoginStart({ ...verifyCommon, flowUi: false, openBrowser: false });
  413. assert.strictEqual(fallbackStart.status, 'ok');
  414. assert.strictEqual(fallbackStart.summary.fallbackServer, true);
  415. assert.ok(fallbackStart.summary.flowUrl.startsWith('http://127.0.0.1:'), 'fallback server should return a local URL');
  416. assertProviderHidden(fallbackStart);
  417. const fallbackUrl = fallbackStart.summary.flowUrl;
  418. const pageRes = await fetch(fallbackUrl);
  419. assert.strictEqual(pageRes.status, 200);
  420. const pageHtml = await pageRes.text();
  421. assert.ok(pageHtml.includes('企业微信扫码登录'));
  422. assert.ok(!pageHtml.includes('sk-test-relay-token'), 'fallback page must not expose token');
  423. const qrRes = await fetch(`${fallbackUrl}flow/qrcode`);
  424. assert.strictEqual(qrRes.status, 200);
  425. assert.strictEqual(qrRes.headers.get('content-type'), 'image/png');
  426. const qrBody = await qrRes.arrayBuffer();
  427. assert.deepStrictEqual(Buffer.from(qrBody), Buffer.from('mock-png'));
  428. const checkRes = await fetch(`${fallbackUrl}flow/check`, { method: 'POST' });
  429. assert.strictEqual(checkRes.status, 200);
  430. const checkJson = await checkRes.json();
  431. assert.strictEqual(checkJson.status, 10);
  432. const verifyRes = await fetch(`${fallbackUrl}flow/verify`, {
  433. method: 'POST',
  434. headers: { 'Content-Type': 'application/json' },
  435. body: JSON.stringify({ code: '123456' })
  436. });
  437. assert.strictEqual(verifyRes.status, 200);
  438. const verifyJson = await verifyRes.json();
  439. assert.strictEqual(verifyJson.ok, true);
  440. const lastVerifyRequest = verifyGateway.requests.at(-1);
  441. assert.strictEqual(lastVerifyRequest.path, '/login/verify');
  442. assert.deepStrictEqual(lastVerifyRequest.body, { uid: verifyCommon.uid, code: '123456' });
  443. const checkAfterRes = await fetch(`${fallbackUrl}flow/check`, { method: 'POST' });
  444. assert.strictEqual(checkAfterRes.status, 200);
  445. const checkAfterJson = await checkAfterRes.json();
  446. assert.strictEqual(checkAfterJson.status, 2);
  447. assert.strictEqual(checkAfterJson.detail.userId, 'u-1');
  448. console.log('[ok] fallback login server serves QR, polls status and accepts verify code');
  449. } finally {
  450. await verifyGateway.close();
  451. stopLoginFallbackServer();
  452. }
  453. const friendStatus = await qiweiCheckFriendStatus({ ...common, guid: 'guid-1', phones: ['13800000000'] });
  454. assert.strictEqual(friendStatus.status, 'ok');
  455. assert.strictEqual(friendStatus.summary.confirmed, 1);
  456. assert.strictEqual(friendStatus.data.details[0].statusText, 'already_friend');
  457. assertProviderHidden(friendStatus);
  458. const portraitFile = path.join(outputsRoot(), 'portraits', accountOutputKey, 'wx-u-1.json');
  459. const tagFile = path.join(outputsRoot(), 'tags', accountOutputKey, 'wx-u-1.json');
  460. try { fs.unlinkSync(portraitFile); } catch {}
  461. try { fs.unlinkSync(tagFile); } catch {}
  462. const profile = await qiweiGetCustomerProfile({ ...common, guid: 'guid-1', phone: '13800000000' });
  463. assert.strictEqual(profile.status, 'ok');
  464. assert.strictEqual(profile.summary.hasPortrait, false);
  465. assert.strictEqual(profile.data.contactList.length, 1);
  466. assert.strictEqual(profile.data.rooms.length, 2);
  467. assertProviderHidden(profile);
  468. console.log('[ok] customer-ops tools check friend status and query customer profile');
  469. const groupMappingFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'confirmed-mapping.json');
  470. const keywordConfigFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'customer-keywords.json');
  471. const rejectedMappingFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'rejected-mapping.json');
  472. try { fs.unlinkSync(groupMappingFile); } catch {}
  473. try { fs.unlinkSync(keywordConfigFile); } catch {}
  474. try { fs.unlinkSync(rejectedMappingFile); } catch {}
  475. const configured = await qiweiConfigureGroupKeywords({ reset: true });
  476. assert.strictEqual(configured.status, 'ok');
  477. assert.strictEqual(configured.summary.matchMode, 'threshold');
  478. assert(configured.summary.keywordCount >= 10);
  479. const synced = await qiweiSyncExternalGroups({ ...common, guid: 'guid-1', scope: 'all' });
  480. assert.strictEqual(synced.status, 'ok');
  481. assert.strictEqual(synced.summary.scanned, 3);
  482. assert.strictEqual(synced.summary.selfCount, 2);
  483. assert.strictEqual(synced.summary.sessionCount, 1);
  484. assert.strictEqual(synced.summary.autoConfirmed, 1);
  485. assert.strictEqual(synced.summary.suggested, 1);
  486. assert.strictEqual(synced.summary.imported, 1);
  487. assertProviderHidden(synced);
  488. const listed = await qiweiListExternalGroups({});
  489. assert.strictEqual(listed.status, 'ok');
  490. assert.strictEqual(listed.summary.total, 3);
  491. assert.strictEqual(listed.summary.autoConfirmed, 1);
  492. assert.strictEqual(listed.summary.suggested, 1);
  493. assert.strictEqual(listed.summary.imported, 1);
  494. assertProviderHidden(listed);
  495. const suggestedOnly = await qiweiListExternalGroups({ status: 'SUGGESTED' });
  496. assert.strictEqual(suggestedOnly.status, 'ok');
  497. assert.strictEqual(suggestedOnly.summary.total, 1);
  498. assert.strictEqual(suggestedOnly.data.groups[0].roomId, 'r-3');
  499. const rejected = await qiweiRejectExternalGroup({ roomId: 'r-2', reason: '测试拒绝' });
  500. assert.strictEqual(rejected.status, 'ok');
  501. assert.strictEqual(rejected.summary.reviewStatus, 'REJECTED');
  502. const listedWithRejected = await qiweiListExternalGroups({ includeRejected: true });
  503. assert.strictEqual(listedWithRejected.status, 'ok');
  504. assert.strictEqual(listedWithRejected.summary.rejected, 1);
  505. const analyzed = await qiweiAnalyzeGroupMembers({ ...common, guid: 'guid-1', roomIds: ['r-1', 'r-3'], updateSnapshot: true });
  506. assert.strictEqual(analyzed.status, 'ok');
  507. assert.strictEqual(analyzed.summary.analyzed, 2);
  508. assert.strictEqual(analyzed.summary.autoConfirmed, 1);
  509. assertProviderHidden(analyzed);
  510. const confirmed = await qiweiConfirmExternalGroup({ roomId: 'r-1', externalUserId: 'wx-u-1', customerName: '张三' });
  511. assert.strictEqual(confirmed.status, 'ok');
  512. assert.strictEqual(confirmed.summary.reviewStatus, 'CONFIRMED');
  513. const added = await qiweiAddExternalGroup({ roomId: 'r-99', roomName: '手动群', externalUserId: 'wx-u-2' });
  514. assert.strictEqual(added.status, 'ok');
  515. assert.strictEqual(gateway.requests.some(request => request.body?.method === '/msg/syncMsg'), false, '群管理流程不应拉取历史消息');
  516. console.log('[ok] group-management tools scan, list, analyze, configure, reject and confirm without history sync');
  517. const scanManifestFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'group-scan-manifest.json');
  518. assert(fs.existsSync(scanManifestFile), 'group-scan-manifest.json should exist');
  519. assert(fs.existsSync(keywordConfigFile), 'customer-keywords.json should exist');
  520. assert(fs.existsSync(rejectedMappingFile), 'rejected-mapping.json should exist');
  521. const messageFile = path.join(outputsRoot(), 'messages', accountOutputKey, 'room-r1-test-portrait.json');
  522. fs.mkdirSync(path.dirname(messageFile), { recursive: true });
  523. fs.writeFileSync(messageFile, JSON.stringify([
  524. { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想采购企业培训服务,预算20万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) }
  525. ]));
  526. const prepared = await qiweiPrepareCustomerPortrait({ externalUserId: 'wx-u-1', roomIds: ['r1'] });
  527. assert.strictEqual(prepared.status, 'ok');
  528. assert.strictEqual(prepared.summary.messageCount, 1);
  529. const updated = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', updateMode: 'full', roomIds: ['r1'] });
  530. assert.strictEqual(updated.status, 'ok');
  531. assert.strictEqual(updated.summary.fields.length > 0, true);
  532. assert.strictEqual(updated.summary.updateMode, 'full');
  533. fs.writeFileSync(messageFile, JSON.stringify([
  534. { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想采购企业培训服务,预算20万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) },
  535. { fromRoomId: 'r1', seq: 3, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '还想了解交付和集成方式', msgUniqueIdentifier: 'm-3', timestamp: Math.floor(Date.now() / 1000) + 1 }
  536. ]));
  537. const incremental = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', updateMode: 'incremental', roomIds: ['r1'] });
  538. assert.strictEqual(incremental.status, 'ok');
  539. assert.strictEqual(incremental.summary.incrementalMessageCount, 1);
  540. assert(incremental.data.portrait.budgetRange, '增量更新应保留既有预算画像');
  541. assert(incremental.data.portrait.keyConcerns, '增量更新应合并新增关注点');
  542. const noChanges = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', updateMode: 'incremental', roomIds: ['r1'] });
  543. assert.strictEqual(noChanges.status, 'ok');
  544. assert.strictEqual(noChanges.summary.skipped, true);
  545. const saved = await qiweiSaveCustomerPortrait({ externalUserId: 'wx-u-1', portrait: { intent: '采购企业培训服务', budgetRange: '20万' } });
  546. assert.strictEqual(saved.status, 'ok');
  547. const tagsAdded = await qiweiAddCustomerTags({ externalUserId: 'wx-u-1', tags: ['高意向', '企业客户'] });
  548. assert.strictEqual(tagsAdded.status, 'ok');
  549. assert.strictEqual(tagsAdded.summary.total, 2);
  550. const tagsListed = await qiweiListCustomerTags({ externalUserId: 'wx-u-1' });
  551. assert.strictEqual(tagsListed.status, 'ok');
  552. assert.strictEqual(tagsListed.data.tags.includes('高意向'), true);
  553. const labels = await qiweiSyncPersonalLabels({ ...common, guid: 'guid-1' });
  554. assert.strictEqual(labels.status, 'ok');
  555. assert.strictEqual(labels.summary.count, 1);
  556. const labelCreated = await qiweiCreatePersonalLabel({ ...common, guid: 'guid-1', labelName: '测试标签' });
  557. assert.strictEqual(labelCreated.status, 'ok');
  558. assertProviderHidden(labelCreated);
  559. try { fs.unlinkSync(messageFile); } catch {}
  560. console.log('[ok] portrait-tags tools prepare, update, save portrait and manage tags/labels');
  561. const brokerMessageFile = path.join(outputsRoot(), 'messages', accountOutputKey, 'room-r2-test-playbook.json');
  562. fs.writeFileSync(brokerMessageFile, JSON.stringify([
  563. { fromRoomId: 'r2', seq: 1, senderId: 'broker-1', senderName: '顾问', msgType: 1, content: '您好,这个服务版本很适合您,预算也匹配', msgUniqueIdentifier: 'bm-1', timestamp: Math.floor(Date.now() / 1000) }
  564. ]));
  565. const brokerPrepared = await qiweiPrepareBrokerPlaybook({ brokerUserId: 'broker-1', roomIds: ['r2'] });
  566. assert.strictEqual(brokerPrepared.status, 'ok');
  567. assert.strictEqual(brokerPrepared.summary.messageCount, 1);
  568. const brokerDistilled = await qiweiDistillBroker({ brokerUserId: 'broker-1', aiMode: 'keyword', roomIds: ['r2'] });
  569. assert.strictEqual(brokerDistilled.status, 'ok');
  570. assert.strictEqual(brokerDistilled.summary.dimensions.length > 0, true);
  571. const brokerSaved = await qiweiSaveBrokerPlaybook({ brokerUserId: 'broker-1', playbook: { '客户分层': '精准匹配', '开场白': '您好' } });
  572. assert.strictEqual(brokerSaved.status, 'ok');
  573. const brokerGot = await qiweiGetBrokerPlaybook({ brokerUserId: 'broker-1' });
  574. assert.strictEqual(brokerGot.status, 'ok');
  575. assert.strictEqual(brokerGot.data.brokerUserId, 'broker-1');
  576. try { fs.unlinkSync(brokerMessageFile); } catch {}
  577. console.log('[ok] broker-playbook tools prepare, distill, save and get playbook');
  578. const preview = await qiweiPreviewTransferPackage({ fromUserId: 'old-broker', toUserId: 'new-broker', roomIds: ['r-1'] });
  579. assert.strictEqual(preview.status, 'ok');
  580. assert.strictEqual(preview.summary.itemCount, 1);
  581. const executed = await qiweiExecuteTransfer({ ...common, guid: 'guid-1', removeOldBroker: true });
  582. assert.strictEqual(executed.status, 'ok');
  583. assert.strictEqual(executed.summary.success, 1);
  584. assertProviderHidden(executed);
  585. console.log('[ok] customer-transfer tools preview and execute transfer');
  586. const wavHeader = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAA=='; // minimal WAV header-ish base64
  587. const voice = await qiweiTranscribeVoice({ base64Audio: wavHeader, transcribe: false });
  588. assert.strictEqual(voice.status, 'ok');
  589. assert.strictEqual(voice.summary.sourceExt, 'wav');
  590. console.log('[ok] voice tool saves audio file');
  591. const statusBefore = await qiweiWebhookStatus({});
  592. assert.strictEqual(statusBefore.status, 'ok');
  593. const currentProduct = getProductMode();
  594. assert.strictEqual(statusBefore.summary.productMode, currentProduct.mode);
  595. assert.strictEqual(statusBefore.summary.localMode, currentProduct.mode === 'personal' ? 'enabled' : 'disabled');
  596. if (!statusBefore.summary.relayEnabled) {
  597. const autoSetupWithoutRelay = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
  598. assert.strictEqual(autoSetupWithoutRelay.status, 'error');
  599. assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('个人版'), true);
  600. }
  601. console.log('[ok] webhook status reflects local mode and avoids external relay setup during smoke tests');
  602. for (const request of gateway.requests) {
  603. assert.strictEqual(request.authorization, 'Bearer sk-test-relay-token');
  604. }
  605. console.log(`[ok] ${gateway.requests.length} mock Fmode gateway requests authenticated`);
  606. } finally {
  607. await gateway.close();
  608. }
  609. stopLoginFlowServer();
  610. stopLoginFallbackServer();
  611. console.log('\nAll smoke tests passed.');
  612. }
  613. main()
  614. .finally(() => {
  615. require('../mcp/src/core/group-operations-store').closeGroupOperationsStore();
  616. fs.rmSync(smokeRoot, { recursive: true, force: true });
  617. })
  618. .catch(error => {
  619. console.error('smoke test failed:', error);
  620. process.exit(1);
  621. });