| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- 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,带 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<string, string>;
- 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<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('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([]);
- });
- 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([]);
- });
- });
- });
|