文件
OmniCard/backend/test/mpuniapp/owner-profile.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

620 行
22 KiB
TypeScript

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

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

/**
* OmniCard AI (CRMmp) - 名片主个人中心与消息通知配置测试套件 (TDD Red -> Green)
*
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/profile 设计编写
*/
import * as fs from 'fs';
import * as path from 'path';
const PROFILE_PAGE = path.resolve(
__dirname,
'../../../mpuniapp/pages/owner/profile/index.uvue'
);
const PROFILE_UTS = path.resolve(
__dirname,
'../../../mpuniapp/pages/owner/profile/useProfile.uts'
);
const PAGES_JSON = path.resolve(
__dirname,
'../../../mpuniapp/pages.json'
);
type OmniLocale =
| 'zh-CN'
| 'en-US'
| 'ar-SA'
| 'es-ES'
| 'de-DE'
| 'fr-FR'
| 'ja-JP';
type UserProfileAccount = {
name: string;
company: string;
department: string;
avatar: string;
};
type NotificationSettings = {
radarPush: boolean;
leadNotice: boolean;
};
type ProfileState = {
account: UserProfileAccount;
notifications: NotificationSettings;
locale: OmniLocale;
saving: boolean;
loading: boolean;
errorMessage: string;
};
type StorageApi = {
getStorageSync: (key: string) => unknown;
setStorageSync: (key: string, value: unknown) => void;
};
type CardProfileFixture = {
id?: string | number;
name: string;
title?: string;
company?: string;
department?: string;
avatarUrl?: string;
};
type OwnerPageQuery = {
page: number;
size: number;
keyWord?: string;
};
type OwnerPageData<T> = {
list: T[];
pagination: {
page: number;
size: number;
total: number;
};
};
type LoadProfileAccountApi = {
fetchOwnerCardPage: (
query: OwnerPageQuery
) => Promise<OwnerPageData<CardProfileFixture>>;
};
type UseProfileModule = {
PROFILE_ACCOUNT_STORAGE_KEY: string;
PROFILE_NOTIFICATIONS_STORAGE_KEY: string;
PORTAL_LOCALE_STORAGE_KEY: string;
getDefaultAccount: () => UserProfileAccount;
getDefaultNotifications: () => NotificationSettings;
createProfileState: (locale?: OmniLocale) => ProfileState;
loadProfileSettings: (storage?: StorageApi) => ProfileState;
saveProfileSettings: (state: ProfileState, storage?: StorageApi) => boolean;
updateAccount: (state: ProfileState, patch: Partial<UserProfileAccount>) => void;
toggleRadarPush: (state: ProfileState, value?: boolean, storage?: StorageApi) => boolean;
toggleLeadNotice: (state: ProfileState, value?: boolean, storage?: StorageApi) => boolean;
setPreferredLocale: (state: ProfileState, locale: string, storage?: StorageApi) => OmniLocale;
calculateQuotaPercent: (used: number, limit: number) => number;
resolveProfileText: (key: string) => string;
displayOrgField: (value: string | null | undefined) => string;
clearOrgFields: (state: ProfileState) => void;
loadOwnerProfileAccount: (
state: ProfileState,
api?: LoadProfileAccountApi
) => Promise<boolean>;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
const SAMPLE_ORG_RE =
/Nexus Tech Global|Overseas Business Dept|Cyberdyne Systems|Global Trade Co\.|示例(公司|组织|部门)/;
function loadUseProfile(): UseProfileModule {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(PROFILE_UTS) as UseProfileModule;
}
describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts)', () => {
let mockStorage: Record<string, unknown> = {};
beforeEach(() => {
jest.resetModules();
mockStorage = {};
(global as { uni?: Record<string, unknown> }).uni = {
getLocale: () => 'zh-CN',
showToast: jest.fn(),
navigateTo: jest.fn(),
navigateBack: jest.fn(),
getStorageSync: jest.fn((key: string) => mockStorage[key] ?? ''),
setStorageSync: jest.fn((key: string, val: unknown) => {
mockStorage[key] = val;
}),
};
});
afterEach(() => {
delete (global as { uni?: Record<string, unknown> }).uni;
});
describe('1. 配置读取与初始化 (loadProfileSettings)', () => {
it('1.1 首次无缓存时,公司/部门为空串,通知开关开启', () => {
const { loadProfileSettings, getDefaultAccount } = loadUseProfile();
const defaults = getDefaultAccount();
const state = loadProfileSettings();
expect(defaults.company).toBe('');
expect(defaults.department).toBe('');
expect(SAMPLE_ORG_RE.test(defaults.company)).toBe(false);
expect(SAMPLE_ORG_RE.test(defaults.department)).toBe(false);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(state.notifications.radarPush).toBe(true);
expect(state.notifications.leadNotice).toBe(true);
expect(state.locale).toBe('zh-CN');
expect(state.errorMessage).toBe('');
});
it('1.2 本地有缓存时,正确恢复已保存的账户与通知开关', () => {
const {
loadProfileSettings,
PROFILE_ACCOUNT_STORAGE_KEY,
PROFILE_NOTIFICATIONS_STORAGE_KEY,
} = loadUseProfile();
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] = JSON.stringify({
name: 'Sarah Connor',
company: 'Acme Corp',
department: 'Security Division',
avatar: 'https://example.com/sarah.jpg',
});
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] = JSON.stringify({
radarPush: false,
leadNotice: true,
});
const state = loadProfileSettings();
expect(state.account.name).toBe('Sarah Connor');
expect(state.account.company).toBe('Acme Corp');
expect(state.account.department).toBe('Security Division');
expect(state.account.avatar).toBe('https://example.com/sarah.jpg');
expect(state.notifications.radarPush).toBe(false);
expect(state.notifications.leadNotice).toBe(true);
});
it('1.3 防御保护:缓存数据损坏时不抛错,公司/部门保持空串', () => {
const {
loadProfileSettings,
PROFILE_ACCOUNT_STORAGE_KEY,
PROFILE_NOTIFICATIONS_STORAGE_KEY,
} = loadUseProfile();
// 非法 JSON 字符串
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] = '{invalid_json--';
// 错误的通知配置类型
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] = 'not-an-object';
const state = loadProfileSettings();
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(state.notifications.radarPush).toBe(true);
expect(state.notifications.leadNotice).toBe(true);
expect(state.errorMessage).toBe('');
});
it('1.4 防御保护:缓存缺公司/部门时保持空串,不回填示例组织', () => {
const {
loadProfileSettings,
PROFILE_ACCOUNT_STORAGE_KEY,
PROFILE_NOTIFICATIONS_STORAGE_KEY,
} = loadUseProfile();
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] = JSON.stringify({
name: 'John Doe',
});
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] = JSON.stringify({
radarPush: false,
});
const state = loadProfileSettings();
expect(state.account.name).toBe('John Doe');
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(state.notifications.radarPush).toBe(false);
expect(state.notifications.leadNotice).toBe(true);
});
});
describe('2. 通知开关切换与持久化同步 (toggleRadarPush & toggleLeadNotice)', () => {
it('2.1 toggleRadarPush 支持反转与显式赋值,并同步写入存储', () => {
const {
loadProfileSettings,
toggleRadarPush,
PROFILE_NOTIFICATIONS_STORAGE_KEY,
} = loadUseProfile();
const state = loadProfileSettings();
expect(state.notifications.radarPush).toBe(true);
// 反转切换
const res1 = toggleRadarPush(state);
expect(res1).toBe(false);
expect(state.notifications.radarPush).toBe(false);
// 验证本地存储已同步
const rawStored1 = mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] as string;
const parsed1 = JSON.parse(rawStored1) as NotificationSettings;
expect(parsed1.radarPush).toBe(false);
// 显式赋值为 true
const res2 = toggleRadarPush(state, true);
expect(res2).toBe(true);
expect(state.notifications.radarPush).toBe(true);
const rawStored2 = mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] as string;
const parsed2 = JSON.parse(rawStored2) as NotificationSettings;
expect(parsed2.radarPush).toBe(true);
});
it('2.2 toggleLeadNotice 支持反转与显式赋值,并同步写入存储', () => {
const {
loadProfileSettings,
toggleLeadNotice,
PROFILE_NOTIFICATIONS_STORAGE_KEY,
} = loadUseProfile();
const state = loadProfileSettings();
expect(state.notifications.leadNotice).toBe(true);
const res1 = toggleLeadNotice(state, false);
expect(res1).toBe(false);
expect(state.notifications.leadNotice).toBe(false);
const parsed1 = JSON.parse(
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] as string
) as NotificationSettings;
expect(parsed1.leadNotice).toBe(false);
const res2 = toggleLeadNotice(state);
expect(res2).toBe(true);
expect(state.notifications.leadNotice).toBe(true);
});
it('2.3 防御保护:存储写入异常时不崩溃,安全更新 state 并记录错误', () => {
const { loadProfileSettings, toggleRadarPush } = loadUseProfile();
const state = loadProfileSettings();
const faultyStorage: StorageApi = {
getStorageSync: jest.fn(),
setStorageSync: jest.fn(() => {
throw new Error('QuotaExceededError: LocalStorage limit reached');
}),
};
const result = toggleRadarPush(state, false, faultyStorage);
expect(result).toBe(false);
expect(state.notifications.radarPush).toBe(false);
expect(state.errorMessage).toContain('QuotaExceededError');
});
});
describe('3. 首选语言选择与记忆持久化 (setPreferredLocale)', () => {
it('3.1 切换首选语言,正确持久化并调用全局 setAppLocale', () => {
const {
loadProfileSettings,
setPreferredLocale,
PORTAL_LOCALE_STORAGE_KEY,
} = loadUseProfile();
const state = loadProfileSettings();
const nextLocale = setPreferredLocale(state, 'en-US');
expect(nextLocale).toBe('en-US');
expect(state.locale).toBe('en-US');
// 验证是否已记忆持久化到本地存储
const rawStored = mockStorage[PORTAL_LOCALE_STORAGE_KEY];
expect(rawStored).toBe('en-US');
});
it('3.2 防御保护:非法语言代码安全回退并归一化', () => {
const { loadProfileSettings, setPreferredLocale } = loadUseProfile();
const state = loadProfileSettings();
const safeLocale = setPreferredLocale(state, 'invalid_code');
expect(['zh-CN', 'en-US']).toContain(safeLocale);
expect(state.locale).toBe(safeLocale);
});
});
describe('4. 账户资料更新与防御保护 (updateAccount & saveProfileSettings)', () => {
it('4.1 更新名片主账户信息并持久化', () => {
const {
loadProfileSettings,
updateAccount,
saveProfileSettings,
PROFILE_ACCOUNT_STORAGE_KEY,
} = loadUseProfile();
const state = loadProfileSettings();
updateAccount(state, {
name: ' Elena Rostova ',
company: 'Northwind Ltd',
department: 'Marketing',
});
expect(state.account.name).toBe('Elena Rostova');
expect(state.account.company).toBe('Northwind Ltd');
expect(state.account.department).toBe('Marketing');
const saved = saveProfileSettings(state);
expect(saved).toBe(true);
const parsed = JSON.parse(
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] as string
) as UserProfileAccount;
expect(parsed.name).toBe('Elena Rostova');
expect(parsed.company).toBe('Northwind Ltd');
});
it('4.2 防御保护:空值或非法输入不覆盖已有有效信息', () => {
const { loadProfileSettings, updateAccount } = loadUseProfile();
const state = loadProfileSettings();
updateAccount(state, {
name: 'Elena',
company: 'Northwind Ltd',
department: 'Sales',
});
updateAccount(state, {
name: ' ',
company: '',
department: '',
});
// 空白字符串不应覆盖已有名称与企业
expect(state.account.name).toBe('Elena');
expect(state.account.company).toBe('Northwind Ltd');
expect(state.account.department).toBe('Sales');
});
});
describe('5. 商业化配额计算与现代化 UX 词条 (calculateQuotaPercent & resolveProfileText)', () => {
it('5.1 calculateQuotaPercent 正确计算百分比并处理极值边界', () => {
const { calculateQuotaPercent } = loadUseProfile();
expect(calculateQuotaPercent(0, 5)).toBe(0);
expect(calculateQuotaPercent(2, 5)).toBe(40);
expect(calculateQuotaPercent(5, 5)).toBe(100);
expect(calculateQuotaPercent(6, 5)).toBe(100); // 溢出截断
expect(calculateQuotaPercent(1, 0)).toBe(100); // 防除以0
expect(calculateQuotaPercent(-1, 5)).toBe(0); // 负数防卫
});
it('5.2 resolveProfileText 包含商业化套餐与微指标新词条', () => {
const { resolveProfileText } = loadUseProfile();
expect(resolveProfileText('tierPro')).toBeTruthy();
expect(resolveProfileText('previewPortal')).toBeTruthy();
expect(resolveProfileText('metricsCards')).toBeTruthy();
expect(resolveProfileText('metricsVisitors')).toBeTruthy();
expect(resolveProfileText('metricsLeads')).toBeTruthy();
expect(resolveProfileText('upgradeQuota')).toBeTruthy();
expect(resolveProfileText('confirmLogoutTitle')).toBeTruthy();
});
it('5.3 displayOrgField:缺失或空串走 i18n 空态,有值原样返回', () => {
const { displayOrgField, resolveProfileText } = loadUseProfile();
const emptyText = resolveProfileText('empty');
expect(emptyText).toBeTruthy();
expect(displayOrgField('')).toBe(emptyText);
expect(displayOrgField(' ')).toBe(emptyText);
expect(displayOrgField(null)).toBe(emptyText);
expect(displayOrgField(undefined)).toBe(emptyText);
expect(displayOrgField('Acme Corp')).toBe('Acme Corp');
});
});
describe('6. R39 公司/部门仅来自接口 (loadOwnerProfileAccount)', () => {
it('6.1 成功时写入接口 company / department,缺字段为空串', async () => {
const { createProfileState, loadOwnerProfileAccount } = loadUseProfile();
const state = createProfileState();
state.account.company = 'stale-company';
state.account.department = 'stale-dept';
const fetchOwnerCardPage = jest.fn(
async (): Promise<OwnerPageData<CardProfileFixture>> => ({
list: [
{
id: 8,
name: 'Elena',
company: 'Acme Corp',
},
],
pagination: { page: 1, size: 1, total: 1 },
})
);
const ok = await loadOwnerProfileAccount(state, { fetchOwnerCardPage });
expect(ok).toBe(true);
expect(fetchOwnerCardPage).toHaveBeenCalledTimes(1);
expect(state.account.company).toBe('Acme Corp');
expect(state.account.department).toBe('');
});
it('6.1b 接口同时返回 company 与 department 时原样写入', async () => {
const { createProfileState, loadOwnerProfileAccount } = loadUseProfile();
const state = createProfileState();
await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => ({
list: [
{
id: 3,
name: 'Elena',
company: 'Acme Corp',
department: 'APAC Sales',
},
],
pagination: { page: 1, size: 1, total: 1 },
}),
});
expect(state.account.company).toBe('Acme Corp');
expect(state.account.department).toBe('APAC Sales');
});
it('6.2 接口返回空串时保持空串,不回填示例组织', async () => {
const { createProfileState, loadOwnerProfileAccount, displayOrgField, resolveProfileText } =
loadUseProfile();
const state = createProfileState();
state.account.company = 'stale';
state.account.department = 'stale';
await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => ({
list: [
{
id: 1,
name: 'Elena',
company: '',
department: ' ',
},
],
pagination: { page: 1, size: 1, total: 1 },
}),
});
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(displayOrgField(state.account.company)).toBe(resolveProfileText('empty'));
expect(displayOrgField(state.account.department)).toBe(resolveProfileText('empty'));
expect(resolveProfileText('empty')).toBe('暂无数据');
});
it('6.3 接口失败时清空公司与部门,不保留上次或示例值', async () => {
const { createProfileState, loadOwnerProfileAccount, clearOrgFields } = loadUseProfile();
const state = createProfileState();
state.account.company = 'stale-company';
state.account.department = 'stale-dept';
const ok = await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => {
throw new Error('network down');
},
});
expect(ok).toBe(false);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
clearOrgFields(state);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
});
it('6.4 列表为空时同样清空公司与部门', async () => {
const { createProfileState, loadOwnerProfileAccount } = loadUseProfile();
const state = createProfileState();
state.account.company = 'stale-company';
state.account.department = 'stale-dept';
const ok = await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => ({
list: [],
pagination: { page: 1, size: 1, total: 0 },
}),
});
expect(ok).toBe(true);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
});
});
});
describe('OmniCard AI 名片主个人中心视图契约 (index.uvue & pages.json)', () => {
it('页面文件与逻辑 Composable 文件均存在', () => {
expect(fs.existsSync(PROFILE_PAGE)).toBe(true);
expect(fs.existsSync(PROFILE_UTS)).toBe(true);
});
it('页面视图包含 Switch 开关,触控区 >= 44x44,且引用 Token 与 useProfile', () => {
const pageSource = fs.readFileSync(PROFILE_PAGE, 'utf8');
const logicSource = fs.readFileSync(PROFILE_UTS, 'utf8');
const combined = `${pageSource}\n${logicSource}`;
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
expect(pageSource.includes('<switch')).toBe(true);
expect(pageSource.includes('@change')).toBe(true);
expect(
pageSource.includes('radarPush') || logicSource.includes('radarPush')
).toBe(true);
expect(
pageSource.includes('leadNotice') || logicSource.includes('leadNotice')
).toBe(true);
expect(combined.includes('loadProfileSettings')).toBe(true);
expect(combined.includes('toggleRadarPush')).toBe(true);
expect(combined.includes('toggleLeadNotice')).toBe(true);
expect(combined.includes('setPreferredLocale')).toBe(true);
expect(combined.includes('loadOwnerProfileAccount')).toBe(true);
expect(combined.includes('displayOrgField')).toBe(true);
// OmniCard 设计 Token
expect(pageSource.includes('#0088FF')).toBe(true); // primary
expect(pageSource.includes('#F5F8FC')).toBe(true); // bg
expect(pageSource.includes('#101828')).toBe(true); // text
expect(pageSource.includes('#64748B')).toBe(true); // text-2
expect(pageSource.includes('#EBF1F7')).toBe(true); // border
// Switch 触控区 >= 44x44 规范验证 (min-height >= 44px 或 height >= 44px)
expect(
pageSource.includes('min-height: 44px') ||
pageSource.includes('min-height: 48px') ||
pageSource.includes('height: 48px') ||
pageSource.includes('height: 44px') ||
pageSource.includes('switch-touch-area')
).toBe(true);
});
it('零 emoji、无 any,公司/部门无写死示例组织', () => {
const pageSource = fs.readFileSync(PROFILE_PAGE, 'utf8');
const logicSource = fs.readFileSync(PROFILE_UTS, 'utf8');
const testSource = fs.readFileSync(__filename, 'utf8');
const template = pageSource.split('<script')[0];
expect(EMOJI_RE.test(pageSource)).toBe(false);
expect(EMOJI_RE.test(logicSource)).toBe(false);
expect(EMOJI_RE.test(testSource)).toBe(false);
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
// 公司/部门展示走 displayOrgField,禁止写死示例组织
expect(template.includes('displayOrgField')).toBe(true);
expect(template.includes('state.account.company }}')).toBe(false);
expect(template.includes('state.account.department }}')).toBe(false);
expect(SAMPLE_ORG_RE.test(pageSource)).toBe(false);
expect(SAMPLE_ORG_RE.test(logicSource)).toBe(false);
expect(logicSource.includes("company: 'Nexus")).toBe(false);
expect(logicSource.includes('company: "Nexus')).toBe(false);
expect(logicSource.includes('Overseas Business')).toBe(false);
expect(logicSource.includes("resolveProfileText('empty')")).toBe(true);
expect(logicSource.includes("getOmniText(locale, 'common.empty')")).toBe(true);
});
it('mpuniapp/pages.json 中已成功注册 pages/owner/profile/index', () => {
expect(fs.existsSync(PAGES_JSON)).toBe(true);
const pagesJsonContent = fs.readFileSync(PAGES_JSON, 'utf8');
const cleanJson = pagesJsonContent
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n\r]*/g, '');
let found = false;
try {
const pagesConfig = JSON.parse(cleanJson);
found = pagesConfig.pages.some(
(p: { path: string }) => p.path === 'pages/owner/profile/index'
);
} catch (_e) {
found = pagesJsonContent.includes('"path": "pages/owner/profile/index"');
}
expect(found).toBe(true);
});
});