parse-api.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  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,带 {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. /*
  49. * 🔴 云函数调用**不能带任何 `X-Parse-*` 头**(2026-11-11 线上 CORS 实测)。
  50. *
  51. * 服务端网关多实例、预检响应不一致:固定白名单里没有任何 `x-parse-*`,
  52. * 命中那台就整条请求被 CORS 拦(实测 10 次里 6 次被拒)。
  53. * 第一轮只去掉 app-id,问题立刻在 session-token 上重现 —— 所以全部不带。
  54. */
  55. const parseHeaders = Object.keys(headers).filter((k) => k.toLowerCase().startsWith('x-parse-'));
  56. expect(parseHeaders).withContext('会触发线上 CORS 预检失败').toEqual([]);
  57. /* 鉴权改由 body 的 token 承担 */
  58. expect(bodyOf()['token']).toBe('r:session-token-abc');
  59. const body = bodyOf();
  60. expect(body['id']).toBeTruthy();
  61. expect(body['token']).toBe('r:session-token-abc');
  62. expect(body['params']).toBeTruthy();
  63. });
  64. it('令牌**每次调用实时读取**(不在构造时固化)', async () => {
  65. let tok = 'first';
  66. const dynamic = new ParseApi(() => tok);
  67. stubFetch([{ body: { code: 200, agents: [] } }, { body: { code: 200, agents: [] } }]);
  68. await dynamic.fetchAgents();
  69. tok = 'second';
  70. await dynamic.fetchAgents();
  71. expect(bodyOf(0)['token']).toBe('first');
  72. expect(bodyOf(1)['token']).toBe('second');
  73. });
  74. it('未登录(无令牌)时不带 Session-Token 头', async () => {
  75. const anon = new ParseApi(() => '');
  76. stubFetch([{ body: { code: 200, agents: [] } }]);
  77. await anon.fetchAgents();
  78. const headers = calls[0].init.headers as Record<string, string>;
  79. expect(headers['X-Parse-Session-Token']).toBeUndefined();
  80. });
  81. });
  82. // ---------- 错误文案(msg → mess → message) ----------
  83. describe('错误文案取值顺序', () => {
  84. it('HTTP 非 2xx → 抛含状态码的错误', async () => {
  85. stubFetch([{ status: 500, body: {} }]);
  86. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/HTTP 500/);
  87. });
  88. it('code!=200 且带 mess → 用 mess 文案', async () => {
  89. stubFetch([{ body: { code: 401, mess: 'SessionToken 无效或已过期' } }]);
  90. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/SessionToken 无效或已过期/);
  91. });
  92. it('msg 优先于 mess', async () => {
  93. stubFetch([{ body: { code: 403, msg: '优先', mess: '次选', message: '末选' } }]);
  94. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/优先/);
  95. });
  96. it('仅 message 存在时用 message', async () => {
  97. stubFetch([{ body: { code: 403, message: '末选文案' } }]);
  98. await expectAsync(api.fetchAgents()).toBeRejectedWithError(/末选文案/);
  99. });
  100. });
  101. // ---------- Agent 映射(字符串 JSON 兼容) ----------
  102. describe('fetchAgents 映射', () => {
  103. const RAW = {
  104. objectId: 'qRovdDU7cY',
  105. agentId: 'agent-node-xinting',
  106. name: 'agent-node-xinting',
  107. botName: '芯葶001',
  108. botAvatar: 'https://s3.fmode.cn/a.png',
  109. gender: 'female',
  110. status: 'running',
  111. currentTask: '写文档',
  112. progress: 0.5,
  113. // 🔴 字符串形态 JSON(存量 parseJsonLoose 的教训)
  114. host: '{"cpuPercent":3.4,"memPercent":52.4,"diskPercent":35}',
  115. balance: '{"remainCny":17842.31}',
  116. channels: '{"matrix":true,"homeserver":"https://matrix.fmode.cn"}',
  117. user: { objectId: 'dhv9yTqK6K', nickname: '用户7536' },
  118. company: { objectId: 'rpnXZC1loU', __type: 'Pointer' },
  119. };
  120. it('字符串 JSON 字段被解析为对象', async () => {
  121. stubFetch([{ body: { code: 200, agents: [RAW] } }]);
  122. const [a] = await api.fetchAgents();
  123. expect(a.host).toEqual({ cpuPercent: 3.4, memPercent: 52.4, diskPercent: 35 });
  124. expect(a.balance?.remainCny).toBe(17842.31);
  125. expect(a.channels?.matrix).toBe(true);
  126. });
  127. it('已是对象的字段同样兼容(两种形态都能吃)', async () => {
  128. stubFetch([
  129. {
  130. body: {
  131. code: 200,
  132. agents: [{ ...RAW, host: { cpuPercent: 1 }, balance: { remainCny: 9 } }],
  133. },
  134. },
  135. ]);
  136. const [a] = await api.fetchAgents();
  137. expect(a.host?.cpuPercent).toBe(1);
  138. expect(a.balance?.remainCny).toBe(9);
  139. });
  140. it('非法 JSON 字符串不抛错,字段降级为 undefined', async () => {
  141. stubFetch([{ body: { code: 200, agents: [{ ...RAW, host: '{坏 JSON' }] } }]);
  142. const [a] = await api.fetchAgents();
  143. expect(a.host).toBeUndefined();
  144. expect(a.id).toBe('agent-node-xinting'); // 其余字段照常
  145. });
  146. it('🔴 旧键 remainRmb 归一为 remainCny(映射层职责)', async () => {
  147. stubFetch([{ body: { code: 200, agents: [{ ...RAW, balance: '{"remainRmb":86.4}' }] } }]);
  148. const [a] = await api.fetchAgents();
  149. expect(a.balance?.remainCny).toBe(86.4);
  150. });
  151. it('owner_token 余额标记为共用账号,并保留 usedCny(2026-11-11)', async () => {
  152. stubFetch([
  153. {
  154. body: {
  155. code: 200,
  156. agents: [
  157. {
  158. ...RAW,
  159. balance: '{"remainCny":948.09,"usedCny":257,"source":"owner_token"}',
  160. },
  161. ],
  162. },
  163. },
  164. ]);
  165. const [a] = await api.fetchAgents();
  166. expect(a.balance?.remainCny).toBe(948.09);
  167. expect(a.balance?.usedCny).toBe(257);
  168. expect(a.balance?.sharedAccount).toBeTrue();
  169. });
  170. it('容器自报余额(非 owner_token)不标共用', async () => {
  171. stubFetch([
  172. {
  173. body: {
  174. code: 200,
  175. agents: [{ ...RAW, balance: '{"remainCny":12,"source":"session"}' }],
  176. },
  177. },
  178. ]);
  179. const [a] = await api.fetchAgents();
  180. expect(a.balance?.sharedAccount).toBeUndefined();
  181. });
  182. it('id 优先取 agentId,缺失时回落 objectId', async () => {
  183. stubFetch([
  184. { body: { code: 200, agents: [{ ...RAW, agentId: undefined, objectId: 'FALLBACK' }] } },
  185. ]);
  186. const [a] = await api.fetchAgents();
  187. expect(a.id).toBe('FALLBACK');
  188. });
  189. it('name 优先 botName,gender/status 有默认值', async () => {
  190. stubFetch([
  191. { body: { code: 200, agents: [{ objectId: 'x', botName: '显示名' }] } },
  192. ]);
  193. const [a] = await api.fetchAgents();
  194. expect(a.name).toBe('显示名');
  195. expect(a.gender).toBe('unknown');
  196. expect(a.status).toBe('offline');
  197. });
  198. it('matrixReady 采用账号级口径(matrixConfig.userId)', async () => {
  199. stubFetch([
  200. {
  201. body: {
  202. code: 200,
  203. agents: [{ ...RAW, channels: '{"matrix":true,"matrixConfig":{"userId":"@x:fmode.cn"}}' }],
  204. },
  205. },
  206. ]);
  207. const [a] = await api.fetchAgents();
  208. expect(a.matrixReady).toBe(true);
  209. });
  210. it('owner 从 user 指针还原', async () => {
  211. stubFetch([{ body: { code: 200, agents: [RAW] } }]);
  212. const [a] = await api.fetchAgents();
  213. expect(a.owner?.id).toBe('dhv9yTqK6K');
  214. expect(a.owner?.name).toBe('用户7536');
  215. });
  216. it('agents 非数组 → 抛错(不伪装成空列表)', async () => {
  217. stubFetch([{ body: { code: 200 } }]);
  218. await expectAsync(api.fetchAgents()).toBeRejected();
  219. });
  220. });
  221. describe('fetchAgentStatus', () => {
  222. it('code 404 → 返回 null(页面自行显示不存在)', async () => {
  223. stubFetch([{ body: { code: 404 } }]);
  224. expect(await api.fetchAgentStatus('nope')).toBeNull();
  225. });
  226. it('code 200 → 返回映射后的 Agent', async () => {
  227. stubFetch([{ body: { code: 200, agent: { agentId: 'a1', status: 'idle' } } }]);
  228. const a = await api.fetchAgentStatus('a1');
  229. expect(a?.id).toBe('a1');
  230. expect(a?.status).toBe('idle');
  231. });
  232. });
  233. // ---------- provision(契约 §2.3 校验规则) ----------
  234. describe('provisionMatrix / validateMatrix', () => {
  235. const OK = {
  236. code: 200,
  237. cached: true,
  238. bindingId: 'iyHgfhOAZ5',
  239. userMxId: '@sr2wipsdyq:fmode.cn',
  240. agentMxId: '@zgdcmgnqcd:fmode.cn',
  241. homeserver: 'https://matrix.fmode.cn',
  242. accessToken: 'tok',
  243. deviceId: 'DEV1',
  244. roomId: '!room:fmode.cn',
  245. status: 'active',
  246. };
  247. it('code 200 + token + userMxId → 通过', async () => {
  248. stubFetch([{ body: OK }]);
  249. const r = await api.provisionMatrix('agent-node-lj-test');
  250. expect(r.agentMxId).toBe('@zgdcmgnqcd:fmode.cn');
  251. expect(r.roomId).toBe('!room:fmode.cn');
  252. });
  253. it('🔴 缺 accessToken → 抛错(不得把失败当成功)', async () => {
  254. stubFetch([{ body: { ...OK, accessToken: '' } }]);
  255. await expectAsync(api.provisionMatrix('a')).toBeRejected();
  256. });
  257. it('🔴 缺 userMxId → 抛错', async () => {
  258. stubFetch([{ body: { ...OK, userMxId: null } }]);
  259. await expectAsync(api.provisionMatrix('a')).toBeRejected();
  260. });
  261. it('validateMatrix 传 validate:1', async () => {
  262. stubFetch([{ body: OK }]);
  263. await api.validateMatrix('agent-x');
  264. expect((bodyOf()['params'] as Record<string, unknown>)['validate']).toBe(1);
  265. });
  266. });
  267. // ---------- 任务看板:显式抛错(SPEC FR-07b 依赖声明) ----------
  268. describe('fetchTasks(云函数未实现)', () => {
  269. it('🔴 必须**抛错**,不得静默返回空数组(空数组会把「未接入」伪装成「没有任务」)', async () => {
  270. await expectAsync(api.fetchTasks('a')).toBeRejectedWithError(/未就绪|未实现/);
  271. // 且**不发起任何网络请求**
  272. expect(calls.length).toBe(0);
  273. });
  274. it('fetchTaskDetail 同样抛错且不发请求', async () => {
  275. await expectAsync(api.fetchTaskDetail('t1')).toBeRejectedWithError(/未就绪|未实现/);
  276. expect(calls.length).toBe(0);
  277. });
  278. });
  279. // ---------- 上传(OBS / S3 预签名,契约 §2.5) ----------
  280. describe('requestUploadUrl', () => {
  281. it('缺 uploadUrl 即抛错(不得静默降级为本地 blob)', async () => {
  282. stubFetch([{ body: { code: 200, key: 'k' } }]);
  283. await expectAsync(api.requestUploadUrl('a.txt', 'text/plain', 10)).toBeRejected();
  284. });
  285. it('publicUrl 缺失时按 s3.fmode.cn/<key> 兜底', async () => {
  286. stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/a.txt' } }]);
  287. const t = await api.requestUploadUrl('a.txt', 'text/plain', 10);
  288. expect(t.publicUrl).toBe('https://s3.fmode.cn/dir/a.txt');
  289. });
  290. it('原样透传服务端给的 headers', async () => {
  291. stubFetch([
  292. {
  293. body: {
  294. code: 200,
  295. uploadUrl: 'u',
  296. key: 'k',
  297. headers: { 'x-obs-acl': 'private' },
  298. },
  299. },
  300. ]);
  301. const t = await api.requestUploadUrl('a.txt', 'text/plain', 1);
  302. expect(t.headers?.['x-obs-acl']).toBe('private');
  303. });
  304. });
  305. describe('uploadFile(S3 直传)', () => {
  306. it('XHR PUT 到预签名地址,默认带 public-read 与 Content-Type', async () => {
  307. stubFetch([{ body: { code: 200, uploadUrl: 'https://s3.fmode.cn/put', key: 'dir/x.png' } }]);
  308. const opened: { method: string; url: string; headers: Record<string, string> }[] = [];
  309. const origXhr = window.XMLHttpRequest;
  310. // 最小 XHR 桩:记录 open/setRequestHeader,send 后立刻 onload
  311. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
  312. const rec = { method: '', url: '', headers: {} as Record<string, string> };
  313. const self = {
  314. upload: { onprogress: null as unknown },
  315. status: 200,
  316. onload: null as unknown,
  317. onerror: null as unknown,
  318. open(m: string, u: string) {
  319. rec.method = m;
  320. rec.url = u;
  321. },
  322. setRequestHeader(k: string, v: string) {
  323. rec.headers[k] = v;
  324. },
  325. send() {
  326. opened.push(rec);
  327. (self.onload as () => void)?.();
  328. },
  329. };
  330. return self;
  331. };
  332. try {
  333. const file = new File(['hello'], 'x.png', { type: 'image/png' });
  334. const r = await api.uploadFile(file);
  335. expect(r.key).toBe('dir/x.png');
  336. expect(r.publicUrl).toBe('https://s3.fmode.cn/dir/x.png');
  337. expect(opened[0].method).toBe('PUT');
  338. expect(opened[0].url).toBe('https://s3.fmode.cn/put');
  339. expect(opened[0].headers['Content-Type']).toBe('image/png');
  340. expect(opened[0].headers['x-obs-acl']).toBe('public-read');
  341. } finally {
  342. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
  343. }
  344. });
  345. it('上传 HTTP 失败 → 抛错', async () => {
  346. stubFetch([{ body: { code: 200, uploadUrl: 'u', key: 'k' } }]);
  347. const origXhr = window.XMLHttpRequest;
  348. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = function () {
  349. const self = {
  350. upload: { onprogress: null as unknown },
  351. status: 403,
  352. onload: null as unknown,
  353. onerror: null as unknown,
  354. open() {},
  355. setRequestHeader() {},
  356. send() {
  357. (self.onload as () => void)?.();
  358. },
  359. };
  360. return self;
  361. };
  362. try {
  363. const file = new File(['x'], 'x.txt', { type: 'text/plain' });
  364. await expectAsync(api.uploadFile(file)).toBeRejectedWithError(/HTTP 403/);
  365. } finally {
  366. (window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = origXhr;
  367. }
  368. });
  369. });
  370. // ---------- 交付成果 ----------
  371. describe('fetchDeliverables', () => {
  372. it('status 含 fail/error → state=failed,否则 delivered', async () => {
  373. stubFetch([
  374. {
  375. body: {
  376. code: 200,
  377. deliverables: [
  378. { objectId: '1', title: 'A', status: 'delivered' },
  379. { objectId: '2', title: 'B', status: 'failed' },
  380. { objectId: '3', title: 'C', status: 'error_timeout' },
  381. ],
  382. },
  383. },
  384. ]);
  385. const list = await api.fetchDeliverables();
  386. expect(list.map((d) => d.state)).toEqual(['delivered', 'failed', 'failed']);
  387. });
  388. it('artifacts 与 tags 缺省为空数组(不产生 undefined 崩溃)', async () => {
  389. stubFetch([{ body: { code: 200, deliverables: [{ objectId: '1', title: 'A' }] } }]);
  390. const [d] = await api.fetchDeliverables();
  391. expect(d.artifacts).toEqual([]);
  392. expect(d.tags).toEqual([]);
  393. });
  394. /*
  395. * 显示名与存储键分离(2026-11-10 用户裁决)。
  396. *
  397. * 中文文件名进不了签名 URL,所以存储键由服务端生成(含随机段)。
  398. * 展示名必须来自 `name` 字段,而不是从 url 末段硬取。
  399. */
  400. it('🔴 有 name 字段时,展示名用 name 而不是存储键末段', async () => {
  401. stubFetch([
  402. {
  403. body: {
  404. code: 200,
  405. deliverables: [
  406. {
  407. objectId: '1',
  408. title: '周报',
  409. artifacts: [
  410. {
  411. name: '新同事入职清单.md',
  412. url: 'https://s3.fmode.cn/deliverables/a/202611/x/0-mwa2gx2gog-abc123.md',
  413. type: 'md',
  414. },
  415. ],
  416. },
  417. ],
  418. },
  419. },
  420. ]);
  421. const [d] = await api.fetchDeliverables();
  422. expect(d.artifacts[0].name).toBe('新同事入职清单.md');
  423. });
  424. it('没有 name 时从存储键剥掉内部前缀', async () => {
  425. stubFetch([
  426. {
  427. body: {
  428. code: 200,
  429. deliverables: [
  430. {
  431. objectId: '1',
  432. title: '周报',
  433. artifacts: ['https://s3.fmode.cn/deliverables/a/202611/x/0-mwa2gx2gog-weekly-report.md'],
  434. },
  435. ],
  436. },
  437. },
  438. ]);
  439. const [d] = await api.fetchDeliverables();
  440. expect(d.artifacts[0].name).toBe('weekly-report.md');
  441. });
  442. it('纯随机键(无前缀可剥)保持原样,不返回空名', async () => {
  443. stubFetch([
  444. {
  445. body: {
  446. code: 200,
  447. deliverables: [
  448. {
  449. objectId: '1',
  450. title: 'x',
  451. artifacts: ['https://s3.fmode.cn/deliverables/a/202611/x/1757631234567.md'],
  452. },
  453. ],
  454. },
  455. },
  456. ]);
  457. const [d] = await api.fetchDeliverables();
  458. expect(d.artifacts[0].name).toBe('1757631234567.md');
  459. });
  460. it('agentId 过滤参数被传入 params', async () => {
  461. stubFetch([{ body: { code: 200, deliverables: [] } }]);
  462. await api.fetchDeliverables({ agentId: 'a1', limit: 12 });
  463. const p = bodyOf()['params'] as Record<string, unknown>;
  464. expect(p['agentId']).toBe('a1');
  465. expect(p['limit']).toBe(12);
  466. expect(p['action']).toBe('list');
  467. });
  468. it('fetchDeliverablesByProject 传 action:byProject', async () => {
  469. stubFetch([{ body: { code: 200, groups: [] } }]);
  470. await api.fetchDeliverablesByProject(200);
  471. const p = bodyOf()['params'] as Record<string, unknown>;
  472. expect(p['action']).toBe('byProject');
  473. });
  474. });
  475. // ---------- 登录 ----------
  476. describe('登录', () => {
  477. it('sendMobileCode:code===1 才算成功(R4 口径)', async () => {
  478. stubFetch([{ body: { code: 1 } }]);
  479. await expectAsync(api.sendMobileCode('13800000000')).toBeResolved();
  480. });
  481. it('sendMobileCode:code!==1 → 抛错并带服务端文案', async () => {
  482. stubFetch([{ body: { code: 200, mess: '频率超限' } }]);
  483. await expectAsync(api.sendMobileCode('13800000000')).toBeRejectedWithError(/频率超限/);
  484. });
  485. it('loginWithMobile:code===200 && data.token 才算成功(R5 口径)', async () => {
  486. stubFetch([
  487. { body: { code: 200, data: { token: 'r:new' } } }, // 登录
  488. { body: { objectId: 'u1', nickname: '我' } }, // fetchMe
  489. { body: { results: [] } }, // fetchRoles
  490. ]);
  491. const s = await api.loginWithMobile('13800000000', '123456');
  492. expect(s.token).toBe('r:new');
  493. expect(s.me.id).toBe('u1');
  494. });
  495. it('loginWithMobile:缺 data.token → 抛错', async () => {
  496. stubFetch([{ body: { code: 200, data: {} } }]);
  497. await expectAsync(api.loginWithMobile('13800000000', '123456')).toBeRejected();
  498. });
  499. it('🔴 fetchMe 必须用 **GET**(契约曾误写 POST,实测 POST 会 404)', async () => {
  500. stubFetch([
  501. { body: { objectId: 'u1', nickname: '我', company: { objectId: 'C1' } } },
  502. { body: { results: [{ name: 'masters' }] } },
  503. ]);
  504. const me = await api.fetchMe('r:tok');
  505. expect(me.companyId).toBe('C1');
  506. expect(me.roles).toContain('masters');
  507. expect(calls[0].init.method).toBe('GET');
  508. expect(calls[0].url).toContain('/parse/users/me');
  509. const h = calls[0].init.headers as Record<string, string>;
  510. expect(h['X-Parse-Session-Token']).toBe('r:tok');
  511. });
  512. it('fetchRoles 失败时降级为空数组(不阻塞登录,判级以服务端为准)', async () => {
  513. stubFetch([
  514. { body: { objectId: 'u1', nickname: '我' } },
  515. { status: 500, body: {} },
  516. ]);
  517. const me = await api.fetchMe('r:tok');
  518. expect(me.id).toBe('u1');
  519. expect(me.roles).toEqual([]);
  520. });
  521. });
  522. });
  523. /**
  524. * 文件台账(fmode-file-hub,2026-11-10)
  525. *
  526. * 用户要求:文件与成果入库,右侧栏按会话直接加载,不依赖聊天历史。
  527. * 这些用例锁住「请求形状」和「错误处理」,避免悄悄退回静默失败。
  528. */
  529. describe('ParseApi · 文件台账', () => {
  530. let api: ParseApi;
  531. let calls: { url: string; init: RequestInit }[] = [];
  532. function stub(responses: { status?: number; body: unknown }[]): void {
  533. let i = 0;
  534. spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
  535. calls.push({ url: String(input), init: init ?? {} });
  536. const r = responses[Math.min(i++, responses.length - 1)];
  537. const status = r.status ?? 200;
  538. return Promise.resolve({
  539. ok: status >= 200 && status < 300,
  540. status,
  541. json: () => Promise.resolve(r.body),
  542. text: () => Promise.resolve(JSON.stringify(r.body)),
  543. } as unknown as Response);
  544. });
  545. }
  546. const bodyOf = (idx = 0): Record<string, unknown> =>
  547. JSON.parse(String(calls[idx].init.body)) as Record<string, unknown>;
  548. beforeEach(() => {
  549. calls = [];
  550. api = new ParseApi(() => 'r:session-token-abc');
  551. });
  552. it('registerFile 调用新函数并透传文件信息', async () => {
  553. stub([{ body: { code: 200, id: 'file-1' } }]);
  554. const r = await api.registerFile({
  555. threadRoot: '$approot_x:fmode.cn',
  556. agentId: 'agent-node-lj-test',
  557. name: 'a.md',
  558. url: 'https://s3.fmode.cn/k/a.md',
  559. key: 'k/a.md',
  560. mimeType: 'text/markdown',
  561. size: 12,
  562. uploadedBy: 'user',
  563. });
  564. expect(r.id).toBe('file-1');
  565. const b = bodyOf();
  566. expect(b['id']).toBe('THXAOGKiPK');
  567. const p = b['params'] as Record<string, unknown>;
  568. expect(p['action']).toBe('registerFile');
  569. expect(p['threadRoot']).toBe('$approot_x:fmode.cn');
  570. expect(p['uploadedBy']).toBe('user');
  571. });
  572. it('registerFile 返回 duplicated 时如实透出(幂等不是错误)', async () => {
  573. stub([{ body: { code: 200, id: 'file-1', duplicated: true } }]);
  574. const r = await api.registerFile({
  575. threadRoot: 't',
  576. name: 'a.md',
  577. url: 'https://s3.fmode.cn/k/a.md',
  578. uploadedBy: 'user',
  579. });
  580. expect(r.duplicated).toBeTrue();
  581. });
  582. it('registerFile 失败必须抛错,不得静默吞掉', async () => {
  583. stub([{ body: { code: 400, msg: 'threadRoot required' } }]);
  584. await expectAsync(
  585. api.registerFile({ threadRoot: '', name: 'a', url: 'https://s3.fmode.cn/a', uploadedBy: 'user' }),
  586. ).toBeRejectedWithError(/threadRoot required/);
  587. });
  588. it('fetchFilesBySession 带 threadRoot 查询并还原记录', async () => {
  589. stub([
  590. {
  591. body: {
  592. code: 200,
  593. files: [
  594. {
  595. id: 'f1',
  596. threadRoot: 't1',
  597. agentId: 'a',
  598. roomId: 'r',
  599. name: 'a.md',
  600. size: 12,
  601. mimeType: 'text/markdown',
  602. url: 'https://s3.fmode.cn/a.md',
  603. storageKey: 'k',
  604. uploadedBy: 'user',
  605. status: 'delivered',
  606. deliveredAt: 1700000000000,
  607. },
  608. ],
  609. },
  610. },
  611. ]);
  612. const files = await api.fetchFilesBySession('t1');
  613. expect(files.length).toBe(1);
  614. expect(files[0].name).toBe('a.md');
  615. expect(files[0].uploadedBy).toBe('user');
  616. const p = bodyOf()['params'] as Record<string, unknown>;
  617. expect(p['action']).toBe('listBySession');
  618. expect(p['threadRoot']).toBe('t1');
  619. });
  620. it('fetchFilesBySession 失败抛错(右栏据此显示错误态,不伪装成空)', async () => {
  621. stub([{ body: { code: 401, msg: 'invalid session' } }]);
  622. await expectAsync(api.fetchFilesBySession('t1')).toBeRejectedWithError(/invalid session/);
  623. });
  624. });