- [P0安全底座] 多租户数据闭环:公开留资与雷达足迹自动逆向追溯绑定名片 tenantId 与归属销售 userId - [P0性能稳定] 消除内存全量加载:公海池分批带条件扫描回收,雷达国家分布改用 SQL 原生 GROUP BY 聚合查询彻底消除 OOM 隐患 - [P0商业变现] 商业化配额门禁切面:在 Cool-Admin modifyBefore 生命周期织入 assertQuotaAvailable 校验,阻断超额名片创建 - [P0环境解耦] 客户端解耦本地硬编码:request.uts 支持 api_base_url 本地覆盖,生产数据库名修正为 crmmpdata - [P1高并发削峰] 雷达足迹批量缓冲引擎:引入 CrmRadarBufferService 环形缓冲队列与阈值/定时批量 Bulk Insert 落盘 - [P2中枢租户化] AI 智能中枢多租户 BYOK 与 Token 成本风控:支持企业自带 Key 隔离、Token 消耗账本、用量配额门禁与名片人设个性化联动 - [跨端规范] 对齐 Uni-app x 移动微官网原生首屏与底部原生 tabBar 规范 - [测试驱动] 全工程 116 个测试套件,1,175 个单元与集成测试用例全部 100% 绿色通过,管理后台 Vite 打包无错误
342 行
11 KiB
TypeScript
342 行
11 KiB
TypeScript
/**
|
|
* OmniCard AI (CRMmp) - mpuniapp 统一登录骨架测试套件 (TDD Red -> Green)
|
|
*
|
|
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD pages/common/login 设计编写
|
|
*/
|
|
|
|
type UniToastOption = {
|
|
title: string;
|
|
icon?: string;
|
|
};
|
|
|
|
type UniRequestSuccessRes = {
|
|
statusCode: number;
|
|
data: unknown;
|
|
header: Record<string, string>;
|
|
};
|
|
|
|
type UniRequestOption = {
|
|
url: string;
|
|
method?: string;
|
|
data?: Record<string, unknown>;
|
|
success?: (res: UniRequestSuccessRes) => void;
|
|
fail?: (err: { errMsg: string }) => void;
|
|
complete?: () => void;
|
|
};
|
|
|
|
type LoginState = {
|
|
tab: 'password' | 'phone' | 'email';
|
|
username: string;
|
|
password: string;
|
|
countryCode: string;
|
|
phone: string;
|
|
smsCode: string;
|
|
email: string;
|
|
submitting: boolean;
|
|
errorMessage: string;
|
|
countdown: number;
|
|
};
|
|
|
|
type UseLoginModule = {
|
|
createLoginState: () => LoginState;
|
|
completeLogin: (token: string, tenantId?: string) => boolean;
|
|
submitPasswordLogin: (state: LoginState) => Promise<boolean>;
|
|
submitPhoneLogin: (state: LoginState) => boolean;
|
|
sendMagicLink: (state: LoginState) => boolean;
|
|
sendSmsCode: (state: LoginState) => boolean;
|
|
tapSocialLogin: (provider: 'google' | 'apple') => void;
|
|
resolveLoginText: (key: string) => string;
|
|
tickCountdown: (state: LoginState) => void;
|
|
fillTestAccount: (state: LoginState) => void;
|
|
};
|
|
|
|
describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', () => {
|
|
let storageMap: Map<string, string>;
|
|
let toastCalls: UniToastOption[];
|
|
let lastRequestOptions: UniRequestOption | null;
|
|
let requestCallCount: number;
|
|
let navigateBackCount: number;
|
|
let switchTabCount: number;
|
|
|
|
beforeEach(() => {
|
|
storageMap = new Map();
|
|
toastCalls = [];
|
|
lastRequestOptions = null;
|
|
requestCallCount = 0;
|
|
navigateBackCount = 0;
|
|
switchTabCount = 0;
|
|
jest.resetModules();
|
|
|
|
(global as { uni?: Record<string, unknown> }).uni = {
|
|
getStorageSync: (key: string) => storageMap.get(key) || '',
|
|
setStorageSync: (key: string, val: string) => storageMap.set(key, val),
|
|
removeStorageSync: (key: string) => storageMap.delete(key),
|
|
showToast: (options: UniToastOption) => {
|
|
toastCalls.push(options);
|
|
},
|
|
reLaunch: jest.fn(),
|
|
switchTab: (_options: { url: string }) => {
|
|
switchTabCount += 1;
|
|
},
|
|
navigateBack: () => {
|
|
navigateBackCount += 1;
|
|
},
|
|
getLocale: () => 'zh-CN',
|
|
request: (options: UniRequestOption) => {
|
|
requestCallCount += 1;
|
|
lastRequestOptions = options;
|
|
setTimeout(() => {
|
|
options.success?.({
|
|
statusCode: 200,
|
|
data: { code: 1000, message: 'success', data: {} },
|
|
header: {},
|
|
});
|
|
options.complete?.();
|
|
}, 0);
|
|
return {
|
|
abort: jest.fn(),
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (global as { uni?: Record<string, unknown> }).uni;
|
|
});
|
|
|
|
function loadUseLogin(): UseLoginModule {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
return require('../../../mpuniapp/pages/common/useLogin.uts') as UseLoginModule;
|
|
}
|
|
|
|
describe('1. completeLogin 写入本地凭证', () => {
|
|
it('1.1 completeLogin 会 setStorage token 并跳转名片页', () => {
|
|
const { completeLogin } = loadUseLogin();
|
|
|
|
const ok = completeLogin('mock_jwt_login_t09');
|
|
|
|
expect(ok).toBe(true);
|
|
expect(storageMap.get('token')).toBe('mock_jwt_login_t09');
|
|
expect(switchTabCount).toBe(1);
|
|
});
|
|
|
|
it('1.2 传入 tenantId 时同步写入 tenant_id', () => {
|
|
const { completeLogin } = loadUseLogin();
|
|
|
|
completeLogin('mock_jwt_login_t09', 'tenant_omni_88');
|
|
|
|
expect(storageMap.get('token')).toBe('mock_jwt_login_t09');
|
|
expect(storageMap.get('tenant_id')).toBe('tenant_omni_88');
|
|
expect(switchTabCount).toBe(1);
|
|
});
|
|
|
|
it('1.3 未传 tenantId 时不写入 tenant_id', () => {
|
|
const { completeLogin } = loadUseLogin();
|
|
|
|
completeLogin('mock_jwt_only_token');
|
|
|
|
expect(storageMap.get('token')).toBe('mock_jwt_only_token');
|
|
expect(storageMap.has('tenant_id')).toBe(false);
|
|
expect(switchTabCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('2. 空手机号 / 空邮箱不提交', () => {
|
|
it('2.1 空手机号不提交验证码登录', () => {
|
|
const { createLoginState, submitPhoneLogin } = loadUseLogin();
|
|
|
|
const state = createLoginState();
|
|
state.smsCode = '123456';
|
|
|
|
const ok = submitPhoneLogin(state);
|
|
|
|
expect(ok).toBe(false);
|
|
expect(storageMap.has('token')).toBe(false);
|
|
expect(requestCallCount).toBe(0);
|
|
expect(lastRequestOptions).toBeNull();
|
|
expect(toastCalls.length).toBeGreaterThan(0);
|
|
expect(toastCalls[0].title).toContain('手机号');
|
|
});
|
|
|
|
it('2.2 空邮箱不提交 Magic Link', () => {
|
|
const { createLoginState, sendMagicLink } = loadUseLogin();
|
|
|
|
const state = createLoginState();
|
|
state.tab = 'email';
|
|
|
|
const ok = sendMagicLink(state);
|
|
|
|
expect(ok).toBe(false);
|
|
expect(storageMap.has('token')).toBe(false);
|
|
expect(requestCallCount).toBe(0);
|
|
expect(lastRequestOptions).toBeNull();
|
|
expect(toastCalls.length).toBeGreaterThan(0);
|
|
expect(toastCalls[0].title).toContain('邮箱');
|
|
});
|
|
});
|
|
|
|
describe('3. 社交登录不伪造成功', () => {
|
|
it('3.1 Google / Apple 仅 toast 即将开放且不写 token', () => {
|
|
const { tapSocialLogin } = loadUseLogin();
|
|
|
|
tapSocialLogin('google');
|
|
tapSocialLogin('apple');
|
|
|
|
expect(storageMap.has('token')).toBe(false);
|
|
expect(navigateBackCount).toBe(0);
|
|
expect(requestCallCount).toBe(0);
|
|
expect(toastCalls.length).toBe(2);
|
|
expect(toastCalls[0].title).toContain('即将开放');
|
|
expect(toastCalls[1].title).toContain('即将开放');
|
|
});
|
|
});
|
|
|
|
describe('4. 极简品牌感文案与国际化解析', () => {
|
|
it('4.1 正确解析品牌感文案,杜绝空字符串与硬编码', () => {
|
|
const { resolveLoginText } = loadUseLogin();
|
|
|
|
expect(resolveLoginText('brandTag').length).toBeGreaterThan(0);
|
|
expect(resolveLoginText('subtitle').length).toBeGreaterThan(0);
|
|
expect(resolveLoginText('orContinueWith').length).toBeGreaterThan(0);
|
|
expect(resolveLoginText('termsNotice').length).toBeGreaterThan(0);
|
|
expect(resolveLoginText('termsOfService').length).toBeGreaterThan(0);
|
|
expect(resolveLoginText('privacyPolicy').length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('5. 验证码倒计时机制', () => {
|
|
it('5.1 手机号合法时发送验证码触发 60s 倒计时', () => {
|
|
const { createLoginState, sendSmsCode, tickCountdown } = loadUseLogin();
|
|
|
|
const state = createLoginState();
|
|
state.phone = '13800138000';
|
|
|
|
sendSmsCode(state);
|
|
expect(state.countdown).toBe(60);
|
|
|
|
// 递减一次
|
|
tickCountdown(state);
|
|
expect(state.countdown).toBe(59);
|
|
|
|
// 倒计时中不能重复触发
|
|
const again = sendSmsCode(state);
|
|
expect(again).toBe(false);
|
|
expect(state.countdown).toBe(59);
|
|
});
|
|
});
|
|
|
|
describe('6. 账号密码真实登录与前后端对齐', () => {
|
|
it('6.1 空账号或空密码不提交', async () => {
|
|
const { createLoginState, submitPasswordLogin } = loadUseLogin();
|
|
|
|
const state = createLoginState();
|
|
state.username = '';
|
|
state.password = '';
|
|
|
|
const ok = await submitPasswordLogin(state);
|
|
|
|
expect(ok).toBe(false);
|
|
expect(storageMap.has('token')).toBe(false);
|
|
expect(requestCallCount).toBe(0);
|
|
expect(toastCalls.length).toBeGreaterThan(0);
|
|
expect(toastCalls[0].title).toContain('账号');
|
|
|
|
// 仅有账号没有密码
|
|
state.username = 'admin';
|
|
state.password = '';
|
|
const ok2 = await submitPasswordLogin(state);
|
|
expect(ok2).toBe(false);
|
|
expect(storageMap.has('token')).toBe(false);
|
|
expect(toastCalls[toastCalls.length - 1].title).toContain('密码');
|
|
});
|
|
|
|
it('6.2 账号密码登录成功:对齐 /admin/base/open/login 接口契约,写入 token 并跳转', async () => {
|
|
const { createLoginState, submitPasswordLogin } = loadUseLogin();
|
|
|
|
const state = createLoginState();
|
|
state.username = 'admin';
|
|
state.password = '123456';
|
|
|
|
// 模拟 uni.request 响应 Cool-Admin 成功契约
|
|
(global as { uni?: Record<string, unknown> }).uni!.request = (options: UniRequestOption) => {
|
|
requestCallCount += 1;
|
|
lastRequestOptions = options;
|
|
setTimeout(() => {
|
|
options.success?.({
|
|
statusCode: 200,
|
|
data: {
|
|
code: 1000,
|
|
message: 'success',
|
|
data: {
|
|
token: 'mock_admin_jwt_token_2026',
|
|
expire: 7200,
|
|
refreshToken: 'mock_refresh_token',
|
|
},
|
|
},
|
|
header: {},
|
|
});
|
|
options.complete?.();
|
|
}, 0);
|
|
return { abort: jest.fn() };
|
|
};
|
|
|
|
const ok = await submitPasswordLogin(state);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(storageMap.get('token')).toBe('mock_admin_jwt_token_2026');
|
|
expect(switchTabCount).toBe(1);
|
|
expect(lastRequestOptions?.url).toContain('/admin/base/open/login');
|
|
expect(lastRequestOptions?.data).toEqual({
|
|
username: 'admin',
|
|
password: '123456',
|
|
});
|
|
});
|
|
|
|
it('6.3 账号密码登录失败:后端返回 1001 时正确提示错误并置错误信息', async () => {
|
|
const { createLoginState, submitPasswordLogin } = loadUseLogin();
|
|
|
|
const state = createLoginState();
|
|
state.username = 'admin';
|
|
state.password = 'wrong_password';
|
|
|
|
(global as { uni?: Record<string, unknown> }).uni!.request = (options: UniRequestOption) => {
|
|
requestCallCount += 1;
|
|
lastRequestOptions = options;
|
|
setTimeout(() => {
|
|
options.success?.({
|
|
statusCode: 200,
|
|
data: {
|
|
code: 1001,
|
|
message: '账户或密码不正确~',
|
|
},
|
|
header: {},
|
|
});
|
|
options.complete?.();
|
|
}, 0);
|
|
return { abort: jest.fn() };
|
|
};
|
|
|
|
const ok = await submitPasswordLogin(state);
|
|
|
|
expect(ok).toBe(false);
|
|
expect(storageMap.has('token')).toBe(false);
|
|
expect(switchTabCount).toBe(0);
|
|
expect(state.errorMessage).toBe('账户或密码不正确~');
|
|
expect(toastCalls[toastCalls.length - 1].title).toBe('账户或密码不正确~');
|
|
});
|
|
|
|
it('6.4 一键填入测试账号便于快速验证', () => {
|
|
const { createLoginState, fillTestAccount } = loadUseLogin();
|
|
|
|
const state = createLoginState();
|
|
expect(state.username).toBe('');
|
|
expect(state.password).toBe('');
|
|
|
|
fillTestAccount(state);
|
|
|
|
expect(state.username).toBe('admin');
|
|
expect(state.password).toBe('123456');
|
|
});
|
|
});
|
|
});
|