325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
/**
|
||
* 名片云 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/card/followDone', // 跟进完成态 2.11.3
|
||
'/card/api/card/views', // 访客轨迹 2.11.4
|
||
// 人脉库 /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',
|
||
'/card/api/apply/submit', // 员工建卡申请 2.11.15
|
||
'/card/api/apply/status', // 员工建卡申请 2.11.15
|
||
'/card/api/design/presets', // 名片预设列表(同源后台 CardTemplatePresets, 后端 v2 无 /card/ 段)
|
||
'/card/api/design/save' // 应用预设/保存本卡样式
|
||
];
|
||
|
||
// 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'],
|
||
['cardFollowDone', 'CARD_FOLLOW_DONE'],
|
||
['cardViews', 'CARD_VIEWS'],
|
||
['applySubmit', 'APPLY_SUBMIT'],
|
||
['applyStatus', 'APPLY_STATUS'],
|
||
['cardDesignPresets', 'CARD_DESIGN_PRESETS'],
|
||
['cardDesignSave', 'CARD_DESIGN_SAVE']
|
||
];
|
||
|
||
// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson)
|
||
const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore', 'cardDesignSave'];
|
||
|
||
// =============================================================
|
||
// 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(!/<view class="row-label">Telegram<\/view>/.test(editSrc),
|
||
'模板不得再有 Telegram 独立输入行');
|
||
assert.ok(!/<view class="row-label">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.<helper>` 调用的实参文本(括号配对)。
|
||
* 兼容两种写法: `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 丢弃`);
|
||
}
|
||
}
|
||
});
|