Files
PlantAI-rag-new/tests/frontend/unit/b1/http.test.js
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- 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>
2026-08-31 12:56:47 +08:00

170 lines
6.8 KiB
JavaScript
Raw Permalink 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.
// 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();
});
});