import { describe, it, expect, beforeEach, vi } from 'vitest' import { createTestPinia } from '../helpers/app.js' import { useAuthStore } from '@/stores/auth' // http 模块持有模块级状态(redirectingToLogin),每个用例前重置模块并动态 import, // 保证跳转去重标志不跨用例泄漏。 async function loadHttp() { vi.resetModules() const { default: http } = await import('@/services/http') return http } function stubLocation() { // jsdom 的 location.assign 会抛 "Not implemented",替换为 spy const assign = vi.fn() Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, assign, pathname: '/dashboard' }, }) return assign } const requestFulfilled = (http) => http.interceptors.request.handlers[0].fulfilled const responseFulfilled = (http) => http.interceptors.response.handlers[0].fulfilled const responseRejected = (http) => http.interceptors.response.handlers[0].rejected beforeEach(() => { localStorage.clear() sessionStorage.clear() vi.restoreAllMocks() }) describe('http 请求拦截器', () => { it('无 token 时不注入 Authorization', async () => { const http = await loadHttp() createTestPinia() const config = { headers: {} } const result = requestFulfilled(http)(config) expect(result.headers.Authorization).toBeUndefined() }) it('有 token 时注入 Bearer 头', async () => { const http = await loadHttp() createTestPinia() useAuthStore().setSession({ token: 'tk-123' }, 'ORG') const config = { headers: {} } const result = requestFulfilled(http)(config) expect(result.headers.Authorization).toBe('Bearer tk-123') }) }) describe('http 响应拦截器 · 成功分支', () => { it('code === 0:标记 success 并原样返回', async () => { const http = await loadHttp() const res = { data: { code: 0, data: [1, 2] } } const result = responseFulfilled(http)(res) expect(result.data.success).toBe(true) expect(result).toBe(res) }) it('无 code 字段:按成功透传并包装 ApiResult', async () => { const http = await loadHttp() const payload = { id: 1, name: 'x' } const result = responseFulfilled(http)({ data: payload }) expect(result).toEqual({ success: true, code: 0, message: '', data: payload, }) }) it('业务错误码(非认证):抛 Error 并携带 code/data/success', async () => { const http = await loadHttp() const res = { data: { code: 500, message: '服务器开小差了', data: { a: 1 } } } let caught try { responseFulfilled(http)(res) } catch (e) { caught = e } expect(caught).toBeInstanceOf(Error) expect(caught.message).toBe('服务器开小差了') expect(caught.code).toBe(500) expect(caught.data).toEqual({ a: 1 }) expect(caught.success).toBe(false) }) it('业务错误码无 message:使用默认文案', async () => { const http = await loadHttp() let caught try { responseFulfilled(http)({ data: { code: 1001 } }) } catch (e) { caught = e } expect(caught.message).toBe('请求失败') }) }) describe('http 响应拦截器 · 认证失效分支', () => { it('401:清空会话并跳转 /login?reason=expired', async () => { const http = await loadHttp() createTestPinia() useAuthStore().setSession({ token: 't', user: { id: 1 } }, 'ORG') const assign = stubLocation() let caught try { responseRejected(http)({ response: { status: 401, data: { code: 401 } } }) } catch (e) { caught = e } expect(useAuthStore().isAuthenticated).toBe(false) expect(assign).toHaveBeenCalledTimes(1) const url = assign.mock.calls[0][0] expect(url.startsWith('/login?')).toBe(true) expect(url).toContain('reason=expired') expect(caught.code).toBe(401) }) it('40101:跳转 /login?reason=session-replaced', async () => { const http = await loadHttp() createTestPinia() useAuthStore().setSession({ token: 't', user: { id: 1 } }, 'ORG') const assign = stubLocation() try { responseRejected(http)({ response: { status: 200, data: { code: 40101 } } }) } catch { // 预期抛错 } const url = assign.mock.calls[0][0] expect(url).toContain('reason=session-replaced') }) it('已在 /login 页面时不重复跳转(不抛导航错误)', async () => { const http = await loadHttp() createTestPinia() const assign = vi.fn() Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, assign, pathname: '/login' }, }) let caught try { responseRejected(http)({ response: { status: 401, data: { code: 401 } } }) } catch (e) { caught = e } expect(assign).not.toHaveBeenCalled() expect(caught.code).toBe(401) }) }) describe('http 响应拦截器 · 网络异常分支', () => { it('超时(ECONNABORTED):抛"请求超时"', async () => { const http = await loadHttp() stubLocation() let caught try { responseRejected(http)({ code: 'ECONNABORTED' }) } catch (e) { caught = e } expect(caught.message).toBe('请求超时,请稍后重试') expect(caught.code).toBe(-1) }) it('其他网络错误:抛"网络异常"', async () => { const http = await loadHttp() stubLocation() let caught try { responseRejected(http)({ message: 'Network Error' }) } catch (e) { caught = e } expect(caught.message).toBe('网络异常,请检查服务是否正常') expect(caught.code).toBe(-1) }) it('HTTP 状态错误且带后端 message:优先使用后端文案', async () => { const http = await loadHttp() stubLocation() let caught try { responseRejected(http)({ response: { status: 500, data: { code: 500, message: '后端错误信息' } }, }) } catch (e) { caught = e } expect(caught.message).toBe('后端错误信息') expect(caught.code).toBe(500) }) })