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 => JSON.parse(String(calls[idx].init.body)) as Record; beforeEach(() => { calls = []; api = new ParseApi(() => 'r:session-token-abc'); }); // ---------- 请求形状(契约 §1.1) ---------- describe('云函数调用协议(API-契约 §1.1)', () => { it('POST 到 /api/functions,带 appId 头与 {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; expect(headers['X-Parse-Application-Id']).toBe('ncloudmaster'); expect(headers['X-Parse-Session-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; 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('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)['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/ 兜底', 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 }[] = []; const origXhr = window.XMLHttpRequest; // 最小 XHR 桩:记录 open/setRequestHeader,send 后立刻 onload (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () { const rec = { method: '', url: '', headers: {} as Record }; 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([]); }); it('agentId 过滤参数被传入 params', async () => { stubFetch([{ body: { code: 200, deliverables: [] } }]); await api.fetchDeliverables({ agentId: 'a1', limit: 12 }); const p = bodyOf()['params'] as Record; 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; 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; 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([]); }); }); });