| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695 |
- import { ParseApi } from './parse-api';
- /**
- * ParseApi 单测 —— 真实数据源映射层
- *
- * 依据《API-契约.md》与《数据访问层设计.md》§三(映射只在此处发生)。
- *
- * 🔴 **本文件是回归重点**:开发期间此层踩过最多坑 ——
- * ① 绑定表 `accessToken` 误填 Agent token(身份错)
- * ② `host`/`channels`/`balance` 是**字符串形态 JSON**(存量 parseJsonLoose 教训)
- * ③ 旧键 `remainRmb` 需归一为 `remainCny`
- * ④ `fetchTasks` 云函数未实现时**必须抛错**,不得静默返回空数组
- * ⑤ 错误文案取值顺序 `msg` → `mess` → `message`
- *
- * 手法:stub 全局 `fetch`,断言「请求形状」与「映射结果」两侧。
- */
- describe('ParseApi', () => {
- let api: ParseApi;
- let calls: { url: string; init: RequestInit }[] = [];
- /** 构造一个受控的 fetch 桩:按序返回预设响应 */
- function stubFetch(responses: { status?: number; body: unknown }[]): void {
- let i = 0;
- spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
- calls.push({ url: String(input), init: init ?? {} });
- const r = responses[Math.min(i++, responses.length - 1)];
- const status = r.status ?? 200;
- return Promise.resolve({
- ok: status >= 200 && status < 300,
- status,
- json: () => Promise.resolve(r.body),
- text: () => Promise.resolve(JSON.stringify(r.body)),
- } as unknown as Response);
- });
- }
- const bodyOf = (idx = 0): Record<string, unknown> =>
- JSON.parse(String(calls[idx].init.body)) as Record<string, unknown>;
- beforeEach(() => {
- calls = [];
- api = new ParseApi(() => 'r:session-token-abc');
- });
- // ---------- 请求形状(契约 §1.1) ----------
- describe('云函数调用协议(API-契约 §1.1)', () => {
- it('POST 到 /api/functions,带 {id, token, params} 体', async () => {
- stubFetch([{ body: { code: 200, agents: [] } }]);
- await api.fetchAgents();
- expect(calls[0].url).toContain('/api/functions');
- expect(calls[0].init.method).toBe('POST');
- const headers = calls[0].init.headers as Record<string, string>;
- /*
- * 🔴 云函数调用**不能带任何 `X-Parse-*` 头**(2026-11-11 线上 CORS 实测)。
- *
- * 服务端网关多实例、预检响应不一致:固定白名单里没有任何 `x-parse-*`,
- * 命中那台就整条请求被 CORS 拦(实测 10 次里 6 次被拒)。
- * 第一轮只去掉 app-id,问题立刻在 session-token 上重现 —— 所以全部不带。
- */
- const parseHeaders = Object.keys(headers).filter((k) => k.toLowerCase().startsWith('x-parse-'));
- expect(parseHeaders).withContext('会触发线上 CORS 预检失败').toEqual([]);
- /* 鉴权改由 body 的 token 承担 */
- expect(bodyOf()['token']).toBe('r:session-token-abc');
- const body = bodyOf();
- expect(body['id']).toBeTruthy();
- expect(body['token']).toBe('r:session-token-abc');
- expect(body['params']).toBeTruthy();
- });
- it('令牌**每次调用实时读取**(不在构造时固化)', async () => {
- let tok = 'first';
- const dynamic = new ParseApi(() => tok);
- stubFetch([{ body: { code: 200, agents: [] } }, { body: { code: 200, agents: [] } }]);
- await dynamic.fetchAgents();
- tok = 'second';
- await dynamic.fetchAgents();
- expect(bodyOf(0)['token']).toBe('first');
- expect(bodyOf(1)['token']).toBe('second');
- });
- it('未登录(无令牌)时不带 Session-Token 头', async () => {
- const anon = new ParseApi(() => '');
- stubFetch([{ body: { code: 200, agents: [] } }]);
- await anon.fetchAgents();
- const headers = calls[0].init.headers as Record<string, string>;
- expect(headers['X-Parse-Session-Token']).toBeUndefined();
- });
- });
- // ---------- 错误文案(msg → mess → message) ----------
- describe('错误文案取值顺序', () => {
- it('HTTP 非 2xx → 抛含状态码的错误', async () => {
- stubFetch([{ status: 500, body: {} }]);
- await expectAsync(api.fetchAgents()).toBeRejectedWithError(/HTTP 500/);
- });
- it('code!=200 且带 mess → 用 mess 文案', async () => {
- stubFetch([{ body: { code: 401, mess: 'SessionToken 无效或已过期' } }]);
- await expectAsync(api.fetchAgents()).toBeRejectedWithError(/SessionToken 无效或已过期/);
- });
- it('msg 优先于 mess', async () => {
- stubFetch([{ body: { code: 403, msg: '优先', mess: '次选', message: '末选' } }]);
- await expectAsync(api.fetchAgents()).toBeRejectedWithError(/优先/);
- });
- it('仅 message 存在时用 message', async () => {
- stubFetch([{ body: { code: 403, message: '末选文案' } }]);
- await expectAsync(api.fetchAgents()).toBeRejectedWithError(/末选文案/);
- });
- });
- // ---------- Agent 映射(字符串 JSON 兼容) ----------
- describe('fetchAgents 映射', () => {
- const RAW = {
- objectId: 'qRovdDU7cY',
- agentId: 'agent-node-xinting',
- name: 'agent-node-xinting',
- botName: '芯葶001',
- botAvatar: 'https://s3.fmode.cn/a.png',
- gender: 'female',
- status: 'running',
- currentTask: '写文档',
- progress: 0.5,
- // 🔴 字符串形态 JSON(存量 parseJsonLoose 的教训)
- host: '{"cpuPercent":3.4,"memPercent":52.4,"diskPercent":35}',
- balance: '{"remainCny":17842.31}',
- channels: '{"matrix":true,"homeserver":"https://matrix.fmode.cn"}',
- user: { objectId: 'dhv9yTqK6K', nickname: '用户7536' },
- company: { objectId: 'rpnXZC1loU', __type: 'Pointer' },
- };
- it('字符串 JSON 字段被解析为对象', async () => {
- stubFetch([{ body: { code: 200, agents: [RAW] } }]);
- const [a] = await api.fetchAgents();
- expect(a.host).toEqual({ cpuPercent: 3.4, memPercent: 52.4, diskPercent: 35 });
- expect(a.balance?.remainCny).toBe(17842.31);
- expect(a.channels?.matrix).toBe(true);
- });
- it('已是对象的字段同样兼容(两种形态都能吃)', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- agents: [{ ...RAW, host: { cpuPercent: 1 }, balance: { remainCny: 9 } }],
- },
- },
- ]);
- const [a] = await api.fetchAgents();
- expect(a.host?.cpuPercent).toBe(1);
- expect(a.balance?.remainCny).toBe(9);
- });
- it('非法 JSON 字符串不抛错,字段降级为 undefined', async () => {
- stubFetch([{ body: { code: 200, agents: [{ ...RAW, host: '{坏 JSON' }] } }]);
- const [a] = await api.fetchAgents();
- expect(a.host).toBeUndefined();
- expect(a.id).toBe('agent-node-xinting'); // 其余字段照常
- });
- it('🔴 旧键 remainRmb 归一为 remainCny(映射层职责)', async () => {
- stubFetch([{ body: { code: 200, agents: [{ ...RAW, balance: '{"remainRmb":86.4}' }] } }]);
- const [a] = await api.fetchAgents();
- expect(a.balance?.remainCny).toBe(86.4);
- });
- it('owner_token 余额标记为共用账号,并保留 usedCny(2026-11-11)', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- agents: [
- {
- ...RAW,
- balance: '{"remainCny":948.09,"usedCny":257,"source":"owner_token"}',
- },
- ],
- },
- },
- ]);
- const [a] = await api.fetchAgents();
- expect(a.balance?.remainCny).toBe(948.09);
- expect(a.balance?.usedCny).toBe(257);
- expect(a.balance?.sharedAccount).toBeTrue();
- });
- it('容器自报余额(非 owner_token)不标共用', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- agents: [{ ...RAW, balance: '{"remainCny":12,"source":"session"}' }],
- },
- },
- ]);
- const [a] = await api.fetchAgents();
- expect(a.balance?.sharedAccount).toBeUndefined();
- });
- it('id 优先取 agentId,缺失时回落 objectId', async () => {
- stubFetch([
- { body: { code: 200, agents: [{ ...RAW, agentId: undefined, objectId: 'FALLBACK' }] } },
- ]);
- const [a] = await api.fetchAgents();
- expect(a.id).toBe('FALLBACK');
- });
- it('name 优先 botName,gender/status 有默认值', async () => {
- stubFetch([
- { body: { code: 200, agents: [{ objectId: 'x', botName: '显示名' }] } },
- ]);
- const [a] = await api.fetchAgents();
- expect(a.name).toBe('显示名');
- expect(a.gender).toBe('unknown');
- expect(a.status).toBe('offline');
- });
- it('matrixReady 采用账号级口径(matrixConfig.userId)', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- agents: [{ ...RAW, channels: '{"matrix":true,"matrixConfig":{"userId":"@x:fmode.cn"}}' }],
- },
- },
- ]);
- const [a] = await api.fetchAgents();
- expect(a.matrixReady).toBe(true);
- });
- it('owner 从 user 指针还原', async () => {
- stubFetch([{ body: { code: 200, agents: [RAW] } }]);
- const [a] = await api.fetchAgents();
- expect(a.owner?.id).toBe('dhv9yTqK6K');
- expect(a.owner?.name).toBe('用户7536');
- });
- it('agents 非数组 → 抛错(不伪装成空列表)', async () => {
- stubFetch([{ body: { code: 200 } }]);
- await expectAsync(api.fetchAgents()).toBeRejected();
- });
- });
- describe('fetchAgentStatus', () => {
- it('code 404 → 返回 null(页面自行显示不存在)', async () => {
- stubFetch([{ body: { code: 404 } }]);
- expect(await api.fetchAgentStatus('nope')).toBeNull();
- });
- it('code 200 → 返回映射后的 Agent', async () => {
- stubFetch([{ body: { code: 200, agent: { agentId: 'a1', status: 'idle' } } }]);
- const a = await api.fetchAgentStatus('a1');
- expect(a?.id).toBe('a1');
- expect(a?.status).toBe('idle');
- });
- });
- // ---------- provision(契约 §2.3 校验规则) ----------
- describe('provisionMatrix / validateMatrix', () => {
- const OK = {
- code: 200,
- cached: true,
- bindingId: 'iyHgfhOAZ5',
- userMxId: '@sr2wipsdyq:fmode.cn',
- agentMxId: '@zgdcmgnqcd:fmode.cn',
- homeserver: 'https://matrix.fmode.cn',
- accessToken: 'tok',
- deviceId: 'DEV1',
- roomId: '!room:fmode.cn',
- status: 'active',
- };
- it('code 200 + token + userMxId → 通过', async () => {
- stubFetch([{ body: OK }]);
- const r = await api.provisionMatrix('agent-node-lj-test');
- expect(r.agentMxId).toBe('@zgdcmgnqcd:fmode.cn');
- expect(r.roomId).toBe('!room:fmode.cn');
- });
- it('🔴 缺 accessToken → 抛错(不得把失败当成功)', async () => {
- stubFetch([{ body: { ...OK, accessToken: '' } }]);
- await expectAsync(api.provisionMatrix('a')).toBeRejected();
- });
- it('🔴 缺 userMxId → 抛错', async () => {
- stubFetch([{ body: { ...OK, userMxId: null } }]);
- await expectAsync(api.provisionMatrix('a')).toBeRejected();
- });
- it('validateMatrix 传 validate:1', async () => {
- stubFetch([{ body: OK }]);
- await api.validateMatrix('agent-x');
- expect((bodyOf()['params'] as Record<string, unknown>)['validate']).toBe(1);
- });
- });
- // ---------- 任务看板:显式抛错(SPEC FR-07b 依赖声明) ----------
- describe('fetchTasks(云函数未实现)', () => {
- it('🔴 必须**抛错**,不得静默返回空数组(空数组会把「未接入」伪装成「没有任务」)', async () => {
- await expectAsync(api.fetchTasks('a')).toBeRejectedWithError(/未就绪|未实现/);
- // 且**不发起任何网络请求**
- expect(calls.length).toBe(0);
- });
- it('fetchTaskDetail 同样抛错且不发请求', async () => {
- await expectAsync(api.fetchTaskDetail('t1')).toBeRejectedWithError(/未就绪|未实现/);
- expect(calls.length).toBe(0);
- });
- });
- // ---------- 上传(OBS / S3 预签名,契约 §2.5) ----------
- describe('requestUploadUrl', () => {
- it('缺 uploadUrl 即抛错(不得静默降级为本地 blob)', async () => {
- stubFetch([{ body: { code: 200, key: 'k' } }]);
- await expectAsync(api.requestUploadUrl('a.txt', 'text/plain', 10)).toBeRejected();
- });
- it('publicUrl 缺失时按 s3.fmode.cn/<key> 兜底', async () => {
- stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/a.txt' } }]);
- const t = await api.requestUploadUrl('a.txt', 'text/plain', 10);
- expect(t.publicUrl).toBe('https://s3.fmode.cn/dir/a.txt');
- });
- it('原样透传服务端给的 headers', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- uploadUrl: 'u',
- key: 'k',
- headers: { 'x-obs-acl': 'private' },
- },
- },
- ]);
- const t = await api.requestUploadUrl('a.txt', 'text/plain', 1);
- expect(t.headers?.['x-obs-acl']).toBe('private');
- });
- });
- describe('uploadFile(S3 直传)', () => {
- it('XHR PUT 到预签名地址,默认带 public-read 与 Content-Type', async () => {
- stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/x.png' } }]);
- const opened: { method: string; url: string; headers: Record<string, string> }[] = [];
- const origXhr = window.XMLHttpRequest;
- // 最小 XHR 桩:记录 open/setRequestHeader,send 后立刻 onload
- (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
- const rec = { method: '', url: '', headers: {} as Record<string, string> };
- const self = {
- upload: { onprogress: null as unknown },
- status: 200,
- onload: null as unknown,
- onerror: null as unknown,
- open(m: string, u: string) {
- rec.method = m;
- rec.url = u;
- },
- setRequestHeader(k: string, v: string) {
- rec.headers[k] = v;
- },
- send() {
- opened.push(rec);
- (self.onload as () => void)?.();
- },
- };
- return self;
- };
- try {
- const file = new File(['hello'], 'x.png', { type: 'image/png' });
- const r = await api.uploadFile(file);
- expect(r.key).toBe('dir/x.png');
- expect(r.publicUrl).toBe('https://s3.fmode.cn/dir/x.png');
- expect(opened[0].method).toBe('PUT');
- expect(opened[0].url).toBe('https://s3.fmode.cn/put');
- expect(opened[0].headers['Content-Type']).toBe('image/png');
- expect(opened[0].headers['x-obs-acl']).toBe('public-read');
- } finally {
- (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
- }
- });
- it('上传 HTTP 失败 → 抛错', async () => {
- stubFetch([{ body: { code: 200, uploadUrl: 'u', key: 'k' } }]);
- const origXhr = window.XMLHttpRequest;
- (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
- const self = {
- upload: { onprogress: null as unknown },
- status: 403,
- onload: null as unknown,
- onerror: null as unknown,
- open() {},
- setRequestHeader() {},
- send() {
- (self.onload as () => void)?.();
- },
- };
- return self;
- };
- try {
- const file = new File(['x'], 'x.txt', { type: 'text/plain' });
- await expectAsync(api.uploadFile(file)).toBeRejectedWithError(/HTTP 403/);
- } finally {
- (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
- }
- });
- });
- // ---------- 交付成果 ----------
- describe('fetchDeliverables', () => {
- it('status 含 fail/error → state=failed,否则 delivered', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- deliverables: [
- { objectId: '1', title: 'A', status: 'delivered' },
- { objectId: '2', title: 'B', status: 'failed' },
- { objectId: '3', title: 'C', status: 'error_timeout' },
- ],
- },
- },
- ]);
- const list = await api.fetchDeliverables();
- expect(list.map((d) => d.state)).toEqual(['delivered', 'failed', 'failed']);
- });
- it('artifacts 与 tags 缺省为空数组(不产生 undefined 崩溃)', async () => {
- stubFetch([{ body: { code: 200, deliverables: [{ objectId: '1', title: 'A' }] } }]);
- const [d] = await api.fetchDeliverables();
- expect(d.artifacts).toEqual([]);
- expect(d.tags).toEqual([]);
- });
- /*
- * 显示名与存储键分离(2026-11-10 用户裁决)。
- *
- * 中文文件名进不了签名 URL,所以存储键由服务端生成(含随机段)。
- * 展示名必须来自 `name` 字段,而不是从 url 末段硬取。
- */
- it('🔴 有 name 字段时,展示名用 name 而不是存储键末段', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- deliverables: [
- {
- objectId: '1',
- title: '周报',
- artifacts: [
- {
- name: '新同事入职清单.md',
- url: 'https://s3.fmode.cn/deliverables/a/202611/x/0-mwa2gx2gog-abc123.md',
- type: 'md',
- },
- ],
- },
- ],
- },
- },
- ]);
- const [d] = await api.fetchDeliverables();
- expect(d.artifacts[0].name).toBe('新同事入职清单.md');
- });
- it('没有 name 时从存储键剥掉内部前缀', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- deliverables: [
- {
- objectId: '1',
- title: '周报',
- artifacts: ['https://s3.fmode.cn/deliverables/a/202611/x/0-mwa2gx2gog-weekly-report.md'],
- },
- ],
- },
- },
- ]);
- const [d] = await api.fetchDeliverables();
- expect(d.artifacts[0].name).toBe('weekly-report.md');
- });
- it('纯随机键(无前缀可剥)保持原样,不返回空名', async () => {
- stubFetch([
- {
- body: {
- code: 200,
- deliverables: [
- {
- objectId: '1',
- title: 'x',
- artifacts: ['https://s3.fmode.cn/deliverables/a/202611/x/1757631234567.md'],
- },
- ],
- },
- },
- ]);
- const [d] = await api.fetchDeliverables();
- expect(d.artifacts[0].name).toBe('1757631234567.md');
- });
- it('agentId 过滤参数被传入 params', async () => {
- stubFetch([{ body: { code: 200, deliverables: [] } }]);
- await api.fetchDeliverables({ agentId: 'a1', limit: 12 });
- const p = bodyOf()['params'] as Record<string, unknown>;
- expect(p['agentId']).toBe('a1');
- expect(p['limit']).toBe(12);
- expect(p['action']).toBe('list');
- });
- it('fetchDeliverablesByProject 传 action:byProject', async () => {
- stubFetch([{ body: { code: 200, groups: [] } }]);
- await api.fetchDeliverablesByProject(200);
- const p = bodyOf()['params'] as Record<string, unknown>;
- expect(p['action']).toBe('byProject');
- });
- });
- // ---------- 登录 ----------
- describe('登录', () => {
- it('sendMobileCode:code===1 才算成功(R4 口径)', async () => {
- stubFetch([{ body: { code: 1 } }]);
- await expectAsync(api.sendMobileCode('13800000000')).toBeResolved();
- });
- it('sendMobileCode:code!==1 → 抛错并带服务端文案', async () => {
- stubFetch([{ body: { code: 200, mess: '频率超限' } }]);
- await expectAsync(api.sendMobileCode('13800000000')).toBeRejectedWithError(/频率超限/);
- });
- it('loginWithMobile:code===200 && data.token 才算成功(R5 口径)', async () => {
- stubFetch([
- { body: { code: 200, data: { token: 'r:new' } } }, // 登录
- { body: { objectId: 'u1', nickname: '我' } }, // fetchMe
- { body: { results: [] } }, // fetchRoles
- ]);
- const s = await api.loginWithMobile('13800000000', '123456');
- expect(s.token).toBe('r:new');
- expect(s.me.id).toBe('u1');
- });
- it('loginWithMobile:缺 data.token → 抛错', async () => {
- stubFetch([{ body: { code: 200, data: {} } }]);
- await expectAsync(api.loginWithMobile('13800000000', '123456')).toBeRejected();
- });
- it('🔴 fetchMe 必须用 **GET**(契约曾误写 POST,实测 POST 会 404)', async () => {
- stubFetch([
- { body: { objectId: 'u1', nickname: '我', company: { objectId: 'C1' } } },
- { body: { results: [{ name: 'masters' }] } },
- ]);
- const me = await api.fetchMe('r:tok');
- expect(me.companyId).toBe('C1');
- expect(me.roles).toContain('masters');
- expect(calls[0].init.method).toBe('GET');
- expect(calls[0].url).toContain('/parse/users/me');
- const h = calls[0].init.headers as Record<string, string>;
- expect(h['X-Parse-Session-Token']).toBe('r:tok');
- });
- it('fetchRoles 失败时降级为空数组(不阻塞登录,判级以服务端为准)', async () => {
- stubFetch([
- { body: { objectId: 'u1', nickname: '我' } },
- { status: 500, body: {} },
- ]);
- const me = await api.fetchMe('r:tok');
- expect(me.id).toBe('u1');
- expect(me.roles).toEqual([]);
- });
- });
- });
- /**
- * 文件台账(fmode-file-hub,2026-11-10)
- *
- * 用户要求:文件与成果入库,右侧栏按会话直接加载,不依赖聊天历史。
- * 这些用例锁住「请求形状」和「错误处理」,避免悄悄退回静默失败。
- */
- describe('ParseApi · 文件台账', () => {
- let api: ParseApi;
- let calls: { url: string; init: RequestInit }[] = [];
- function stub(responses: { status?: number; body: unknown }[]): void {
- let i = 0;
- spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
- calls.push({ url: String(input), init: init ?? {} });
- const r = responses[Math.min(i++, responses.length - 1)];
- const status = r.status ?? 200;
- return Promise.resolve({
- ok: status >= 200 && status < 300,
- status,
- json: () => Promise.resolve(r.body),
- text: () => Promise.resolve(JSON.stringify(r.body)),
- } as unknown as Response);
- });
- }
- const bodyOf = (idx = 0): Record<string, unknown> =>
- JSON.parse(String(calls[idx].init.body)) as Record<string, unknown>;
- beforeEach(() => {
- calls = [];
- api = new ParseApi(() => 'r:session-token-abc');
- });
- it('registerFile 调用新函数并透传文件信息', async () => {
- stub([{ body: { code: 200, id: 'file-1' } }]);
- const r = await api.registerFile({
- threadRoot: '$approot_x:fmode.cn',
- agentId: 'agent-node-lj-test',
- name: 'a.md',
- url: 'https://s3.fmode.cn/k/a.md',
- key: 'k/a.md',
- mimeType: 'text/markdown',
- size: 12,
- uploadedBy: 'user',
- });
- expect(r.id).toBe('file-1');
- const b = bodyOf();
- expect(b['id']).toBe('THXAOGKiPK');
- const p = b['params'] as Record<string, unknown>;
- expect(p['action']).toBe('registerFile');
- expect(p['threadRoot']).toBe('$approot_x:fmode.cn');
- expect(p['uploadedBy']).toBe('user');
- });
- it('registerFile 返回 duplicated 时如实透出(幂等不是错误)', async () => {
- stub([{ body: { code: 200, id: 'file-1', duplicated: true } }]);
- const r = await api.registerFile({
- threadRoot: 't',
- name: 'a.md',
- url: 'https://s3.fmode.cn/k/a.md',
- uploadedBy: 'user',
- });
- expect(r.duplicated).toBeTrue();
- });
- it('registerFile 失败必须抛错,不得静默吞掉', async () => {
- stub([{ body: { code: 400, msg: 'threadRoot required' } }]);
- await expectAsync(
- api.registerFile({ threadRoot: '', name: 'a', url: 'https://s3.fmode.cn/a', uploadedBy: 'user' }),
- ).toBeRejectedWithError(/threadRoot required/);
- });
- it('fetchFilesBySession 带 threadRoot 查询并还原记录', async () => {
- stub([
- {
- body: {
- code: 200,
- files: [
- {
- id: 'f1',
- threadRoot: 't1',
- agentId: 'a',
- roomId: 'r',
- name: 'a.md',
- size: 12,
- mimeType: 'text/markdown',
- url: 'https://s3.fmode.cn/a.md',
- storageKey: 'k',
- uploadedBy: 'user',
- status: 'delivered',
- deliveredAt: 1700000000000,
- },
- ],
- },
- },
- ]);
- const files = await api.fetchFilesBySession('t1');
- expect(files.length).toBe(1);
- expect(files[0].name).toBe('a.md');
- expect(files[0].uploadedBy).toBe('user');
- const p = bodyOf()['params'] as Record<string, unknown>;
- expect(p['action']).toBe('listBySession');
- expect(p['threadRoot']).toBe('t1');
- });
- it('fetchFilesBySession 失败抛错(右栏据此显示错误态,不伪装成空)', async () => {
- stub([{ body: { code: 401, msg: 'invalid session' } }]);
- await expectAsync(api.fetchFilesBySession('t1')).toBeRejectedWithError(/invalid session/);
- });
- });
|