Files
lucky_shop/tests/card-share-card-id.test.js

431 lines
19 KiB
JavaScript

/**
* 名片分享带卡 ID · 前端契约测试(2026-10-28)
*
* 背景:
* 小程序端「我的名片」组件(diy-card-self)的「发送名片」按钮是 <button open-type="share">,
* 微信只认**页面级** onShareAppMessage。承载该组件的 DIY 页自带 onShareAppMessage
* (common/js/diy.js) → Vue2 defaultStrat(子覆盖父)会顶掉全局兜底 mixin
* (common/js/mp-share-default.js)里那段 dataset.cardId 分支
* → 分享 path 不带 card_id, 访客打开看到的是企业兜底卡而不是分享者那张卡。
*
* 修法(前端侧):
* ① common/js/card-share.js(新增): 三条取 card_id 的通道
* dataset(发送给朋友) / storage(朋友圈) / 路由 query(落地回传)
* ② common/js/diy.js: 页面级分享钩子把 card_id 拼进分享 path
* ③ components-diy/diy-card-mini.vue: 分享按钮 @tap 记住当前卡片(storage 通道)
* ④ components-diy/diy-card-self.vue: 从路由 query 取 card_id 回传 /card/api/diy/mycard
*
* 跑法: node --test tests/card-share-card-id.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 read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
const strip = s => s
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
/**
* card-share.js 是纯 ESM(无 import) —— 去掉 export 关键字后用 new Function 求值,
* 直接跑真实逻辑(不复制源码, 避免测试与实现漂移)。
* uni / getCurrentPages 以参数注入, 天然完成 mock。
*/
function loadCardShare(uni, getCurrentPages) {
const src = read('common/js/card-share.js').replace(/export\s+(const|function)\s/g, '$1 ');
const factory = new Function(
'uni', 'getCurrentPages',
src + `
return {
SHARE_CARD_STORAGE_KEY, SHARE_CARD_MEMORY_TTL,
rememberShareCardId, takeRememberedShareCardId, pickShareCardId,
routeQueryValue, appendPathParam, appendQueryParam, pathQueryPart
};`
);
return factory(uni, getCurrentPages);
}
function makeUni() {
const store = new Map();
return {
store,
setStorageSync(k, v) { store.set(k, v); },
getStorageSync(k) { return store.has(k) ? store.get(k) : ''; },
removeStorageSync(k) { store.delete(k); },
};
}
// =============================================================
// A. card-share.js 真实行为
// =============================================================
test('A1: 记住 → 取出 往返一致', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
assert.equal(rememberShareCardId('C_1_shop_20261013111612_9588EA'), 'C_1_shop_20261013111612_9588EA');
assert.equal(takeRememberedShareCardId(), 'C_1_shop_20261013111612_9588EA');
});
test('A2: 取出后即清除(下一次分享不串卡)', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_A');
assert.equal(takeRememberedShareCardId(), 'C_A');
assert.equal(takeRememberedShareCardId(), '', '第二次取应为空');
});
test('A3: 空 card_id 不入记忆槽', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
assert.equal(rememberShareCardId(''), '');
assert.equal(rememberShareCardId(null), '');
assert.equal(takeRememberedShareCardId(), '');
});
test('A4: 记忆过期则不再用于分享(TTL)', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_OLD');
// 把时间戳改老(10 分钟前)
uni.store.set('_cardShareCardId', { card_id: 'C_OLD', at: Date.now() - 10 * 60 * 1000 });
assert.equal(takeRememberedShareCardId(), '', '过期记忆应被丢弃');
});
test('A5: pickShareCardId 优先取 dataset(发送给朋友)', () => {
const uni = makeUni();
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_FROM_STORAGE');
const opts = { from: 'button', target: { dataset: { cardId: 'C_FROM_DATASET' } } };
assert.equal(pickShareCardId(opts), 'C_FROM_DATASET');
});
test('A6: pickShareCardId 无 dataset 时回落 storage(朋友圈通道)', () => {
const uni = makeUni();
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_FROM_STORAGE');
assert.equal(pickShareCardId(null), 'C_FROM_STORAGE');
// storage 通道是"读一次即清"语义 → 第二次取前需重新记忆
rememberShareCardId('C_FROM_STORAGE');
assert.equal(pickShareCardId({ from: 'menu' }), 'C_FROM_STORAGE');
});
test('A6b: 第三级兜底 — storage 空且落地 query 带 card_id → 续传(右上角转发 S5-2)', () => {
// 场景: 访客经分享落地(query 带 card_id), 未点过任何分享按钮(storage 空),
// 直接右上角「···」转发 → 应自动续传落地页的源头卡
const uni = makeUni();
const { pickShareCardId } = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { id: '3', card_id: 'C_LANDING' },
}]));
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
assert.equal(pickShareCardId(null), 'C_LANDING');
});
test('A6c: 三级优先级 dataset > storage > 落地 query', () => {
const uni = makeUni();
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { card_id: 'C_LANDING' },
}]));
// query 在, 但 dataset 最准 → dataset 胜
assert.equal(pickShareCardId({ target: { dataset: { cardId: 'C_DATASET' } } }), 'C_DATASET');
// storage 在(刚点过按钮), dataset 无 → storage 胜(按钮意图更近)
rememberShareCardId('C_FROM_BTN');
assert.equal(pickShareCardId(null), 'C_FROM_BTN');
// 都没有 → 落地 query
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
});
test('A7: pickShareCardId 三路都没有 → 空', () => {
const uni = makeUni();
const { pickShareCardId } = loadCardShare(uni, () => []);
assert.equal(pickShareCardId({ from: 'menu' }), '');
assert.equal(pickShareCardId(null), '');
});
test('A8: routeQueryValue 从当前页 options 取值(分享落地页)', () => {
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
route: 'pages_tool/index/diy',
options: { id: '123', name: 'home', card_id: 'C_1_shop_x' },
}]));
assert.equal(routeQueryValue('card_id'), 'C_1_shop_x');
assert.equal(routeQueryValue('id'), '123');
assert.equal(routeQueryValue('not_exist'), '');
});
test('A9: routeQueryValue 兼容字符串形态 query', () => {
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
route: 'pages_tool/index/diy',
query: 'id=123&card_id=C_2',
}]));
assert.equal(routeQueryValue('card_id'), 'C_2');
});
test('A10: routeQueryValue 无页面 / 无 uni 时安全返回空', () => {
const { routeQueryValue } = loadCardShare(makeUni(), () => []);
assert.equal(routeQueryValue('card_id'), '');
});
test('A11: appendPathParam 拼 path(带/不带 query 两种形态)', () => {
const { appendPathParam } = loadCardShare(makeUni(), () => []);
assert.equal(
appendPathParam('/pages_tool/index/diy?id=123', 'card_id', 'C_1'),
'/pages_tool/index/diy?id=123&card_id=C_1'
);
assert.equal(
appendPathParam('/pages_tool/index/diy', 'card_id', 'C_1'),
'/pages_tool/index/diy?card_id=C_1'
);
assert.equal(appendPathParam('/pages_tool/index/diy?id=1', 'card_id', ''), '/pages_tool/index/diy?id=1');
});
test('A12: appendQueryParam 拼 query(朋友圈只认 query)', () => {
const { appendQueryParam } = loadCardShare(makeUni(), () => []);
assert.equal(appendQueryParam('id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
assert.equal(appendQueryParam('', 'card_id', 'C_1'), 'card_id=C_1');
assert.equal(appendQueryParam('?id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
});
test('A13: pathQueryPart 取 path 的 query 段', () => {
const { pathQueryPart } = loadCardShare(makeUni(), () => []);
assert.equal(pathQueryPart('/pages_tool/index/diy?id=1&name=x'), 'id=1&name=x');
assert.equal(pathQueryPart('/pages_tool/index/diy'), '');
});
// =============================================================
// B. DIY 页分享钩子(common/js/diy.js)
// =============================================================
/**
* 把 diy.js 的两个分享钩子抠出来**真跑**(diy.js 本体 import 了地图/配置等模块,
* node 里无法直接 import; 这里只抠钩子源码, 用 card-share.js 的真实实现注入)。
*/
function loadDiyShareHooks() {
const src = read('common/js/diy.js');
const start = src.indexOf('onShareAppMessage(options) {');
// 两个钩子是文件最后两个方法 → 一直取到导出对象的最后一个 '}' 之前
const end = src.lastIndexOf('}');
assert.ok(start > 0 && end > start, '未能定位 diy.js 的分享钩子');
const hooksSrc = src.slice(start, end);
const share = loadCardShare(makeUni(), () => []);
const factory = new Function(
'pickShareCardId', 'appendPathParam', 'appendQueryParam', 'pathQueryPart',
'return {' + hooksSrc + '};'
);
const hooks = factory(share.pickShareCardId, share.appendPathParam, share.appendQueryParam, share.pathQueryPart);
return { hooks, share };
}
/** 模拟一个 DIY 页实例的上下文 */
function makeDiyCtx() {
return {
mpShareData: {
appMessage: { title: '店铺名', path: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
timeLine: { title: '店铺名', query: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
},
$util: {
getCurrentRoute: () => ({ path: '/pages_tool/index/diy?id=3&name=home', query: 'id=3&name=home' }),
},
};
}
test('B1: diy.js 引入名片分享工具', () => {
const src = read('common/js/diy.js');
assert.match(src, /import\s*\{[^}]*pickShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
'未引入 card-share.js 的 pickShareCardId');
});
test('B2: onShareAppMessage 接 options 并按 dataset/storage 取 card_id', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /onShareAppMessage\(options\)\s*\{/,
'onShareAppMessage 未接事件参数(拿不到 dataset)');
assert.match(src, /pickShareCardId\(options\)/,
'onShareAppMessage 未从事件里取 card_id');
});
test('B3: onShareAppMessage 把 card_id 拼进分享 path', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /appendPathParam\([^)]*'card_id',\s*cardId\)/,
'分享 path 未追加 card_id');
});
test('B4: onShareTimeline 走 storage 通道并把 card_id 拼进 query', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /onShareTimeline\(\)\s*\{/);
assert.match(src, /pickShareCardId\(null\)/,
'onShareTimeline 未从 storage 取 card_id(它不接受事件参数)');
assert.match(src, /appendQueryParam\([^)]*'card_id',\s*cardId\)/,
'朋友圈 query 未追加 card_id');
});
test('B5: 拿不到 card_id 时仍回落原分享数据(向后兼容)', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /return this\.mpShareData\?\.appMessage;/,
'未保留原有 appMessage 回落');
assert.match(src, /return base;/,
'未保留原有 timeLine 回落');
});
test('B6: 行为级 — 钩子真跑: dataset 命中 → path 带 card_id', () => {
const { hooks, share } = loadDiyShareHooks();
const ctx = makeDiyCtx();
const res = hooks.onShareAppMessage.call(ctx, {
from: 'button',
target: { dataset: { cardId: 'C_1_shop_x', cardName: '张三' } },
});
assert.equal(res.path, '/pages_tool/index/diy?id=3&name=home&card_id=C_1_shop_x',
'分享 path 未带上当前页 query + card_id');
assert.equal(res.title, '店铺名');
});
test('B7: 行为级 — 无 card_id 时原样返回后台配置的分享数据', () => {
const { hooks } = loadDiyShareHooks();
const ctx = makeDiyCtx();
assert.equal(hooks.onShareAppMessage.call(ctx, { from: 'menu' }), ctx.mpShareData.appMessage,
'无 card_id 时应原样返回 mpShareData.appMessage');
});
test('B8: 行为级 — 朋友圈钩子把 card_id 拼进 query', () => {
const { hooks, share } = loadDiyShareHooks();
const ctx = makeDiyCtx();
share.rememberShareCardId('C_1_shop_x');
const res = hooks.onShareTimeline.call(ctx);
assert.equal(res.query, 'id=3&card_id=C_1_shop_x',
'朋友圈 query 未带 card_id');
assert.equal(res.title, '店铺名');
});
test('B9: 行为级 — 朋友圈无 card_id 时原样回落', () => {
const { hooks } = loadDiyShareHooks();
const ctx = makeDiyCtx();
assert.equal(hooks.onShareTimeline.call(ctx), ctx.mpShareData.timeLine);
});
// =============================================================
// B+. H5 公众号分享链接补 card_id(setPublicShare, S10-1)
// =============================================================
/** 把 diy.js 的 setPublicShare 抠出来真跑(同 loadDiyShareHooks 思路) */
function loadDiySetPublicShare(share) {
const src = read('common/js/diy.js');
const start = src.indexOf('setPublicShare() {');
const end = src.indexOf('onPageScroll(e) {');
assert.ok(start > 0 && end > start, '未能定位 diy.js 的 setPublicShare');
// 剥注释后, 丢掉切片尾部的外层 methods 闭括号(setPublicShare 自己的 '}' 是倒数第二个)
let body = strip(src.slice(start, end));
body = body.slice(0, body.lastIndexOf('}')).trim();
const factory = new Function('routeQueryValue', 'appendPathParam', 'return {' + body + '};');
// 工厂返回 { setPublicShare: fn } 对象, 这里直接交出方法本体
return factory(share.routeQueryValue, share.appendPathParam).setPublicShare;
}
test('B10: diy.js 引入 routeQueryValue 且 setPublicShare 补 card_id', () => {
const src = read('common/js/diy.js');
assert.match(src, /import\s*\{[^}]*routeQueryValue[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
'未引入 card-share.js 的 routeQueryValue');
const stripped = strip(src);
const start = stripped.indexOf('setPublicShare()');
const end = stripped.indexOf('onPageScroll');
assert.ok(start > 0 && end > start, '未定位到 setPublicShare');
const seg = stripped.slice(start, end);
assert.match(seg, /routeQueryValue\('card_id'\)/, 'setPublicShare 未从落地 query 取 card_id');
assert.match(seg, /appendPathParam\(shareUrl, 'card_id', shareCardId\)/, '分享链接未追加 card_id');
});
test('B11: 行为级 — H5 落地带 card_id 时公众号分享链接续传', () => {
const uni = makeUni();
const share = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { id: '3', card_id: 'C_1_shop_x' },
}]));
const setPublicShare = loadDiySetPublicShare(share);
let captured = null;
const ctx = {
$config: { h5Domain: 'https://h5.example.com' },
diyRoute: '/pages_tool/index/diy',
id: 3,
name: '',
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
$langConfig: { getField: () => '标题' },
diyData: { global: {} },
siteInfo: null,
};
setPublicShare.call(ctx);
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3&card_id=C_1_shop_x',
'H5 分享链接未续传落地 card_id');
});
test('B12: 行为级 — 裸 Tab(无 card_id)时 H5 分享链接行为零变化', () => {
const uni = makeUni();
const share = loadCardShare(uni, () => ([]));
const setPublicShare = loadDiySetPublicShare(share);
let captured = null;
const ctx = {
$config: { h5Domain: 'https://h5.example.com' },
diyRoute: '/pages_tool/index/diy',
id: 3,
name: '',
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
$langConfig: { getField: () => '标题' },
diyData: { global: {} },
siteInfo: null,
};
setPublicShare.call(ctx);
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3',
'无 card_id 时链接应保持原样(不得追加空参数)');
});
// =============================================================
// C. 名片基件的分享按钮(diy-card-mini.vue)
// =============================================================
test('C1: 小程序端分享按钮带 data-card-id + @tap 记忆', () => {
const src = strip(read('components-diy/diy-card-mini.vue'));
assert.match(src, /open-type="share"/, '分享按钮丢了 open-type=share');
assert.match(src, /:data-card-id="card && card\.card_id"/,
'分享按钮未带 data-card-id(dataset 通道依赖它)');
assert.match(src, /@tap="rememberShareCard"/,
'分享按钮未在 tap 时记住卡片(storage 通道依赖它)');
});
test('C2: diy-card-mini 引入 rememberShareCardId 并实现 rememberShareCard', () => {
const src = read('components-diy/diy-card-mini.vue');
assert.match(src, /import\s*\{[^}]*rememberShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/);
assert.match(src, /rememberShareCard\(\)\s*\{[\s\S]*?rememberShareCardId\(this\.card && this\.card\.card_id\)/);
});
// =============================================================
// D. 「我的名片」组件把分享带来的 card_id 回传后端(diy-card-self.vue)
// =============================================================
test('D1: diy-card-self 从路由 query 取 card_id 并传给 mycard', () => {
const src = strip(read('components-diy/diy-card-self.vue'));
assert.match(src, /routeQueryValue\('card_id'\)/,
'未从路由 query 取分享带来的 card_id');
assert.match(src, /card_id:\s*shareCardId/,
'请求参数未带 card_id');
});
test('D2: 分享卡优先于登录引导空态(fallbackMode=none 也照常取分享卡)', () => {
const src = strip(read('components-diy/diy-card-self.vue'));
assert.match(src, /!this\.hasToken && this\.fallbackMode === 'none' && !shareCardId/,
'登录引导早退条件未排除"有分享卡"的情况');
});
// =============================================================
// E. 全局兜底 mixin 的 dataset 分支仍在(其它页面继续受益)
// =============================================================
test('E1: mp-share-default 的 dataset.cardId 分支未被破坏', () => {
const src = strip(read('common/js/mp-share-default.js'));
assert.match(src, /options\.target\.dataset\.cardId/,
'全局兜底 mixin 的 dataset 分支丢了');
assert.match(src, /path:\s*'\/pages_tool\/card\/detail\?card_id=' \+ d\.cardId/);
});