import { changePassword, getPasswordRuleMessage, resetPassword, validatePassword, validatePasswordConfirm, } from '../api/api'; import { loginByPassword, sendSmsCode, setMockMode } from '../api'; import { PASSWORD_RULE_LABEL } from '../constants'; import type { PasswordRuleError } from '../types'; import { isValidPhone, isValidSmsCode } from '../utils'; // api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 salary.test.ts 同) jest.mock('../lib/storage', () => ({ storageGet: jest.fn(async () => null), storageSet: jest.fn(async () => undefined), storageRemove: jest.fn(async () => undefined), })); /** 联合类型的全部成员:新增成员而漏配文案时,下面的完整性用例会失败 */ const ALL_RULE_ERRORS: PasswordRuleError[] = [ 'empty', 'too-short', 'too-long', 'need-letter-digit', 'mismatch', 'same-as-old', ]; /** 构造 fetch 的响应桩:支持 ok / status / headers.get / text() */ function makeResponse(status: number, body: unknown) { return { ok: status >= 200 && status < 300, status, headers: { get: (k: string) => (k === 'X-Request-Id' ? 'rid' : null) }, text: async () => JSON.stringify(body), }; } describe('validatePassword(密码强度业务规则)', () => { it('空密码返回 empty', () => { expect(validatePassword('')).toBe('empty'); }); it('少于 8 位返回 too-short', () => { expect(validatePassword('ab12')).toBe('too-short'); expect(validatePassword('abc1234')).toBe('too-short'); }); it('超过 20 位返回 too-long', () => { expect(validatePassword('abc12345678901234567890')).toBe('too-long'); }); it('纯数字返回 need-letter-digit', () => { expect(validatePassword('12345678')).toBe('need-letter-digit'); }); it('纯字母返回 need-letter-digit', () => { expect(validatePassword('abcdefgh')).toBe('need-letter-digit'); }); it('8–20 位且含字母和数字时通过', () => { expect(validatePassword('abc12345')).toBeNull(); expect(validatePassword('a1b2c3d4e5f6g7h8i9j0')).toBeNull(); }); }); describe('validatePasswordConfirm(两次输入一致性)', () => { it('一致时返回 null', () => { expect(validatePasswordConfirm('abc12345', 'abc12345')).toBeNull(); }); it('不一致时返回 mismatch', () => { expect(validatePasswordConfirm('abc12345', 'abc12346')).toBe('mismatch'); }); }); describe('PASSWORD_RULE_LABEL(错误码 → 文案映射完整性)', () => { it('每个错误码都有非空中文文案', () => { ALL_RULE_ERRORS.forEach((code) => { const label = PASSWORD_RULE_LABEL[code]; expect(typeof label).toBe('string'); expect(label.length).toBeGreaterThan(0); }); }); it('getPasswordRuleMessage 与映射表一致', () => { ALL_RULE_ERRORS.forEach((code) => { expect(getPasswordRuleMessage(code)).toBe(PASSWORD_RULE_LABEL[code]); }); }); }); describe('validators(手机号 / 验证码格式)', () => { it('手机号必须是 1 开头共 11 位', () => { expect(isValidPhone('13800138000')).toBe(true); expect(isValidPhone('1380013800')).toBe(false); expect(isValidPhone('23800138000')).toBe(false); expect(isValidPhone('138001380001')).toBe(false); expect(isValidPhone('')).toBe(false); }); it('验证码为 4–6 位数字', () => { expect(isValidSmsCode('1234')).toBe(true); expect(isValidSmsCode('123456')).toBe(true); expect(isValidSmsCode('123')).toBe(false); expect(isValidSmsCode('1234567')).toBe(false); expect(isValidSmsCode('12a4')).toBe(false); }); }); describe('loginByPassword(手机号 + 密码登录 · 真实后端)', () => { beforeEach(() => { setMockMode(false); (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(); }); it('手机号非法时本地拒绝,不发请求', async () => { await expect( loginByPassword({ phone: '138', password: 'abc12345' }), ).rejects.toThrow('手机号格式不正确'); expect((global as unknown as { fetch: jest.Mock }).fetch).not.toHaveBeenCalled(); }); it('参数合法时 POST /auth/password/login 且返回双 Token(匿名接口不带 Bearer)', async () => { (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( async () => makeResponse(200, { code: 200, message: 'ok', data: { userId: 123, phone: '13800138000', nickname: '测试用户', role: 'STUDENT', accessToken: 'A', refreshToken: 'R', expiresIn: 86400, }, requestId: 'r1', timestamp: 't', }), ); const result = await loginByPassword({ phone: '13800138000', password: 'abc12345' }); expect(result.accessToken).toBe('A'); expect(result.refreshToken).toBe('R'); const [url, init] = ( (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock ).mock.calls[0]; expect(url).toContain('/auth/password/login'); expect(init.method).toBe('POST'); expect(init.headers['Authorization']).toBeUndefined(); expect(JSON.parse(init.body)).toEqual({ phone: '13800138000', password: 'abc12345' }); }); it('HTTP 401 时抛出错误(无 refreshToken 不自动刷新)', async () => { (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( async () => makeResponse(401, { code: 401, message: '凭证无效', data: null, requestId: 'r2', timestamp: 't', }), ); await expect( loginByPassword({ phone: '13800138000', password: 'abc12345' }), ).rejects.toThrow(); }); }); describe('sendSmsCode(发送验证码 · 真实后端)', () => { beforeEach(() => { setMockMode(false); (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(); }); it('手机号非法时本地拒绝,不发请求', async () => { await expect(sendSmsCode({ phone: '138', purpose: 'login' })).rejects.toThrow( '手机号格式不正确', ); expect((global as unknown as { fetch: jest.Mock }).fetch).not.toHaveBeenCalled(); }); it('手机号合法时 POST /auth/sms/send(带 purpose)', async () => { (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( async () => makeResponse(200, { code: 200, message: 'ok', data: null, requestId: 'r3', timestamp: 't', }), ); await sendSmsCode({ phone: '13800138000', purpose: 'login' }); const [url, init] = ( (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock ).mock.calls[0]; expect(url).toContain('/auth/sms/send'); // sms/send 后端只接受 Query 参数(body 为空) expect(url).toContain('phone=13800138000'); expect(url).toContain('purpose=login'); expect(init.method).toBe('POST'); expect(init.body).toBeUndefined(); }); }); describe('resetPassword(重置 / 首次设置密码)', () => { it('验证码不合法时拒绝', async () => { await expect( resetPassword({ phone: '13800138000', code: '12', password: 'abc12345' }), ).rejects.toThrow('验证码不正确'); }); it('新密码不合规时返回对应文案', async () => { await expect( resetPassword({ phone: '13800138000', code: '1234', password: '12345678' }), ).rejects.toThrow(PASSWORD_RULE_LABEL['need-letter-digit']); }); it('参数合法时直接下发登录态,用户无需再登录一次', async () => { const result = await resetPassword({ phone: '13800138000', code: '1234', password: 'abc12345', }); expect(result.phone).toBe('13800138000'); expect(result.token).toContain('13800138000'); }); }); describe('changePassword(已登录态改密)', () => { beforeAll(() => setMockMode(true)); it('未填原密码时拒绝', async () => { await expect( changePassword({ oldPassword: '', newPassword: 'abc12345', confirmPassword: 'abc12345' }), ).rejects.toThrow('请输入原密码'); }); it('两次新密码不一致时拒绝', async () => { await expect( changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12346' }), ).rejects.toThrow(PASSWORD_RULE_LABEL.mismatch); }); it('新密码与原密码相同时拒绝', async () => { await expect( changePassword({ oldPassword: 'abc12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }), ).rejects.toThrow(PASSWORD_RULE_LABEL['same-as-old']); }); it('参数合法时成功', async () => { await expect( changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }), ).resolves.toBeUndefined(); }); });