/** * 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; }; type UniRequestOption = { url: string; method?: string; data?: Record; 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; submitPhoneLogin: (state: LoginState) => Promise; sendMagicLink: (state: LoginState) => Promise; sendSmsCode: (state: LoginState) => Promise; tapSocialLogin: (provider: 'google' | 'apple') => Promise; 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; 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; 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 }).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 }).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); }); }); });