parse-api.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import { ParseApi } from './parse-api';
  2. /**
  3. * ParseApi 单测 —— 真实数据源映射层
  4. *
  5. * 依据《API-契约.md》与《数据访问层设计.md》§三(映射只在此处发生)。
  6. *
  7. * 🔴 **本文件是回归重点**:开发期间此层踩过最多坑 ——
  8. * ① 绑定表 `accessToken` 误填 Agent token(身份错)
  9. * ② `host`/`channels`/`balance` 是**字符串形态 JSON**(存量 parseJsonLoose 教训)
  10. * ③ 旧键 `remainRmb` 需归一为 `remainCny`
  11. * ④ `fetchTasks` 云函数未实现时**必须抛错**,不得静默返回空数组
  12. * ⑤ 错误文案取值顺序 `msg` → `mess` → `message`
  13. *
  14. * 手法:stub 全局 `fetch`,断言「请求形状」与「映射结果」两侧。
  15. */
  16. describe('ParseApi', () => {
  17. let api: ParseApi;
  18. let calls: { url: string; init: RequestInit }[] = [];
  19. /** 构造一个受控的 fetch 桩:按序返回预设响应 */
  20. function stubFetch(responses: { status?: number; body: unknown }[]): void {
  21. let i = 0;
  22. spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
  23. calls.push({ url: String(input), init: init ?? {} });
  24. const r = responses[Math.min(i++, responses.length - 1)];
  25. const status = r.status ?? 200;
  26. return Promise.resolve({
  27. ok: status >= 200 && status < 300,
  28. status,
  29. json: () => Promise.resolve(r.body),
  30. text: () => Promise.resolve(JSON.stringify(r.body)),
  31. } as unknown as Response);
  32. });
  33. }
  34. const bodyOf = (idx = 0): Record<string, unknown> =>
  35. JSON.parse(String(calls[idx].init.body)) as Record<string, unknown>;
  36. beforeEach(() => {
  37. calls = [];
  38. api = new ParseApi(() => 'r:session-token-abc');
  39. });
  40. // ---------- 请求形状(契约 §1.1) ----------
  41. describe('云函数调用协议(API-契约 §1.1)', () => {
  42. it('POST 到 /api/functions,带 appId 头与 {id, token, params} 体', async () => {
  43. stubFetch([{ body: { code: 200, agents: [] } }]);
  44. await api.fetchAgents();
  45. expect(calls[0].url).toContain('/api/functions');
  46. expect(calls[0].init.method).toBe('POST');
  47. const headers = calls[0].init.headers as Record<string, string>;
  48. expect(headers['X-Parse-Application-Id']).toBe('ncloudmaster');
  49. expect(headers['X-Parse-Session-Token']).toBe('r:session-token-abc');
  50. const body = bodyOf();
  51. expect(body['id']).toBeTruthy();
  52. expect(body['token']).toBe('r:session-token-abc');
  53. expect(body['params']).toBeTruthy();
  54. });
  55. it('令牌**每次调用实时读取**(不在构造时固化)', async () => {
  56. let tok = 'first';
  57. const dynamic = new ParseApi(() => tok);
  58. stubFetch([{ body: { code: 200, agents: [] } }, { body: { code: 200, agents: [] } }]);
  59. await dynamic.fetchAgents();
  60. tok = 'second';
  61. await dynamic.fetchAgents();
  62. expect(bodyOf(0)['token']).toBe('first');
  63. expect(bodyOf(1)['token']).toBe('second');
  64. });
  65. it('未登录(无令牌)时不带 Session-Token 头', async () => {
  66. const anon = new ParseApi(() => '');
  67. stubFetch([{ body: { code: 200, agents: [] } }]);
  68. await anon.fetchAgents();
  69. const headers = calls[0].init.headers as Record<string, string>;
  70. expect(headers['X-Parse-Session-Token']).toBeUndefined();
  71. });
  72. });
  73. // ---------- 错误文案(msg → mess → message) ----------
  74. describe('错误文案取值顺序', () => {
  75. it('HTTP 非 2xx → 抛含状态码的错误', async () => {
  76. stubFetch([{ status: 500, body: {} }]);
  77. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/HTTP 500/);
  78. });
  79. it('code!=200 且带 mess → 用 mess 文案', async () => {
  80. stubFetch([{ body: { code: 401, mess: 'SessionToken 无效或已过期' } }]);
  81. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/SessionToken 无效或已过期/);
  82. });
  83. it('msg 优先于 mess', async () => {
  84. stubFetch([{ body: { code: 403, msg: '优先', mess: '次选', message: '末选' } }]);
  85. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/优先/);
  86. });
  87. it('仅 message 存在时用 message', async () => {
  88. stubFetch([{ body: { code: 403, message: '末选文案' } }]);
  89. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/末选文案/);
  90. });
  91. });
  92. // ---------- Agent 映射(字符串 JSON 兼容) ----------
  93. describe('fetchAgents 映射', () => {
  94. const RAW = {
  95. objectId: 'qRovdDU7cY',
  96. agentId: 'agent-node-xinting',
  97. name: 'agent-node-xinting',
  98. botName: '芯葶001',
  99. botAvatar: 'https://s3.fmode.cn/a.png',
  100. gender: 'female',
  101. status: 'running',
  102. currentTask: '写文档',
  103. progress: 0.5,
  104. // 🔴 字符串形态 JSON(存量 parseJsonLoose 的教训)
  105. host: '{"cpuPercent":3.4,"memPercent":52.4,"diskPercent":35}',
  106. balance: '{"remainCny":17842.31}',
  107. channels: '{"matrix":true,"homeserver":"https://matrix.fmode.cn"}',
  108. user: { objectId: 'dhv9yTqK6K', nickname: '用户7536' },
  109. company: { objectId: 'rpnXZC1loU', __type: 'Pointer' },
  110. };
  111. it('字符串 JSON 字段被解析为对象', async () => {
  112. stubFetch([{ body: { code: 200, agents: [RAW] } }]);
  113. const [a] = await api.fetchAgents();
  114. expect(a.host).toEqual({ cpuPercent: 3.4, memPercent: 52.4, diskPercent: 35 });
  115. expect(a.balance?.remainCny).toBe(17842.31);
  116. expect(a.channels?.matrix).toBe(true);
  117. });
  118. it('已是对象的字段同样兼容(两种形态都能吃)', async () => {
  119. stubFetch([
  120. {
  121. body: {
  122. code: 200,
  123. agents: [{ ...RAW, host: { cpuPercent: 1 }, balance: { remainCny: 9 } }],
  124. },
  125. },
  126. ]);
  127. const [a] = await api.fetchAgents();
  128. expect(a.host?.cpuPercent).toBe(1);
  129. expect(a.balance?.remainCny).toBe(9);
  130. });
  131. it('非法 JSON 字符串不抛错,字段降级为 undefined', async () => {
  132. stubFetch([{ body: { code: 200, agents: [{ ...RAW, host: '{坏 JSON' }] } }]);
  133. const [a] = await api.fetchAgents();
  134. expect(a.host).toBeUndefined();
  135. expect(a.id).toBe('agent-node-xinting'); // 其余字段照常
  136. });
  137. it('🔴 旧键 remainRmb 归一为 remainCny(映射层职责)', async () => {
  138. stubFetch([{ body: { code: 200, agents: [{ ...RAW, balance: '{"remainRmb":86.4}' }] } }]);
  139. const [a] = await api.fetchAgents();
  140. expect(a.balance?.remainCny).toBe(86.4);
  141. });
  142. it('id 优先取 agentId,缺失时回落 objectId', async () => {
  143. stubFetch([
  144. { body: { code: 200, agents: [{ ...RAW, agentId: undefined, objectId: 'FALLBACK' }] } },
  145. ]);
  146. const [a] = await api.fetchAgents();
  147. expect(a.id).toBe('FALLBACK');
  148. });
  149. it('name 优先 botName,gender/status 有默认值', async () => {
  150. stubFetch([
  151. { body: { code: 200, agents: [{ objectId: 'x', botName: '显示名' }] } },
  152. ]);
  153. const [a] = await api.fetchAgents();
  154. expect(a.name).toBe('显示名');
  155. expect(a.gender).toBe('unknown');
  156. expect(a.status).toBe('offline');
  157. });
  158. it('matrixReady 采用账号级口径(matrixConfig.userId)', async () => {
  159. stubFetch([
  160. {
  161. body: {
  162. code: 200,
  163. agents: [{ ...RAW, channels: '{"matrix":true,"matrixConfig":{"userId":"@x:fmode.cn"}}' }],
  164. },
  165. },
  166. ]);
  167. const [a] = await api.fetchAgents();
  168. expect(a.matrixReady).toBe(true);
  169. });
  170. it('owner 从 user 指针还原', async () => {
  171. stubFetch([{ body: { code: 200, agents: [RAW] } }]);
  172. const [a] = await api.fetchAgents();
  173. expect(a.owner?.id).toBe('dhv9yTqK6K');
  174. expect(a.owner?.name).toBe('用户7536');
  175. });
  176. it('agents 非数组 → 抛错(不伪装成空列表)', async () => {
  177. stubFetch([{ body: { code: 200 } }]);
  178. await expectAsync(api.fetchAgents()).toBeRejected();
  179. });
  180. });
  181. describe('fetchAgentStatus', () => {
  182. it('code 404 → 返回 null(页面自行显示不存在)', async () => {
  183. stubFetch([{ body: { code: 404 } }]);
  184. expect(await api.fetchAgentStatus('nope')).toBeNull();
  185. });
  186. it('code 200 → 返回映射后的 Agent', async () => {
  187. stubFetch([{ body: { code: 200, agent: { agentId: 'a1', status: 'idle' } } }]);
  188. const a = await api.fetchAgentStatus('a1');
  189. expect(a?.id).toBe('a1');
  190. expect(a?.status).toBe('idle');
  191. });
  192. });
  193. // ---------- provision(契约 §2.3 校验规则) ----------
  194. describe('provisionMatrix / validateMatrix', () => {
  195. const OK = {
  196. code: 200,
  197. cached: true,
  198. bindingId: 'iyHgfhOAZ5',
  199. userMxId: '@sr2wipsdyq:fmode.cn',
  200. agentMxId: '@zgdcmgnqcd:fmode.cn',
  201. homeserver: 'https://matrix.fmode.cn',
  202. accessToken: 'tok',
  203. deviceId: 'DEV1',
  204. roomId: '!room:fmode.cn',
  205. status: 'active',
  206. };
  207. it('code 200 + token + userMxId → 通过', async () => {
  208. stubFetch([{ body: OK }]);
  209. const r = await api.provisionMatrix('agent-node-lj-test');
  210. expect(r.agentMxId).toBe('@zgdcmgnqcd:fmode.cn');
  211. expect(r.roomId).toBe('!room:fmode.cn');
  212. });
  213. it('🔴 缺 accessToken → 抛错(不得把失败当成功)', async () => {
  214. stubFetch([{ body: { ...OK, accessToken: '' } }]);
  215. await expectAsync(api.provisionMatrix('a')).toBeRejected();
  216. });
  217. it('🔴 缺 userMxId → 抛错', async () => {
  218. stubFetch([{ body: { ...OK, userMxId: null } }]);
  219. await expectAsync(api.provisionMatrix('a')).toBeRejected();
  220. });
  221. it('validateMatrix 传 validate:1', async () => {
  222. stubFetch([{ body: OK }]);
  223. await api.validateMatrix('agent-x');
  224. expect((bodyOf()['params'] as Record<string, unknown>)['validate']).toBe(1);
  225. });
  226. });
  227. // ---------- 任务看板:显式抛错(SPEC FR-07b 依赖声明) ----------
  228. describe('fetchTasks(云函数未实现)', () => {
  229. it('🔴 必须**抛错**,不得静默返回空数组(空数组会把「未接入」伪装成「没有任务」)', async () => {
  230. await expectAsync(api.fetchTasks('a')).toBeRejectedWithError(/未就绪|未实现/);
  231. // 且**不发起任何网络请求**
  232. expect(calls.length).toBe(0);
  233. });
  234. it('fetchTaskDetail 同样抛错且不发请求', async () => {
  235. await expectAsync(api.fetchTaskDetail('t1')).toBeRejectedWithError(/未就绪|未实现/);
  236. expect(calls.length).toBe(0);
  237. });
  238. });
  239. // ---------- 上传(OBS / S3 预签名,契约 §2.5) ----------
  240. describe('requestUploadUrl', () => {
  241. it('缺 uploadUrl 即抛错(不得静默降级为本地 blob)', async () => {
  242. stubFetch([{ body: { code: 200, key: 'k' } }]);
  243. await expectAsync(api.requestUploadUrl('a.txt', 'text/plain', 10)).toBeRejected();
  244. });
  245. it('publicUrl 缺失时按 s3.fmode.cn/<key> 兜底', async () => {
  246. stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/a.txt' } }]);
  247. const t = await api.requestUploadUrl('a.txt', 'text/plain', 10);
  248. expect(t.publicUrl).toBe('https://s3.fmode.cn/dir/a.txt');
  249. });
  250. it('原样透传服务端给的 headers', async () => {
  251. stubFetch([
  252. {
  253. body: {
  254. code: 200,
  255. uploadUrl: 'u',
  256. key: 'k',
  257. headers: { 'x-obs-acl': 'private' },
  258. },
  259. },
  260. ]);
  261. const t = await api.requestUploadUrl('a.txt', 'text/plain', 1);
  262. expect(t.headers?.['x-obs-acl']).toBe('private');
  263. });
  264. });
  265. describe('uploadFile(S3 直传)', () => {
  266. it('XHR PUT 到预签名地址,默认带 public-read 与 Content-Type', async () => {
  267. stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/x.png' } }]);
  268. const opened: { method: string; url: string; headers: Record<string, string> }[] = [];
  269. const origXhr = window.XMLHttpRequest;
  270. // 最小 XHR 桩:记录 open/setRequestHeader,send 后立刻 onload
  271. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
  272. const rec = { method: '', url: '', headers: {} as Record<string, string> };
  273. const self = {
  274. upload: { onprogress: null as unknown },
  275. status: 200,
  276. onload: null as unknown,
  277. onerror: null as unknown,
  278. open(m: string, u: string) {
  279. rec.method = m;
  280. rec.url = u;
  281. },
  282. setRequestHeader(k: string, v: string) {
  283. rec.headers[k] = v;
  284. },
  285. send() {
  286. opened.push(rec);
  287. (self.onload as () => void)?.();
  288. },
  289. };
  290. return self;
  291. };
  292. try {
  293. const file = new File(['hello'], 'x.png', { type: 'image/png' });
  294. const r = await api.uploadFile(file);
  295. expect(r.key).toBe('dir/x.png');
  296. expect(r.publicUrl).toBe('https://s3.fmode.cn/dir/x.png');
  297. expect(opened[0].method).toBe('PUT');
  298. expect(opened[0].url).toBe('https://s3.fmode.cn/put');
  299. expect(opened[0].headers['Content-Type']).toBe('image/png');
  300. expect(opened[0].headers['x-obs-acl']).toBe('public-read');
  301. } finally {
  302. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
  303. }
  304. });
  305. it('上传 HTTP 失败 → 抛错', async () => {
  306. stubFetch([{ body: { code: 200, uploadUrl: 'u', key: 'k' } }]);
  307. const origXhr = window.XMLHttpRequest;
  308. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
  309. const self = {
  310. upload: { onprogress: null as unknown },
  311. status: 403,
  312. onload: null as unknown,
  313. onerror: null as unknown,
  314. open() {},
  315. setRequestHeader() {},
  316. send() {
  317. (self.onload as () => void)?.();
  318. },
  319. };
  320. return self;
  321. };
  322. try {
  323. const file = new File(['x'], 'x.txt', { type: 'text/plain' });
  324. await expectAsync(api.uploadFile(file)).toBeRejectedWithError(/HTTP 403/);
  325. } finally {
  326. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
  327. }
  328. });
  329. });
  330. // ---------- 交付成果 ----------
  331. describe('fetchDeliverables', () => {
  332. it('status 含 fail/error → state=failed,否则 delivered', async () => {
  333. stubFetch([
  334. {
  335. body: {
  336. code: 200,
  337. deliverables: [
  338. { objectId: '1', title: 'A', status: 'delivered' },
  339. { objectId: '2', title: 'B', status: 'failed' },
  340. { objectId: '3', title: 'C', status: 'error_timeout' },
  341. ],
  342. },
  343. },
  344. ]);
  345. const list = await api.fetchDeliverables();
  346. expect(list.map((d) => d.state)).toEqual(['delivered', 'failed', 'failed']);
  347. });
  348. it('artifacts 与 tags 缺省为空数组(不产生 undefined 崩溃)', async () => {
  349. stubFetch([{ body: { code: 200, deliverables: [{ objectId: '1', title: 'A' }] } }]);
  350. const [d] = await api.fetchDeliverables();
  351. expect(d.artifacts).toEqual([]);
  352. expect(d.tags).toEqual([]);
  353. });
  354. it('agentId 过滤参数被传入 params', async () => {
  355. stubFetch([{ body: { code: 200, deliverables: [] } }]);
  356. await api.fetchDeliverables({ agentId: 'a1', limit: 12 });
  357. const p = bodyOf()['params'] as Record<string, unknown>;
  358. expect(p['agentId']).toBe('a1');
  359. expect(p['limit']).toBe(12);
  360. expect(p['action']).toBe('list');
  361. });
  362. it('fetchDeliverablesByProject 传 action:byProject', async () => {
  363. stubFetch([{ body: { code: 200, groups: [] } }]);
  364. await api.fetchDeliverablesByProject(200);
  365. const p = bodyOf()['params'] as Record<string, unknown>;
  366. expect(p['action']).toBe('byProject');
  367. });
  368. });
  369. // ---------- 登录 ----------
  370. describe('登录', () => {
  371. it('sendMobileCode:code===1 才算成功(R4 口径)', async () => {
  372. stubFetch([{ body: { code: 1 } }]);
  373. await expectAsync(api.sendMobileCode('13800000000')).toBeResolved();
  374. });
  375. it('sendMobileCode:code!==1 → 抛错并带服务端文案', async () => {
  376. stubFetch([{ body: { code: 200, mess: '频率超限' } }]);
  377. await expectAsync(api.sendMobileCode('13800000000')).toBeRejectedWithError(/频率超限/);
  378. });
  379. it('loginWithMobile:code===200 && data.token 才算成功(R5 口径)', async () => {
  380. stubFetch([
  381. { body: { code: 200, data: { token: 'r:new' } } }, // 登录
  382. { body: { objectId: 'u1', nickname: '我' } }, // fetchMe
  383. { body: { results: [] } }, // fetchRoles
  384. ]);
  385. const s = await api.loginWithMobile('13800000000', '123456');
  386. expect(s.token).toBe('r:new');
  387. expect(s.me.id).toBe('u1');
  388. });
  389. it('loginWithMobile:缺 data.token → 抛错', async () => {
  390. stubFetch([{ body: { code: 200, data: {} } }]);
  391. await expectAsync(api.loginWithMobile('13800000000', '123456')).toBeRejected();
  392. });
  393. it('🔴 fetchMe 必须用 **GET**(契约曾误写 POST,实测 POST 会 404)', async () => {
  394. stubFetch([
  395. { body: { objectId: 'u1', nickname: '我', company: { objectId: 'C1' } } },
  396. { body: { results: [{ name: 'masters' }] } },
  397. ]);
  398. const me = await api.fetchMe('r:tok');
  399. expect(me.companyId).toBe('C1');
  400. expect(me.roles).toContain('masters');
  401. expect(calls[0].init.method).toBe('GET');
  402. expect(calls[0].url).toContain('/parse/users/me');
  403. const h = calls[0].init.headers as Record<string, string>;
  404. expect(h['X-Parse-Session-Token']).toBe('r:tok');
  405. });
  406. it('fetchRoles 失败时降级为空数组(不阻塞登录,判级以服务端为准)', async () => {
  407. stubFetch([
  408. { body: { objectId: 'u1', nickname: '我' } },
  409. { status: 500, body: {} },
  410. ]);
  411. const me = await api.fetchMe('r:tok');
  412. expect(me.id).toBe('u1');
  413. expect(me.roles).toEqual([]);
  414. });
  415. });
  416. });