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

605 行
20 KiB
TypeScript

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

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

/**
* 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) => Promise<boolean>;
sendMagicLink: (state: LoginState) => Promise<boolean>;
sendSmsCode: (state: LoginState) => Promise<boolean>;
tapSocialLogin: (provider: 'google' | 'apple') => Promise<void>;
resolveLoginText: (key: string) => string;
tickCountdown: (state: LoginState) => void;
fillTestAccount: (state: LoginState) => void;
};
type MockPayload = {
statusCode?: number;
data?: unknown;
failError?: { errMsg: string };
};
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;
let clipboardCalls: Array<{ data: string }>;
let openUrlCalls: string[];
let mockByUrl: Map<string, MockPayload>;
let defaultMock: MockPayload;
beforeEach(() => {
storageMap = new Map();
toastCalls = [];
lastRequestOptions = null;
requestCallCount = 0;
navigateBackCount = 0;
switchTabCount = 0;
clipboardCalls = [];
openUrlCalls = [];
mockByUrl = new Map();
defaultMock = {
statusCode: 200,
data: { code: 1000, message: 'success', data: {} },
};
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;
},
setClipboardData: (options: { data: string; success?: () => void }) => {
clipboardCalls.push({ data: options.data });
options.success?.();
},
getLocale: () => 'zh-CN',
request: (options: UniRequestOption) => {
requestCallCount += 1;
lastRequestOptions = options;
const matched = Array.from(mockByUrl.entries()).find(([path]) =>
(options.url || '').includes(path)
);
const payload = matched ? matched[1] : defaultMock;
setTimeout(() => {
if (payload.failError) {
options.fail?.(payload.failError);
} else {
options.success?.({
statusCode: payload.statusCode ?? 200,
data: payload.data,
header: {},
});
}
options.complete?.();
}, 0);
return {
abort: jest.fn(),
};
},
};
(global as { window?: { open?: (url: string) => void } }).window = {
open: (url: string) => {
openUrlCalls.push(url);
},
};
});
afterEach(() => {
delete (global as { uni?: Record<string, unknown> }).uni;
delete (global as { window?: unknown }).window;
});
function loadUseLogin(): UseLoginModule {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../../mpuniapp/pages/common/useLogin.uts') as UseLoginModule;
}
function mockEndpoint(path: string, data: unknown, statusCode = 200): void {
mockByUrl.set(path, {
statusCode,
data: { code: 1000, message: 'success', data },
});
}
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 空手机号不提交验证码登录', async () => {
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.smsCode = '123456';
const ok = await 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', async () => {
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.tab = 'email';
const ok = await 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. 外部登录:未配置不写 token / sent 不写 / ok 才写', () => {
it('3.1 smsSend not_configured:toast 即将开放且不写 token', async () => {
mockEndpoint('/app/user/external/smsSend', { status: 'not_configured' });
const { createLoginState, sendSmsCode } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
const ok = await sendSmsCode(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/smsSend');
expect(lastRequestOptions?.data).toEqual({ phone: '13800138000' });
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
expect(state.countdown).toBe(0);
});
it('3.2 smsSend sent:提示已发送,不写 token,启动倒计时', async () => {
mockEndpoint('/app/user/external/smsSend', { status: 'sent' });
const { createLoginState, sendSmsCode, tickCountdown } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
const ok = await sendSmsCode(state);
expect(ok).toBe(true);
expect(storageMap.has('token')).toBe(false);
expect(state.countdown).toBe(60);
expect(toastCalls[toastCalls.length - 1].title).toMatch(/已发送|sent/i);
tickCountdown(state);
expect(state.countdown).toBe(59);
const again = await sendSmsCode(state);
expect(again).toBe(false);
expect(state.countdown).toBe(59);
});
it('3.3 smsLogin not_configured:不写 token', async () => {
mockEndpoint('/app/user/external/smsLogin', { status: 'not_configured' });
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '123456';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/smsLogin');
expect(lastRequestOptions?.data).toEqual({ phone: '13800138000', code: '123456' });
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
});
it('3.4 smsLogin ok + token:写入 token 并跳转', async () => {
mockEndpoint('/app/user/external/smsLogin', {
status: 'ok',
token: 'mock_sms_jwt_token',
});
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '654321';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(true);
expect(storageMap.get('token')).toBe('mock_sms_jwt_token');
expect(switchTabCount).toBe(1);
});
it('3.5 smsLogin 请求失败:不写 token', async () => {
mockByUrl.set('/app/user/external/smsLogin', {
statusCode: 200,
data: { code: 1001, message: '验证码错误' },
});
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '000000';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
it('3.6 magicLink not_configured:不写 token', async () => {
mockEndpoint('/app/user/external/magicLink', { status: 'not_configured' });
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.email = 'demo@omnicard.ai';
const ok = await sendMagicLink(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/magicLink');
expect(lastRequestOptions?.data).toEqual({ email: 'demo@omnicard.ai' });
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
});
it('3.7 magicLink sent:提示已发送,不写 token', async () => {
mockEndpoint('/app/user/external/magicLink', { status: 'sent' });
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.email = 'demo@omnicard.ai';
const ok = await sendMagicLink(state);
expect(ok).toBe(true);
expect(storageMap.has('token')).toBe(false);
expect(toastCalls[toastCalls.length - 1].title).toMatch(/已发送|sent/i);
});
it('3.8 magicLink ok + token:写入 token', async () => {
mockEndpoint('/app/user/external/magicLink', {
status: 'ok',
token: 'mock_magic_jwt_token',
});
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.email = 'demo@omnicard.ai';
const ok = await sendMagicLink(state);
expect(ok).toBe(true);
expect(storageMap.get('token')).toBe('mock_magic_jwt_token');
expect(switchTabCount).toBe(1);
});
it('3.9 Google not_configured 且无 url:toast 即将开放,不写 token,不打开假地址', async () => {
mockEndpoint('/app/user/external/googleStart', { status: 'not_configured' });
const { tapSocialLogin } = loadUseLogin();
await tapSocialLogin('google');
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/googleStart');
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
expect(openUrlCalls.length).toBe(0);
expect(clipboardCalls.length).toBe(0);
});
it('3.10 Apple 返回 url:可复制或打开,不写 token', async () => {
mockEndpoint('/app/user/external/appleStart', {
status: 'ok',
url: 'https://appleid.apple.com/auth/authorize?client_id=omni',
});
const { tapSocialLogin } = loadUseLogin();
await tapSocialLogin('apple');
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/appleStart');
const openedOrCopied =
openUrlCalls.includes('https://appleid.apple.com/auth/authorize?client_id=omni') ||
clipboardCalls.some(
(c) => c.data === 'https://appleid.apple.com/auth/authorize?client_id=omni'
);
expect(openedOrCopied).toBe(true);
});
it('3.11 社交登录请求失败:不写 token', async () => {
mockByUrl.set('/app/user/external/googleStart', {
failError: { errMsg: 'network error' },
});
const { tapSocialLogin } = loadUseLogin();
await tapSocialLogin('google');
expect(storageMap.has('token')).toBe(false);
expect(openUrlCalls.length).toBe(0);
});
it('3.12 smsLogin status=ok 但无 token:不写凭证,不跳转', async () => {
mockEndpoint('/app/user/external/smsLogin', { status: 'ok' });
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '123456';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
});
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 手机号合法且 smsSend sent 时触发 60s 倒计时', async () => {
mockEndpoint('/app/user/external/smsSend', { status: 'sent' });
const { createLoginState, sendSmsCode, tickCountdown } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
await sendSmsCode(state);
expect(state.countdown).toBe(60);
tickCountdown(state);
expect(state.countdown).toBe(59);
const again = await 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';
mockEndpoint('/admin/base/open/login', {
token: 'mock_admin_jwt_token_2026',
expire: 7200,
refreshToken: 'mock_refresh_token',
});
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';
mockByUrl.set('/admin/base/open/login', {
statusCode: 200,
data: {
code: 1001,
message: '账户或密码不正确~',
},
});
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');
});
it('6.5 接口成功但无 token 字段:不得写入 token,不得当成已登录', async () => {
const { createLoginState, submitPasswordLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
mockEndpoint('/admin/base/open/login', {
expire: 7200,
refreshToken: 'should_not_be_used_as_token',
});
const ok = await submitPasswordLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
expect(state.errorMessage.length).toBeGreaterThan(0);
});
it('6.6 接口返回空 token:不得写入本地凭证', async () => {
const { createLoginState, submitPasswordLogin, completeLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
mockEndpoint('/admin/base/open/login', { token: '' });
const okEmpty = await submitPasswordLogin(state);
expect(okEmpty).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
mockEndpoint('/admin/base/open/login', { token: ' ' });
const okBlank = await submitPasswordLogin(state);
expect(okBlank).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(completeLogin('')).toBe(false);
expect(completeLogin(' ')).toBe(false);
expect(storageMap.has('token')).toBe(false);
});
it('6.7 账号登录网络失败:不得写入 token', async () => {
mockByUrl.set('/admin/base/open/login', {
failError: { errMsg: 'network error' },
});
const { createLoginState, submitPasswordLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
const ok = await submitPasswordLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
it('6.8 账号登录 HTTP 非成功:不得写入 token', async () => {
mockByUrl.set('/admin/base/open/login', {
statusCode: 500,
data: { message: 'Internal Server Error' },
});
const { createLoginState, submitPasswordLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
const ok = await submitPasswordLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
});
});