219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
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<void>((resolve) => { releaseRelaunch = resolve }))
|
||
)
|
||
vi.mock('@/utils/navigation', () => ({ reLaunch: mockReLaunch }))
|
||
|
||
import { request } from '@/http/client'
|
||
import { ApiError, BusinessError, AuthExpiredError } from '@/http/errors'
|
||
|
||
const okBody = <T>(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('Taro.request reject 的是 { errMsg } 形状对象时透传 errMsg(微信真实失败形态)', async () => {
|
||
mockRequest.mockRejectedValue({ errMsg: 'request:fail url not in domain list' })
|
||
await expect(request.get('/demo')).rejects.toMatchObject({
|
||
name: 'ApiError',
|
||
message: 'request:fail url not in domain list'
|
||
})
|
||
})
|
||
|
||
it('reject 无任何可读信息时兜底「网络异常」', async () => {
|
||
mockRequest.mockRejectedValue(undefined)
|
||
await expect(request.get('/demo')).rejects.toMatchObject({
|
||
name: 'ApiError',
|
||
message: '网络异常'
|
||
})
|
||
})
|
||
|
||
it('需鉴权请求注入 token 头(后端约定自定义头,无 Bearer 前缀)', async () => {
|
||
mockRequest.mockResolvedValue(okBody(null))
|
||
await request.get('/demo')
|
||
expect(mockRequest.mock.calls[0][0].header.token).toBe('unit-token')
|
||
})
|
||
|
||
it('needAuth: false 时不注入 token 头', async () => {
|
||
mockRequest.mockResolvedValue(okBody(null))
|
||
await request.post('/demo', {}, { needAuth: false })
|
||
expect(mockRequest.mock.calls[0][0].header.token).toBeUndefined()
|
||
})
|
||
|
||
it('BASE_URL 拼接:请求 url = BASE_URL + path', async () => {
|
||
mockRequest.mockResolvedValue(okBody(null))
|
||
await request.get('/thirdPlatform/wxDevLogin/miniapp/login')
|
||
// BASE_URL 前缀由后端网关决定(当前 /api/user),此处只锁定 path 拼接
|
||
expect(mockRequest.mock.calls[0][0].url).toMatch(/\/api\/user\/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: '<html>blocked</html>' })
|
||
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)
|
||
})
|
||
|
||
it("200 响应的 Message 子状态(needUserInfo 等)附加到返回值 message 字段", async () => {
|
||
mockRequest.mockResolvedValue({
|
||
statusCode: 200,
|
||
data: { Code: '200', Message: 'needUserInfo', Content: { openId: 'o-1', bindKey: 'b-1' } }
|
||
})
|
||
const res = await request.get<{ openId: string }>('/demo')
|
||
expect(res).toMatchObject({ openId: 'o-1', bindKey: 'b-1', message: 'needUserInfo' })
|
||
})
|
||
|
||
it("Message='noMobile' 同样附加(手机号授权子状态)", async () => {
|
||
mockRequest.mockResolvedValue({
|
||
statusCode: 200,
|
||
data: { Code: '200', Message: 'noMobile', Content: { bindKey: 'b-2' } }
|
||
})
|
||
const res = await request.get('/demo')
|
||
expect(res).toMatchObject({ bindKey: 'b-2', message: 'noMobile' })
|
||
})
|
||
})
|