// http.js 单测(B1):错误信封解析 + 非 2xx / 网络错误归一(API.md §1) import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { api, ApiError } from '../../../../ui/src/api/http.js'; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); function jsonRes(payload, status = 200) { return new Response(JSON.stringify(payload), { status, headers: { 'Content-Type': 'application/json' }, }); } describe('api/http.js', () => { beforeEach(() => fetchMock.mockReset()); afterEach(() => vi.clearAllMocks()); it('GET 成功:返回业务 JSON(不包 ok:true 信封)', async () => { fetchMock.mockResolvedValueOnce(jsonRes({ status: 'ok', version: '0.1.0' })); const data = await api.get('/api/health'); expect(data).toEqual({ status: 'ok', version: '0.1.0' }); expect(fetchMock).toHaveBeenCalledWith( '/api/health', expect.objectContaining({ method: 'GET' }) ); }); it('POST:设置 Content-Type 并序列化 body', async () => { fetchMock.mockResolvedValueOnce(jsonRes({ id: 'x' })); await api.post('/api/sessions', { title: '新会话' }); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe('/api/sessions'); expect(opts.method).toBe('POST'); expect(opts.headers['Content-Type']).toBe('application/json'); expect(JSON.parse(opts.body)).toEqual({ title: '新会话' }); }); it('错误信封:抛结构化 ApiError(code/message/degraded)', async () => { fetchMock.mockResolvedValueOnce( jsonRes( { ok: false, error: { code: 'UPSTREAM_DOWN', message: '知识问答服务当前不可用,请稍后重试', degraded: true } }, 503 ) ); await expect(api.post('/api/qa/chat', { question: 'x' })).rejects.toSatisfy((e) => { expect(e).toBeInstanceOf(ApiError); expect(e.code).toBe('UPSTREAM_DOWN'); expect(e.message).toBe('知识问答服务当前不可用,请稍后重试'); expect(e.degraded).toBe(true); expect(e.status).toBe(503); return true; }); }); it('错误信封 degraded 缺省为 false', async () => { fetchMock.mockResolvedValueOnce( jsonRes({ ok: false, error: { code: 'INTERNAL', message: 'BFF 内部错误' } }, 500) ); await expect(api.get('/api/x')).rejects.toMatchObject({ code: 'INTERNAL', degraded: false, status: 500, }); }); it('非 2xx 且无信封:归一为 INTERNAL + HTTP 状态', async () => { fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); await expect(api.get('/api/x')).rejects.toMatchObject({ code: 'INTERNAL', status: 500 }); }); it('网络错误:归一为 NETWORK ApiError(不抛原始 TypeError)', async () => { fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); await expect(api.get('/api/health')).rejects.toSatisfy((e) => { expect(e).toBeInstanceOf(ApiError); expect(e.code).toBe('NETWORK'); expect(e.status).toBe(0); expect(e.degraded).toBe(false); return true; }); }); it('调用方主动 abort:透传原始 AbortError(供 B2 流式中断判断)', async () => { const ctrl = new AbortController(); fetchMock.mockImplementationOnce( (url, opts) => new Promise((_, reject) => { opts.signal.addEventListener('abort', () => reject(new DOMException('The operation was aborted.', 'AbortError')) ); }) ); const p = api.post('/api/qa/chat/stream', { question: 'x' }, { signal: ctrl.signal }); ctrl.abort(); await expect(p).rejects.toBeInstanceOf(DOMException); }); // ---------- BUG-R1-01 修复:get 的 params 序列化为 query string ---------- it('get(path, params):params 拼装 query string(BUG-R1-01)', async () => { fetchMock.mockResolvedValueOnce(jsonRes({ query: 'OsSPL14', results: [] })); await api.get('/api/kg/entities', { q: 'OsSPL14', limit: 20 }); const [url] = fetchMock.mock.calls[0]; expect(url).toBe('/api/kg/entities?q=OsSPL14&limit=20'); }); it('get(params):undefined/null 跳过、数字转字符串、中文正确编码', async () => { fetchMock.mockResolvedValueOnce(jsonRes({})); await api.get('/api/kg/entities', { q: '水稻 SPL', limit: 20, skip: undefined, none: null }); const [url] = fetchMock.mock.calls[0]; // URLSearchParams 标准 form 编码:空格 → '+',中文 → %XX(FastAPI 按此解析) expect(url).toBe('/api/kg/entities?q=' + encodeURIComponent('水稻') + '+SPL&limit=20'); }); it('get 不带 params:URL 原样(无 ? 尾巴)', async () => { fetchMock.mockResolvedValueOnce(jsonRes({})); await api.get('/api/health'); expect(fetchMock.mock.calls[0][0]).toBe('/api/health'); }); it('get 第三参仍收 opts:{ timeout, signal } 透传', async () => { const ctrl = new AbortController(); fetchMock.mockResolvedValueOnce(jsonRes({})); await api.get('/api/kg/entities', { q: 'x' }, { timeout: 0, signal: ctrl.signal }); const [, opts] = fetchMock.mock.calls[0]; expect(opts.signal).toBe(ctrl.signal); }); // ---------- BUG-R1-07 修复:默认读超时覆盖真实上游最长路径 ---------- it('默认读超时 ≥600s(BUG-R1-07:92s 的 qa search 不再被 30s 误杀)', async () => { const ctrl = new AbortController(); fetchMock.mockImplementationOnce( (url, opts) => new Promise((resolve) => { // 在「30s」时刻检查未中止(旧默认超时在此处就会 abort) setTimeout(() => { expect(opts.signal.aborted).toBe(false); resolve(new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })); }, 30000); }) ); vi.useFakeTimers(); try { const p = api.post('/api/qa/search', { question: 'x' }, { signal: ctrl.signal }); await vi.advanceTimersByTimeAsync(30000); const data = await p; expect(data).toEqual({}); expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(false); } finally { vi.useRealTimers(); } }); it('调用方 timeout 覆盖默认值;raw:true 不设读超时', async () => { fetchMock.mockResolvedValueOnce(jsonRes({})); await api.get('/api/kg/entities', { q: 'x' }, { timeout: 0 }); expect(fetchMock).toHaveBeenCalledTimes(1); }); // ---------- FormData 走 multipart(FR-EXTRACT-004 上传契约) ---------- it('post FormData:不设 JSON Content-Type、body 原样透传', async () => { fetchMock.mockResolvedValueOnce(jsonRes({ success: true, job_id: 'j1' })); const form = new FormData(); form.append('file', new File(['x'], 'a.pdf')); await api.post('/api/extraction/upload', form); const [, opts] = fetchMock.mock.calls[0]; expect(opts.body).toBe(form); expect(opts.headers['Content-Type']).toBeUndefined(); }); });