/** * 名片云 API 收口层测试: common/js/card-api.js ↔ 后端 CARD_API_REFERENCE.md * * 背景: * 后端《名片云(card addon)API 对接文档》checklist 第 1 条要求: * "所有 /card/api/* URL 收口到 API 层, 与本文档路径逐一比对(现有 7 个页面 * 12 处直连, 路径不可变)"。本测试把该契约固化为断言: * * 覆盖: * 1. card-api.js 的 18 个端点与后端文档逐一比对(一个字符都不能差) * 2. card-api.js 不得出现文档之外的 /card/api/ 路径 * 3. 每个端点都有对应 helper 方法(收口不是只建常量表) * 4. 7 个业务页面零字面量直连(全部走 this.$cardApi) * 5. main.js 注册 $cardApi(Vue.prototype) * 6. 字典与后端白名单一致: SOCIAL_TYPES / LEAD_STATUS / CONTACT_SOURCES * 7. 嵌套对象入参走 sendJson 通道(card/update、contact/add、contact/update、 * recognizeBatch), 其余走 sendRequest * 8. 走 sendJson 的 helper, 页面必须把 payload 包在 data 里 * (sendJson 只合并 params.data, 顶层挂 fields 会被丢弃) * * 跑法: * node --test tests/card-api.test.js */ 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const CARD_DIR = path.join(ROOT, 'pages_tool/card'); const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8'); const API_SRC = fs.readFileSync(path.join(ROOT, 'common/js/card-api.js'), 'utf8'); const MAIN_SRC = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); // 后端 CARD_API_REFERENCE.md 第 1/3/4/5 节列出的全部端点(15 个) const DOC_URLS = [ // 名片夹 /card/api/card/* '/card/api/card/lists', '/card/api/card/detail', '/card/api/card/update', '/card/api/card/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准 '/card/api/card/claim', '/card/api/card/recognize', '/card/api/card/retry', '/card/api/card/recognizeBatch', // 人脉库 /card/api/contact/* '/card/api/contact/lists', '/card/api/contact/add', '/card/api/contact/info', '/card/api/contact/update', '/card/api/contact/delete', '/card/api/contact/toLead', // 潜在客户 /card/api/lead/* '/card/api/lead/lists', '/card/api/lead/setStatus', // 交换名片 /card/api/exchange/* '/card/api/exchange/lists', '/card/api/exchange/agree', '/card/api/exchange/ignore' ]; // helper 方法名 ↔ URL 常量名(收口层必须两端齐全) const HELPER_URL_PAIRS = [ ['cardLists', 'CARD_LISTS'], ['cardDetail', 'CARD_DETAIL'], ['cardUpdate', 'CARD_UPDATE'], ['cardCreate', 'CARD_CREATE'], ['cardClaim', 'CARD_CLAIM'], ['cardRecognize', 'CARD_RECOGNIZE'], ['cardRetry', 'CARD_RETRY'], ['cardRecognizeBatch', 'CARD_RECOGNIZE_BATCH'], ['contactLists', 'CONTACT_LISTS'], ['contactAdd', 'CONTACT_ADD'], ['contactInfo', 'CONTACT_INFO'], ['contactUpdate', 'CONTACT_UPDATE'], ['contactDelete', 'CONTACT_DELETE'], ['contactToLead', 'CONTACT_TO_LEAD'], ['leadLists', 'LEAD_LISTS'], ['leadSetStatus', 'LEAD_SET_STATUS'], ['exchangeLists', 'EXCHANGE_LISTS'], ['exchangeAgree', 'EXCHANGE_AGREE'], ['exchangeIgnore', 'EXCHANGE_IGNORE'] ]; // 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson) const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore']; // ============================================================= // 1. 端点与后端文档逐一比对 // ============================================================= test('收口: card-api.js 含文档全部 18 个端点', () => { for (const u of DOC_URLS) { assert.ok(API_SRC.includes(`'${u}'`), `card-api.js 缺少端点 ${u}(与后端文档不符)`); } }); test('收口: card-api.js 不得出现文档之外的 /card/api/ 路径', () => { const found = API_SRC.match(/'\/card\/api\/[^']+'/g) || []; const unknown = found.map(s => s.slice(1, -1)).filter(u => !DOC_URLS.includes(u)); assert.deepEqual(unknown, [], `存在文档外路径: ${unknown.join(', ')}`); assert.equal(found.length, DOC_URLS.length, `端点数量应为 ${DOC_URLS.length}, 实际 ${found.length}(防止漏收/多收)`); }); // ============================================================= // 2. 每个端点都有 helper(收口不是只建常量表) // ============================================================= test('收口: 15 个端点均有对应 helper 方法', () => { for (const [helper, urlConst] of HELPER_URL_PAIRS) { assert.ok(new RegExp(`\\b${helper}\\(p\\)\\s*\\{`).test(API_SRC), `缺少 helper ${helper}(p)`); assert.ok(new RegExp(`url:\\s*CARD_API_URL\\.${urlConst}\\b`).test(API_SRC), `helper ${helper} 未绑定 ${urlConst}`); } }); test('收口: 嵌套入参 helper 走 sendJson 通道', () => { for (const helper of JSON_BODY_HELPERS) { const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`)); assert.ok(m, `应能提取 ${helper} 方法体`); assert.ok(/Http\.sendJson\(/.test(m[1]), `${helper} 必须走 Http.sendJson(JSON body), 否则嵌套 fields 跨端序列化有歧义`); } }); test('收口: 普通入参 helper 走 sendRequest 通道', () => { const formHelpers = HELPER_URL_PAIRS .map(p => p[0]) .filter(h => !JSON_BODY_HELPERS.includes(h)); for (const helper of formHelpers) { const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`)); assert.ok(m, `应能提取 ${helper} 方法体`); assert.ok(/Http\.sendRequest\(/.test(m[1]), `${helper} 应走 Http.sendRequest`); assert.ok(!/Http\.sendJson\(/.test(m[1]), `${helper} 不应走 sendJson(无嵌套入参)`); } }); // ============================================================= // 3. 页面零字面量直连 // ============================================================= test('收口: 7 个业务页面不得再直连 /card/api/ 字面量', () => { const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue', 'contacts.vue', 'contact-edit.vue']; for (const f of files) { const src = read(f); assert.ok(!/['"]\/card\/api\//.test(src), `${f} 仍直连 /card/api/ 字面量, 必须走 this.$cardApi(后端 checklist 第 1 条)`); } }); test('收口: 有接口调用的 6 个页面全部走 this.$cardApi', () => { // share.vue 是 web-view 中转页, 无业务接口调用, 不在此列 const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'contacts.vue', 'contact-edit.vue']; for (const f of files) { const src = read(f); assert.ok(/this\.\$cardApi\./.test(src), `${f} 未使用 this.$cardApi`); } }); test('收口: main.js 注册 Vue.prototype.$cardApi', () => { assert.ok(/import\s+CardApi\s+from\s+'\.\/common\/js\/card-api\.js'/.test(MAIN_SRC), 'main.js 应 import card-api.js'); assert.ok(/Vue\.prototype\.\$cardApi\s*=\s*CardApi/.test(MAIN_SRC), 'main.js 应注册 Vue.prototype.$cardApi'); }); // ============================================================= // 4. 字典与后端白名单一致 // ============================================================= test('字典: SOCIAL_TYPES 与后端 CardRichFields::SOCIAL_TYPES 白名单一致', () => { const keys = [...API_SRC.matchAll(/\{\s*key:\s*'([a-z]+)',\s*label:/g)].map(m => m[1]); // 2026-10-13: telegram / messenger 由独立标量软下线, 并入 socials 白名单 const expected = ['alipay', 'douyin', 'wxvideo', 'xiaohongshu', 'weibo', 'kuaishou', 'bilibili', 'qq', 'linkedin', 'facebook', 'instagram', 'x', 'telegram', 'messenger', 'other']; assert.deepEqual(keys, expected, 'SOCIAL_TYPES 增减必须与后端同步'); }); test('字典: CARD_FIELDS 不含 telegram/messenger(2026-10-13 软下线)', () => { const block = API_SRC.match(/export const CARD_FIELDS = \[([\s\S]*?)\];/); assert.ok(block, 'CARD_FIELDS 应存在'); const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]); assert.ok(!values.includes('telegram'), 'telegram 已软下线为独立标量, 不得出现在 CARD_FIELDS'); assert.ok(!values.includes('messenger'), 'messenger 已软下线为独立标量, 不得出现在 CARD_FIELDS'); // 其余标量不受影响 for (const f of ['name', 'company', 'job_title', 'avatar', 'mobile', 'phone', 'email', 'website', 'wechat', 'country', 'province', 'city', 'address', 'postal_code', 'bio']) { assert.ok(values.includes(f), `CARD_FIELDS 应保留 ${f}`); } }); test('软下线: edit.vue 不再提交 telegram/messenger 标量, 字典从收口层导入', () => { const editSrc = fs.readFileSync(path.join(CARD_DIR, 'edit.vue'), 'utf8'); assert.ok(!/form\.telegram/.test(editSrc), 'edit.vue 不得再绑 form.telegram(软下线)'); assert.ok(!/form\.messenger/.test(editSrc), 'edit.vue 不得再绑 form.messenger(软下线)'); assert.ok(!/fields\.telegram/.test(editSrc) && !/fields\.messenger/.test(editSrc), 'edit.vue 保存时不得再提交 telegram/messenger 标量'); assert.ok(/import\s*\{\s*SOCIAL_TYPES,\s*CARD_FIELDS,\s*RICH_FIELD_LIMITS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(editSrc), 'edit.vue 应从 card-api.js 导入 SOCIAL_TYPES/CARD_FIELDS/RICH_FIELD_LIMITS'); // 模板里不得再有 Telegram / Messenger 独立输入行 assert.ok(!/Telegram<\/view>/.test(editSrc), '模板不得再有 Telegram 独立输入行'); assert.ok(!/Messenger<\/view>/.test(editSrc), '模板不得再有 Messenger 独立输入行'); }); test('软下线: detail.vue 不再单独渲染 telegram/messenger 行(旧值已并入 socials_list)', () => { const detailSrc = fs.readFileSync(path.join(CARD_DIR, 'detail.vue'), 'utf8'); assert.ok(!/push\('Telegram'/.test(detailSrc), 'detail.vue 不得再单独渲染 Telegram 行'); assert.ok(!/push\('Messenger'/.test(detailSrc), 'detail.vue 不得再单独渲染 Messenger 行'); // 社交账号统一走 socials_list(后端已合并旧值) assert.ok(/r\.socials_list/.test(detailSrc), 'detail.vue 应继续消费 socials_list'); }); test('字典: LEAD_STATUS 与后端 Lead::STATUSES 一致(0-3)', () => { const block = API_SRC.match(/export const LEAD_STATUS = \[([\s\S]*?)\];/); assert.ok(block, 'LEAD_STATUS 应存在'); const values = [...block[1].matchAll(/value:\s*(\d)/g)].map(m => Number(m[1])); assert.deepEqual(values, [0, 1, 2, 3]); // setStatus 只接受 0/1/2/3, 其它值后端报「参数不合法」 assert.ok(/status\?\(0-3\)/.test(API_SRC), 'leadLists 文档注释应标注 status 0-3'); }); test('字典: CONTACT_SOURCES 与后端 Contact::SOURCES 一致', () => { const block = API_SRC.match(/export const CONTACT_SOURCES = \[([\s\S]*?)\];/); assert.ok(block, 'CONTACT_SOURCES 应存在'); const values = [...block[1].matchAll(/value:\s*'([a-z_]+)'/g)].map(m => m[1]); assert.deepEqual(values, ['manual', 'lead_in', 'scan', 'exchange']); }); test('字典: CONTACT_FIELDS 与后端 Contact::EDITABLE_FIELDS 一致', () => { const block = API_SRC.match(/export const CONTACT_FIELDS = \[([\s\S]*?)\];/); assert.ok(block, 'CONTACT_FIELDS 应存在'); const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]); assert.deepEqual(values, ['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status']); }); // ============================================================= // 5. 文档关键语义在收口层有注释固化(防后人改错) // ============================================================= test('语义: card/update 的 normalize 规则写在收口层注释里', () => { // 键存在才写 / 空串=清空 / 键缺席=不擦除(历史事故定稿) assert.ok(/键存在才写/.test(API_SRC) && /空串=清空/.test(API_SRC) && /键缺席=不擦除/.test(API_SRC), 'cardUpdate 注释必须固化 normalize 三语义'); }); test('语义: recognize 的 deduped 提示写在收口层注释里', () => { assert.ok(/deduped=true 表示站点内已有同手机号名片/.test(API_SRC), 'cardRecognize 注释必须提示 deduped 语义'); }); test('语义: 名片用 card_id 字符串 / 人脉线索用 id 数字的区分写在注释里', () => { assert.ok(/card_id(字符串),人脉\/线索用 id(数字)/.test(API_SRC), '收口层注释必须固化主键类型区分(后端 checklist 第 9 条)'); }); // ============================================================= // 6. 走 sendJson 的 helper: 页面必须把 payload 包在 data 里 // ============================================================= /** * 提取 `$cardApi.` 调用的实参文本(括号配对)。 * 兼容两种写法: `this.$cardApi.cardUpdate({...})` 与三元分支 * `(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({...})` * —— 后者 helper 名后紧跟的是 ')' 而不是 '(', 故先定位名字再找其后第一个 '('。 */ function callArgs(src, helper) { const at = src.indexOf('$cardApi.' + helper); if (at < 0) return null; const start = src.indexOf('(', at); if (start < 0) return null; let depth = 0; for (let i = start; i < src.length; i++) { const c = src[i]; if (c === '(') depth++; else if (c === ')') { depth--; if (depth === 0) return src.slice(start, i + 1); } } return null; } test('入参: 走 sendJson 的 helper 必须把 payload 包在 data 里', () => { // 历史事故(2026-10-13): edit.vue 保存时把 fields 挂在顶层传参, 而 // Http.sendJson 只 Object.assign(data, params.data)(见 common/js/http.js), // 顶层键被整体丢弃 → 请求体里没有 fields → 后端按空 name 校验, // 前端弹「保存失败」、Network 里看到 {code:-1, data:"请填写姓名"}。 const CALLERS = { 'edit.vue': ['cardCreate', 'cardUpdate'], 'contact-edit.vue': ['contactAdd', 'contactUpdate'] }; for (const [f, helpers] of Object.entries(CALLERS)) { const src = read(f); for (const h of helpers) { const args = callArgs(src, h); assert.ok(args, `${f} 应能定位 $cardApi.${h} 调用`); assert.ok(/data\s*:/.test(args), `${f} 的 $cardApi.${h} 必须传 data: {...}; 顶层挂 fields 会被 sendJson 丢弃`); } } });