Files
lucky_shop/tests/card-api.test.js

325 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 名片云 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 丢弃`);
}
}
});