SEC_Warehouse/tests/unit/http/client.spec.ts

219 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' })
})
})