import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const addPendingMock = vi.fn() const removePendingMock = vi.fn() const createMock = vi.fn() const alertMock = vi.fn() const messageSuccessMock = vi.fn() const messageErrorMock = vi.fn() const startMock = vi.fn() const doneMock = vi.fn() const getItemMock = vi.fn((name: string, type?: string) => { if (name === 'x-auth-token') { return 'mock-token' } if (name === 'language' && type === 'localStorage') { return 'en' } return undefined }) vi.mock('axios', () => ({ default: { create: createMock } })) vi.mock('nprogress', () => ({ default: { start: startMock, done: doneMock } })) vi.mock('element-plus', () => ({ ElMessageBox: { alert: alertMock }, ElMessage: { success: messageSuccessMock, error: messageErrorMock } })) vi.mock('../../../script/cancalToken', () => ({ default: { addPending: addPendingMock, removePending: removePendingMock } })) vi.mock('../../../script/storage', () => ({ __getItem: getItemMock })) import { createRequest } from '../../../script/createRequest' describe('createRequest', () => { let requestInterceptor: ((config: any) => any) | undefined let requestRejectedInterceptor: ((error: any) => any) | undefined let responseInterceptor: ((response: any) => any) | undefined let responseRejectedInterceptor: ((error: any) => any) | undefined let requestMock: any let serviceMock: any let originalHref: string let originalPathname: string beforeEach(() => { vi.clearAllMocks() getItemMock.mockImplementation((name: string, type?: string) => { if (name === 'x-auth-token') { return 'mock-token' } if (name === 'language' && type === 'localStorage') { return 'en' } return undefined }) requestInterceptor = undefined requestRejectedInterceptor = undefined responseInterceptor = undefined responseRejectedInterceptor = undefined originalHref = window.location.href originalPathname = window.location.pathname window.sessionStorage.clear() window.localStorage.clear() window.sessionStorage.setItem( 'serveConfig', JSON.stringify({ baseURL: '/gateway', token: 'X-TOKEN', logoutPrompt: '登录失效', defaultLoginHref: '/login' }) ) window.history.replaceState({}, '', '/resource/list') requestMock = vi.fn((config: any) => Promise.resolve({ data: { ok: true }, config })) serviceMock = { defaults: { headers: { post: {} } }, interceptors: { request: { use: vi.fn((fulfilled, rejected) => { requestInterceptor = fulfilled requestRejectedInterceptor = rejected }) }, response: { use: vi.fn((fulfilled, rejected) => { responseInterceptor = fulfilled responseRejectedInterceptor = rejected }) } }, request: requestMock } createMock.mockReturnValue(serviceMock) }) afterEach(() => { window.history.replaceState({}, '', originalPathname || '/') window.sessionStorage.clear() window.localStorage.clear() }) it('默认使用 serveConfig.baseURL 创建请求实例,并设置 post content-type', () => { createRequest() expect(createMock).toHaveBeenCalledWith({ baseURL: '/gateway', withCredentials: true }) expect(serviceMock.defaults.headers.post['Content-Type']).toBe('application/json;charset=UTF-8') }) it('传入 requestBaseURL 时覆盖默认 baseURL', () => { createRequest('/custom-api') expect(createMock).toHaveBeenCalledWith({ baseURL: '/custom-api', withCredentials: true }) }) it('请求拦截器会注入 token、语言、AUTH-ROUTE 并处理取消队列', () => { createRequest() const config = { method: 'get', url: '/users', headers: {} } const result = requestInterceptor!(config) expect(removePendingMock).toHaveBeenCalledWith(config) expect(addPendingMock).toHaveBeenCalledWith(config) expect(result.headers['X-TOKEN']).toBe('mock-token') expect(result.headers['accept-language']).toBe('en-US') expect(result.headers['AUTH-ROUTE']).toBe('/resource/list') }) it('请求拦截器在无 token 时不会写入 token 头,但仍保留其他头', () => { getItemMock.mockImplementation((name: string, type?: string) => { if (name === 'language' && type === 'localStorage') return 'zh' return undefined }) createRequest() const config = { method: 'get', url: '/users', headers: {} } const result = requestInterceptor!(config) expect(result.headers['X-TOKEN']).toBeUndefined() expect(result.headers['accept-language']).toBe('zh-CN') expect(result.headers['AUTH-ROUTE']).toBe('/resource/list') }) it('请求错误拦截器遇到 CanceledError 返回 null', () => { createRequest() expect(requestRejectedInterceptor!({ name: 'CanceledError' })).toBeNull() }) it('响应成功拦截器会移除 pending 并透传响应', () => { createRequest() const response = { data: { code: 0 }, config: { method: 'get', url: '/users' } } const result = responseInterceptor!(response) expect(removePendingMock).toHaveBeenCalledWith(response.config) expect(result).toBe(response) }) it('响应成功拦截器在 code=111 时会清空 sessionStorage token', () => { createRequest() sessionStorage.setItem('token', 'expired-token') responseInterceptor!({ data: { code: 111 }, config: { method: 'get', url: '/users' } }) expect(sessionStorage.getItem('token')).toBe('') }) it('响应错误拦截器遇到 CanceledError 时直接返回 undefined', () => { createRequest() expect(responseRejectedInterceptor!({ name: 'CanceledError' })).toBeUndefined() }) it('响应错误拦截器遇到 401 时弹窗并暴露登录跳转 callback', () => { createRequest() responseRejectedInterceptor!({ response: { status: 401 } }) expect(alertMock).toHaveBeenCalledTimes(1) const [, , options] = alertMock.mock.calls[0] expect(alertMock.mock.calls[0][0]).toBe('登录失效') expect(typeof options.callback).toBe('function') expect(typeof options.beforeClose).toBe('function') const done = vi.fn() options.beforeClose('confirm', {}, done) expect(done).toHaveBeenCalledTimes(1) }) it('响应错误拦截器对非 401 错误返回 Promise.resolve(err)', async () => { createRequest() const err = { response: { status: 500 }, message: 'server error' } await expect(responseRejectedInterceptor!(err)).resolves.toBe(err) }) it('get 会调用 service.request,传入 method=get 和 params,并正确关闭进度条', async () => { const request = createRequest() const response = { data: { ok: true } } requestMock.mockResolvedValueOnce(response) await expect(request.get('/users', { page: 1 })).resolves.toBe(response) expect(requestMock).toHaveBeenCalledWith({ url: '/users', method: 'get', params: { page: 1 } }) expect(startMock).toHaveBeenCalledTimes(1) expect(doneMock).toHaveBeenCalledTimes(1) }) it('post 会序列化对象参数并合并额外配置', async () => { const request = createRequest() const response = { data: { ok: true } } requestMock.mockResolvedValueOnce(response) await request.post('/users', { name: 'tom' }, { timeout: 5000 }) expect(requestMock).toHaveBeenCalledWith({ url: '/users', method: 'post', data: JSON.stringify({ name: 'tom' }), timeout: 5000 }) }) it('post 对字符串参数保持原样,不做 JSON.stringify', async () => { const request = createRequest() requestMock.mockResolvedValueOnce({ data: { ok: true } }) await request.post('/users', 'raw-body') expect(requestMock).toHaveBeenCalledWith({ url: '/users', method: 'post', data: 'raw-body' }) }) it('put 和 delete 会把参数放到 data 中', async () => { const request = createRequest() requestMock.mockResolvedValue({ data: { ok: true } }) await request.put('/users/1', { name: 'jack' }) await request.delete('/users/1', { force: true }) expect(requestMock).toHaveBeenNthCalledWith(1, { url: '/users/1', method: 'put', data: { name: 'jack' } }) expect(requestMock).toHaveBeenNthCalledWith(2, { url: '/users/1', method: 'delete', data: { force: true } }) }) it('upload 会带上 multipart/form-data 头并合并额外配置', async () => { const request = createRequest() requestMock.mockResolvedValueOnce({ data: { ok: true } }) const file = new FormData() await request.upload('/upload', file, { timeout: 1000 }) expect(requestMock).toHaveBeenCalledWith({ url: '/upload', method: 'post', data: file, headers: { 'Content-Type': 'multipart/form-data' }, timeout: 1000 }) }) it('请求失败时会 reject,并确保进度条关闭', async () => { const request = createRequest() const error = new Error('network fail') requestMock.mockRejectedValueOnce(error) await expect(request.get('/fail')).rejects.toThrow('network fail') expect(startMock).toHaveBeenCalledTimes(1) expect(doneMock).toHaveBeenCalledTimes(1) }) it('download 会创建 iframe、设置 src,并在 onload 后移除', () => { const request = createRequest() const appendSpy = vi.spyOn(document.body, 'appendChild') const removeSpy = vi.spyOn(document.body, 'removeChild') request.download('/download/file') const iframe = appendSpy.mock.calls[0][0] as HTMLIFrameElement expect(iframe.tagName).toBe('IFRAME') expect(iframe.style.display).toBe('none') expect(iframe.src).toContain('/download/file') iframe.onload!(new Event('load')) expect(removeSpy).toHaveBeenCalledWith(iframe) }) })