文件
OmniCard/backend/test/mpuniapp/portal.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

633 行
23 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* OmniCard AI (CRMmp) - mpuniapp 微官网 Bento 首页与 Hero 身份区测试套件 (TDD Red -> Green)
*
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD 3.1、5.1 设计编写
*/
describe('OmniCard AI 用户端微官网 Bento 首页与 Hero 业务逻辑测试 (usePortal.uts)', () => {
let storageMap: Map<string, string>;
let toastCalls: Array<{ title: string; icon?: string }>;
let makePhoneCallParam: { phoneNumber: string } | null;
let clipboardText: string;
let audioContextMock: any;
let mockRequestResponse: {
statusCode?: number;
data?: any;
failError?: any;
};
beforeEach(() => {
storageMap = new Map();
toastCalls = [];
makePhoneCallParam = null;
clipboardText = '';
audioContextMock = {
src: '',
play: jest.fn(),
pause: jest.fn(),
stop: jest.fn(),
destroy: jest.fn(),
onPlay: jest.fn((cb: () => void) => {
audioContextMock._onPlayCb = cb;
}),
onEnded: jest.fn((cb: () => void) => {
audioContextMock._onEndedCb = cb;
}),
onError: jest.fn((cb: (err: any) => void) => {
audioContextMock._onErrorCb = cb;
}),
_onPlayCb: null,
_onEndedCb: null,
_onErrorCb: null,
};
mockRequestResponse = {
statusCode: 200,
data: {
code: 1000,
message: 'success',
data: {
id: 'card-101',
name: 'Alex Vance',
title: 'Managing Director & Global Partner',
company: 'Nexus Tech Global Ltd',
timezone: 'Dubai UTC+4',
audioPronunciationUrl: 'https://cdn.omnicard.ai/audio/alex.mp3',
phone: '+971501234567',
email: 'alex.vance@nexustech.global',
whatsapp: '+971501234567',
linkedin: 'https://linkedin.com/in/alexvance',
status: 'published',
bentoBlocks: [
{
id: 1,
cardId: 'card-101',
type: 'hero',
colSpan: 2,
rowSpan: 1,
sortOrder: 0,
title: 'Hero Block',
},
{
id: 2,
cardId: 'card-101',
type: 'case',
colSpan: 2,
rowSpan: 1,
sortOrder: 1,
title: 'Middle East Energy Hub',
content: 'Built scalable renewable energy microgrid in UAE.',
},
{
id: 3,
cardId: 'card-101',
type: 'article',
colSpan: 1,
rowSpan: 1,
sortOrder: 2,
title: 'The Future of Cross-Border Trade',
},
{
id: 4,
cardId: 'card-101',
type: 'social',
colSpan: 1,
rowSpan: 1,
sortOrder: 3,
title: 'Global Social Matrix',
},
],
},
},
};
// 构造全局 uni 运行时 Mock 环境
(global as any).uni = {
getStorageSync: (key: string) => storageMap.get(key) || '',
setStorageSync: (key: string, val: string) => storageMap.set(key, val),
removeStorageSync: (key: string) => storageMap.delete(key),
showToast: (options: { title: string; icon?: string }) => {
toastCalls.push(options);
},
reLaunch: jest.fn(),
makePhoneCall: (options: { phoneNumber: string }) => {
makePhoneCallParam = options;
},
setClipboardData: (options: { data: string; success?: () => void }) => {
clipboardText = options.data;
options.success?.();
},
createInnerAudioContext: () => audioContextMock,
request: (options: any) => {
setTimeout(() => {
if (mockRequestResponse.failError) {
options.fail?.(mockRequestResponse.failError);
} else {
options.success?.({
statusCode: mockRequestResponse.statusCode ?? 200,
data: mockRequestResponse.data,
header: {},
});
}
options.complete?.();
}, 0);
return {
abort: jest.fn(),
};
},
};
});
afterEach(() => {
delete (global as any).uni;
});
describe('1. 访客标识识别与生成 (getOrCreateVisitorId)', () => {
it('1.1 若本地无 visitorId,则自动生成持久化并返回', () => {
// @ts-ignore
const { getOrCreateVisitorId } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const id = getOrCreateVisitorId();
expect(id).toBeDefined();
expect(id.startsWith('vis_')).toBe(true);
expect(storageMap.get('omni_visitor_id')).toBe(id);
});
it('1.2 若本地已有 visitorId,则直接复用', () => {
// @ts-ignore
const { getOrCreateVisitorId } = require('../../../mpuniapp/pages/portal/usePortal.uts');
storageMap.set('omni_visitor_id', 'vis_existing_7788');
const id = getOrCreateVisitorId();
expect(id).toBe('vis_existing_7788');
});
});
describe('2. 微官网数据拉取与雷达初次触碰上报 (loadCardPortal)', () => {
it('2.1 成功拉取名片聚合详情并自动上报首次雷达轨迹', async () => {
// @ts-ignore
const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const state = createPortalState();
expect(state.loading).toBe(false);
expect(state.card).toBeNull();
const loadPromise = loadCardPortal(state, 'card-101', 'qr');
expect(state.loading).toBe(true);
const result = await loadPromise;
expect(state.loading).toBe(false);
expect(state.errorMessage).toBe('');
expect(result).not.toBeNull();
expect(state.card?.id).toBe('card-101');
expect(state.card?.name).toBe('Alex Vance');
expect(state.touchType).toBe('qr');
});
it('2.2 网络异常或404时正确设置错误提示并结束 loading', async () => {
// @ts-ignore
const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts');
mockRequestResponse = {
statusCode: 404,
data: {
code: 404,
message: '名片不存在或未公开',
},
};
const state = createPortalState();
const result = await loadCardPortal(state, 'non-existing-id');
expect(state.loading).toBe(false);
expect(result).toBeNull();
expect(state.card).toBeNull();
expect(state.errorMessage).toContain('名片不存在');
});
});
describe('3. 访客雷达停留时长与深度上报 (reportPortalStay)', () => {
it('3.1 页面停留后上报有效停留毫秒数与滚动深度', async () => {
// @ts-ignore
const { createPortalState, loadCardPortal, reportPortalStay } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const state = createPortalState();
await loadCardPortal(state, 'card-101', 'link');
// 模拟停留 500ms
state.enterTime = Date.now() - 500;
const track = await reportPortalStay(state, 85);
expect(track).toBeDefined();
});
it('3.2 若名片尚未成功加载,上报应安全返回 null 而不抛出异常', async () => {
// @ts-ignore
const { createPortalState, reportPortalStay } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const state = createPortalState();
const track = await reportPortalStay(state, 0);
expect(track).toBeNull();
});
});
describe('4. 名字发音试听控制 (toggleAudioPronunciation)', () => {
it('4.1 成功创建音频上下文并播放,更新 isPlayingAudio 状态', () => {
// @ts-ignore
const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const state = createPortalState();
const audioUrl = 'https://cdn.omnicard.ai/audio/alex.mp3';
toggleAudioPronunciation(state, audioUrl);
expect(audioContextMock.src).toBe(audioUrl);
expect(audioContextMock.play).toHaveBeenCalled();
expect(state.isPlayingAudio).toBe(true);
// 再次点击应触发暂停/停止
toggleAudioPronunciation(state, audioUrl);
expect(audioContextMock.stop).toHaveBeenCalled();
expect(state.isPlayingAudio).toBe(false);
});
it('4.2 音频播放完毕或异常回调时自动复位 isPlayingAudio 状态', () => {
// @ts-ignore
const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const state = createPortalState();
toggleAudioPronunciation(state, 'https://cdn.omnicard.ai/audio/alex.mp3');
expect(state.isPlayingAudio).toBe(true);
// 触发 onEnded 回调
audioContextMock._onEndedCb?.();
expect(state.isPlayingAudio).toBe(false);
});
it('4.3 若未提供 audioUrl,安全退出不创建上下文', () => {
// @ts-ignore
const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const state = createPortalState();
toggleAudioPronunciation(state, '');
expect(audioContextMock.play).not.toHaveBeenCalled();
expect(state.isPlayingAudio).toBe(false);
});
});
describe('5. 快捷动作条触发与呼出交互 (triggerQuickAction)', () => {
it('5.1 点击电话直接调用原生 makePhoneCall', () => {
// @ts-ignore
const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const ok = triggerQuickAction('phone', '+971501234567');
expect(ok).toBe(true);
expect(makePhoneCallParam?.phoneNumber).toBe('+971501234567');
});
it('5.2 点击 WhatsApp 或 LinkedIn 将安全处理并提示', () => {
// @ts-ignore
const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const okWa = triggerQuickAction('whatsapp', '+971501234567');
expect(okWa).toBe(true);
const okIn = triggerQuickAction('linkedin', 'https://linkedin.com/in/alexvance');
expect(okIn).toBe(true);
});
it('5.3 字段为空时给出清晰 Toast 提示而不报错', () => {
// @ts-ignore
const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const ok = triggerQuickAction('phone', '');
expect(ok).toBe(false);
expect(toastCalls.length).toBeGreaterThan(0);
expect(toastCalls[0].title).toContain('未设置');
});
});
describe('6. Bento Grid 跨列与数据过滤规则 (getBentoColSpanClass & filterVisibleBentoBlocks)', () => {
it('6.1 colSpan 计算:1 为半宽,2 为全宽', () => {
// @ts-ignore
const { getBentoColSpanClass } = require('../../../mpuniapp/pages/portal/usePortal.uts');
expect(getBentoColSpanClass(1)).toBe('bento-col-1');
expect(getBentoColSpanClass(2)).toBe('bento-col-2');
// 默认/兜底
expect(getBentoColSpanClass(0 as any)).toBe('bento-col-1');
});
it('6.2 过滤 Hero 类型的块,并按 sortOrder 升序排列其余 Bento 块', () => {
// @ts-ignore
const { filterVisibleBentoBlocks } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const rawBlocks = [
{ id: 1, type: 'hero', sortOrder: 0, title: 'Hero' },
{ id: 4, type: 'social', sortOrder: 3, title: 'Social' },
{ id: 2, type: 'case', sortOrder: 1, title: 'Case' },
{ id: 3, type: 'article', sortOrder: 2, title: 'Article' },
];
const visible = filterVisibleBentoBlocks(rawBlocks as any);
expect(visible.length).toBe(3);
expect(visible.map((b: any) => b.type)).toEqual(['case', 'article', 'social']);
});
});
describe('7. 顶栏返回入口与智能导航 (executePortalGoBack)', () => {
it('7.1 当页面栈深度大于 1 时调用 uni.navigateBack', () => {
// @ts-ignore
const { executePortalGoBack } = require('../../../mpuniapp/pages/portal/usePortal.uts');
let navigatedBack = false;
let switchedTabUrl = '';
(global as any).getCurrentPages = () => [{}, {}];
(global as any).uni = {
...(global as any).uni,
navigateBack: () => {
navigatedBack = true;
},
switchTab: (p: { url: string }) => {
switchedTabUrl = p.url;
},
};
const result = executePortalGoBack(true);
expect(navigatedBack).toBe(true);
expect(result.action).toBe('navigateBack');
expect(switchedTabUrl).toBe('');
});
it('7.2 当页面栈为 1 且为名片主 (isOwner) 时安全跳转至名片管理首页', () => {
// @ts-ignore
const { executePortalGoBack } = require('../../../mpuniapp/pages/portal/usePortal.uts');
let navigatedBack = false;
let switchedTabUrl = '';
(global as any).getCurrentPages = () => [{}];
(global as any).uni = {
...(global as any).uni,
navigateBack: () => {
navigatedBack = true;
},
switchTab: (p: { url: string }) => {
switchedTabUrl = p.url;
},
};
const result = executePortalGoBack(true);
expect(navigatedBack).toBe(false);
expect(result.action).toBe('switchTab');
expect(result.targetUrl).toBe('/pages/owner/card/index');
expect(switchedTabUrl).toBe('/pages/owner/card/index');
});
it('7.3 当页面栈为 1 且为访客时安全跳转至大厅', () => {
// @ts-ignore
const { executePortalGoBack } = require('../../../mpuniapp/pages/portal/usePortal.uts');
let switchedTabUrl = '';
(global as any).getCurrentPages = () => [{}];
(global as any).uni = {
...(global as any).uni,
switchTab: (p: { url: string }) => {
switchedTabUrl = p.url;
},
};
const result = executePortalGoBack(false);
expect(result.targetUrl).toBe('/pages/owner/hall/index');
expect(switchedTabUrl).toBe('/pages/owner/hall/index');
});
});
});
describe('OmniCard AI 门户 Cookie 同意条契约 (R33)', () => {
const fs = require('fs') as typeof import('fs');
const path = require('path') as typeof import('path');
const PORTAL_PAGE = path.resolve(
__dirname,
'../../../mpuniapp/pages/portal/index.uvue'
);
const LOCALE_FILE = path.resolve(
__dirname,
'../../../mpuniapp/common/locale.uts'
);
const COOKIE_CONSENT_KEY = 'omni_cookie_consent';
const COOKIE_CONSENT_ACCEPTED = 'accepted';
function readPortalPage(): string {
return fs.readFileSync(PORTAL_PAGE, 'utf8');
}
function extractFunctionBody(source: string, fnName: string): string {
const marker = `function ${fnName}`;
const start = source.indexOf(marker);
expect(start).toBeGreaterThan(-1);
const braceStart = source.indexOf('{', start);
expect(braceStart).toBeGreaterThan(-1);
let depth = 0;
for (let i = braceStart; i < source.length; i++) {
const ch = source.charAt(i);
if (ch === '{') depth++;
if (ch === '}') {
depth--;
if (depth === 0) {
return source.slice(braceStart, i + 1);
}
}
}
return '';
}
it('8.1 无本地同意记录时模板仅渲染一条 Cookie 同意条,文案走 i18n', () => {
const pageSource = readPortalPage();
const localeSource = fs.readFileSync(LOCALE_FILE, 'utf8');
const template = pageSource.split('<script')[0];
const cookieBlockMatch = template.match(
/v-if="showCookieBanner"[\s\S]*?cookie-consent-bar[\s\S]*?<\/view>/
);
const cookieBlock = cookieBlockMatch != null ? cookieBlockMatch[0] : '';
expect(template.includes('cookie-consent-bar')).toBe(true);
expect((template.match(/class="cookie-consent-bar"/g) || []).length).toBe(1);
expect(template.includes('v-if="showCookieBanner"')).toBe(true);
expect(cookieBlock.includes("textOf('portal.cookie_notice')") || pageSource.includes("textOf('portal.cookie_notice')")).toBe(true);
expect(pageSource.includes("textOf('portal.cookie_accept')")).toBe(true);
expect(pageSource.includes("textOf('portal.cookie_reject')")).toBe(true);
expect(/[\u4e00-\u9fff]/.test(cookieBlock)).toBe(false);
expect(localeSource.includes("'portal.cookie_notice'")).toBe(true);
expect(localeSource.includes("'portal.cookie_accept'")).toBe(true);
expect(localeSource.includes("'portal.cookie_reject'")).toBe(true);
expect(pageSource.includes(COOKIE_CONSENT_KEY)).toBe(true);
expect(pageSource.includes(COOKIE_CONSENT_ACCEPTED)).toBe(true);
expect(pageSource.includes('initCookieBanner')).toBe(true);
});
it('8.2 点同意才写入本地同意记录并收起条;无记录时不假装已同意', () => {
const pageSource = readPortalPage();
const acceptBody = extractFunctionBody(pageSource, 'acceptCookieConsent');
const initBody = extractFunctionBody(pageSource, 'initCookieBanner');
expect(acceptBody.includes('setStorageSync')).toBe(true);
expect(
acceptBody.includes(COOKIE_CONSENT_KEY) ||
acceptBody.includes('COOKIE_CONSENT_KEY')
).toBe(true);
expect(
acceptBody.includes(COOKIE_CONSENT_ACCEPTED) ||
acceptBody.includes('COOKIE_CONSENT_ACCEPTED')
).toBe(true);
expect(acceptBody.includes('showCookieBanner.value = false')).toBe(true);
expect(initBody.includes('getStorageSync') || pageSource.includes('readCookieConsentRaw')).toBe(true);
expect(pageSource.includes(`'${COOKIE_CONSENT_KEY}'`) || pageSource.includes(`"${COOKIE_CONSENT_KEY}"`)).toBe(true);
expect(pageSource.includes(`'${COOKIE_CONSENT_ACCEPTED}'`) || pageSource.includes(`"${COOKIE_CONSENT_ACCEPTED}"`)).toBe(true);
expect(initBody.includes('showCookieBanner')).toBe(true);
expect(initBody.includes('hasCookieConsentRecord')).toBe(true);
// 初始化不得写死已同意(不在无检查时直接 false)
expect(initBody.trim() === '{ showCookieBanner.value = false; }').toBe(false);
// 行为模拟:无记录 → 应显示;写入 accepted → 收起
const storage = new Map<string, string>();
const hasRecord = (): boolean =>
storage.get(COOKIE_CONSENT_KEY) === COOKIE_CONSENT_ACCEPTED;
let show = !hasRecord();
expect(show).toBe(true);
storage.set(COOKIE_CONSENT_KEY, COOKIE_CONSENT_ACCEPTED);
show = !hasRecord();
expect(show).toBe(false);
expect(hasRecord()).toBe(true);
});
it('8.3 点拒绝不得写入同意记录,不得把拒绝当成已同意', () => {
const pageSource = readPortalPage();
const rejectBody = extractFunctionBody(pageSource, 'rejectCookieConsent');
expect(rejectBody.includes('setStorageSync')).toBe(false);
expect(rejectBody.includes('COOKIE_CONSENT_ACCEPTED')).toBe(false);
expect(rejectBody.includes(`'${COOKIE_CONSENT_ACCEPTED}'`)).toBe(false);
expect(rejectBody.includes('showCookieBanner.value = false')).toBe(true);
// 行为模拟:拒绝只收起当前会话条,本地仍无同意记录
const storage = new Map<string, string>();
let show = !storage.has(COOKIE_CONSENT_KEY);
expect(show).toBe(true);
// reject: hide only
show = false;
expect(storage.has(COOKIE_CONSENT_KEY)).toBe(false);
expect(storage.get(COOKIE_CONSENT_KEY)).toBeUndefined();
// 再次进入:无同意记录 → 仍应出现同意条
show = storage.get(COOKIE_CONSENT_KEY) !== COOKIE_CONSENT_ACCEPTED;
expect(show).toBe(true);
});
});
describe('OmniCard AI 门户 Line / KakaoTalk 空 URL 不渲染 (R38)', () => {
const fs = require('fs') as typeof import('fs');
const path = require('path') as typeof import('path');
const PORTAL_PAGE = path.resolve(
__dirname,
'../../../mpuniapp/pages/portal/index.uvue'
);
const PORTAL_LOGIC = path.resolve(
__dirname,
'../../../mpuniapp/pages/portal/usePortal.uts'
);
const LOCALE_FILE = path.resolve(
__dirname,
'../../../mpuniapp/common/locale.uts'
);
function readPortalPage(): string {
return fs.readFileSync(PORTAL_PAGE, 'utf8');
}
function readPortalLogic(): string {
return fs.readFileSync(PORTAL_LOGIC, 'utf8');
}
it('9.1 isRenderableHttpUrl:空/缺失/非 http(s) 为 false,合法 http(s) 为 true', () => {
// @ts-ignore
const { isRenderableHttpUrl } = require('../../../mpuniapp/pages/portal/usePortal.uts');
expect(isRenderableHttpUrl(undefined)).toBe(false);
expect(isRenderableHttpUrl(null as any)).toBe(false);
expect(isRenderableHttpUrl('')).toBe(false);
expect(isRenderableHttpUrl(' ')).toBe(false);
expect(isRenderableHttpUrl('line.me/ti/p/@demo')).toBe(false);
expect(isRenderableHttpUrl('ftp://example.com/line')).toBe(false);
expect(isRenderableHttpUrl('javascript:alert(1)')).toBe(false);
expect(isRenderableHttpUrl('https://line.me/ti/p/@real')).toBe(true);
expect(isRenderableHttpUrl('http://open.kakao.com/o/real')).toBe(true);
});
it('9.2 模板仅在 hasQuickAction(line|kakao) 时渲染,无示例链接与演示账号', () => {
const pageSource = readPortalPage();
const logicSource = readPortalLogic();
const localeSource = fs.readFileSync(LOCALE_FILE, 'utf8');
const template = pageSource.split('<script')[0];
expect(template.includes("hasQuickAction('line')")).toBe(true);
expect(template.includes("hasQuickAction('kakao')")).toBe(true);
expect(template.includes("textOf('portal.line')")).toBe(true);
expect(template.includes("textOf('portal.kakao')")).toBe(true);
// 不得硬编码演示账号 / 示例社交链
expect(/line\.me\/ti\/p\/@demo/i.test(pageSource)).toBe(false);
expect(/open\.kakao\.com\/o\/demo/i.test(pageSource)).toBe(false);
expect(/example\.com\/(line|kakao)/i.test(pageSource)).toBe(false);
expect(pageSource.includes('演示账号')).toBe(false);
// 空 URL 不渲染依赖 isRenderableHttpUrl / hasQuickAction
expect(logicSource.includes('isRenderableHttpUrl')).toBe(true);
expect(pageSource.includes('isRenderableHttpUrl') || pageSource.includes("hasQuickAction('line')")).toBe(true);
expect(localeSource.includes("'portal.line'")).toBe(true);
expect(localeSource.includes("'portal.kakao'")).toBe(true);
});
it('9.3 Line/Kakao 有合法 URL 可触发复制;空值不打开且不填示例链', () => {
let clipboardText = '';
const toastCalls: Array<{ title: string; icon?: string }> = [];
(global as any).uni = {
showToast: (options: { title: string; icon?: string }) => {
toastCalls.push(options);
},
setClipboardData: (options: { data: string; success?: () => void }) => {
clipboardText = options.data;
options.success?.();
},
getStorageSync: () => '',
setStorageSync: () => {},
};
// @ts-ignore
const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const okEmpty = triggerQuickAction('line', '');
expect(okEmpty).toBe(false);
expect(clipboardText).toBe('');
expect(toastCalls.length).toBeGreaterThan(0);
toastCalls.length = 0;
const okBad = triggerQuickAction('kakao', 'not-a-url');
expect(okBad).toBe(false);
expect(clipboardText).toBe('');
const lineUrl = 'https://line.me/ti/p/@real-user';
const okLine = triggerQuickAction('line', lineUrl);
expect(okLine).toBe(true);
expect(clipboardText).toBe(lineUrl);
const kakaoUrl = 'https://open.kakao.com/o/real-user';
const okKakao = triggerQuickAction('kakao', kakaoUrl);
expect(okKakao).toBe(true);
expect(clipboardText).toBe(kakaoUrl);
delete (global as any).uni;
});
});