- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
170 lines
6.8 KiB
JavaScript
170 lines
6.8 KiB
JavaScript
// 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();
|
||
});
|
||
});
|