import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' // Taro.request 由各用例设置返回值(vi.hoisted 保证 mock 提升后仍可引用) const mockRequest = vi.hoisted(() => vi.fn()) vi.mock('@tarojs/taro', () => ({ default: { request: mockRequest } })) // client 依赖 store 取 token / dispatch clearAuth;authState 可变,支持用例模拟在途请求期间换 token const authState = vi.hoisted(() => ({ token: 'unit-token' })) const mockDispatch = vi.hoisted(() => vi.fn()) vi.mock('@/store', () => ({ store: { getState: () => ({ auth: { token: authState.token } }), dispatch: mockDispatch } })) vi.mock('@/store/authSlice', () => ({ clearAuth: vi.fn(() => ({ type: 'auth/clearAuth' })) })) // 受控 promise:reLaunch 未 resolve 前 401 防重入 flag 保持 true let releaseRelaunch: () => void = () => {} const mockReLaunch = vi.hoisted( () => vi.fn(() => new Promise((resolve) => { releaseRelaunch = resolve })) ) vi.mock('@/utils/navigation', () => ({ reLaunch: mockReLaunch })) import { request } from '@/http/client' import { ApiError, BusinessError, AuthExpiredError } from '@/http/errors' const okBody = (content: T) => ({ statusCode: 200, data: { Code: '200', Message: '', Content: content, RecordCount: 0, PageIndex: 0, PageSize: 0 } }) beforeEach(() => { mockRequest.mockReset() // token 基线复位:上个用例可能模拟过换 token authState.token = 'unit-token' }) // 兜底释放挂起的 reLaunch,防止 redirectingToLogin flag 泄漏到后续用例 afterEach(() => releaseRelaunch()) describe('client envelope 解析', () => { it('Code=200 时解包返回 Content', async () => { mockRequest.mockResolvedValue(okBody({ name: '张三' })) const res = await request.get<{ name: string }>('/demo') expect(res).toEqual({ name: '张三' }) // GET 参数经 data 透传(Taro 运行时序列化为 query string) expect(mockRequest.mock.calls[0][0].data).toBeUndefined() }) it('GET 参数放入 data 字段', async () => { mockRequest.mockResolvedValue(okBody(null)) await request.get('/demo', { page: 1 }) expect(mockRequest.mock.calls[0][0].data).toEqual({ page: 1 }) }) it("Code='500' 抛 BusinessError(message + 字符串 code)", async () => { mockRequest.mockResolvedValue({ statusCode: 200, data: { Code: '500', Message: '手机号已绑定', Content: null } }) await expect(request.get('/demo')).rejects.toMatchObject({ name: 'BusinessError', message: '手机号已绑定', code: '500' }) await expect(request.get('/demo')).rejects.toBeInstanceOf(BusinessError) }) it('HTTP 404 抛 ApiError 并带 statusCode', async () => { mockRequest.mockResolvedValue({ statusCode: 404, data: 'Not Found' }) await expect(request.get('/demo')).rejects.toMatchObject({ name: 'ApiError', message: 'HTTP 404', statusCode: 404 }) }) it('Taro.request 异常(断网等)抛 ApiError', async () => { mockRequest.mockRejectedValue(new Error('request:fail timeout')) await expect(request.get('/demo')).rejects.toMatchObject({ name: 'ApiError', message: 'request:fail timeout' }) }) it('需鉴权请求注入 Bearer token 头', async () => { mockRequest.mockResolvedValue(okBody(null)) await request.get('/demo') expect(mockRequest.mock.calls[0][0].header.Authorization).toBe('Bearer unit-token') }) it('needAuth: false 时不注入 Authorization', async () => { mockRequest.mockResolvedValue(okBody(null)) await request.post('/demo', {}, { needAuth: false }) expect(mockRequest.mock.calls[0][0].header.Authorization).toBeUndefined() }) it('BASE_URL 拼接:请求 url = BASE_URL + path', async () => { mockRequest.mockResolvedValue(okBody(null)) await request.get('/thirdPlatform/wxDevLogin/miniapp/login') expect(mockRequest.mock.calls[0][0].url).toMatch(/\/api\/biz\/thirdPlatform\/wxDevLogin\/miniapp\/login$/) }) }) describe('登录态失效踢回登录页', () => { const unauthorizedBody = { statusCode: 200, data: { Code: '401', Message: '未登录', Content: null } } beforeEach(() => { mockDispatch.mockClear() mockReLaunch.mockClear() }) it("业务码 '401':抛 AuthExpiredError、dispatch clearAuth、reLaunch 登录页", async () => { mockRequest.mockResolvedValue(unauthorizedBody) await expect(request.get('/yanglao/app/ylHome/getList')).rejects.toBeInstanceOf(AuthExpiredError) expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/clearAuth' }) expect(mockReLaunch).toHaveBeenCalledWith('/pages/login/index') }) it('HTTP 401 同样触发踢登录并抛 AuthExpiredError', async () => { mockRequest.mockResolvedValue({ statusCode: 401, data: { Code: '401', Message: 'token 无效' } }) await expect(request.get('/demo')).rejects.toMatchObject({ name: 'AuthExpiredError' }) expect(mockDispatch).toHaveBeenCalledTimes(1) }) it('陈旧 401:请求发起后 token 已更换时不误清新会话', async () => { mockRequest.mockResolvedValue(unauthorizedBody) const p = request.get('/a').catch((e) => e) await Promise.resolve() authState.token = 'new-token' // 模拟用户重新登录 await p expect(mockDispatch).not.toHaveBeenCalled() expect(mockReLaunch).not.toHaveBeenCalled() }) it('防重入:跳转进行中并发的第二个 401 请求不重复 dispatch/reLaunch', async () => { mockRequest.mockResolvedValue(unauthorizedBody) const p1 = request.get('/a').catch((e) => e) const p2 = request.get('/b').catch((e) => e) await Promise.all([p1, p2]) // 此刻 releaseRelaunch 未调用、防重入 flag 仍为 true expect(mockDispatch).toHaveBeenCalledTimes(1) expect(mockReLaunch).toHaveBeenCalledTimes(1) }) it("普通业务错 '500' 不触发踢登录", async () => { mockRequest.mockResolvedValue({ statusCode: 200, data: { Code: '500', Message: '服务器开小差', Content: null } }) await expect(request.get('/demo')).rejects.toBeInstanceOf(BusinessError) expect(mockDispatch).not.toHaveBeenCalled() expect(mockReLaunch).not.toHaveBeenCalled() }) it('HTTP 500 抛 ApiError 且不踢登录', async () => { mockRequest.mockResolvedValue({ statusCode: 500, data: '' }) await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError) expect(mockDispatch).not.toHaveBeenCalled() }) it('200 但 body 非 envelope 形态(HTML 拦截页等)抛 ApiError 而非 BusinessError', async () => { mockRequest.mockResolvedValue({ statusCode: 200, data: 'blocked' }) await expect(request.get('/demo')).rejects.toMatchObject({ name: 'ApiError', message: '响应格式异常', statusCode: 200 }) expect(mockDispatch).not.toHaveBeenCalled() }) it('200 且 body 为 null 抛 ApiError 而非 TypeError', async () => { mockRequest.mockResolvedValue({ statusCode: 200, data: null }) await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError) }) })