chat.service.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import { TestBed } from '@angular/core/testing';
  2. import { ChatService } from './chat.service';
  3. import { FmodeApi } from './api/fmode-api';
  4. import { LiveService } from './live.service';
  5. import { MatrixClientService, MxMessage } from './matrix-client.service';
  6. import { SessionService } from './session.service';
  7. import { UnreadService } from './unread.service';
  8. import { Agent, MatrixProvision } from './api/models';
  9. import { environment } from '../../environments/environment';
  10. /**
  11. * ChatService 单测 —— 会话状态机(SPEC FR-04)
  12. *
  13. * 覆盖:provision → 连接 → 房间 → 收发 → 分页 的编排与失败分环节报错。
  14. * 依赖(FmodeApi / LiveService / MatrixClientService)全部用桩替换,
  15. * **不触网、不加载 matrix-js-sdk**。
  16. *
  17. * 🔴 重点回归:
  18. * ① 切会话时 `seq` 防串扰(过期回包必须被丢弃)
  19. * ② `close()` **不断开**会话级连接(未读依赖 sync 常驻)
  20. * ③ 失败时**分环节**报错(FMODE 开通 / Matrix 登录 / 房间创建)
  21. * ④ `paginate()` 防重入(滚动事件高频触发)
  22. */
  23. describe('ChatService', () => {
  24. let svc: ChatService;
  25. /** 桩:FmodeApi */
  26. const apiStub = {
  27. provisionMatrix: jasmine.createSpy('provisionMatrix'),
  28. validateMatrix: jasmine.createSpy('validateMatrix'),
  29. fetchAgents: jasmine.createSpy('fetchAgents'),
  30. uploadFile: jasmine.createSpy('uploadFile'),
  31. requestUploadUrl: jasmine.createSpy('requestUploadUrl'),
  32. };
  33. /** 桩:LiveService */
  34. const liveStub = {
  35. ensureLoaded: jasmine.createSpy('ensureLoaded').and.returnValue(Promise.resolve()),
  36. agentById: jasmine.createSpy('agentById'),
  37. agents: jasmine.createSpy('agents').and.returnValue([] as Agent[]),
  38. /** 加载状态(chat.open 用它区分「加载中」与「确实不存在」) */
  39. state: jasmine.createSpy('state').and.returnValue('ready'),
  40. error: jasmine.createSpy('error').and.returnValue(null),
  41. };
  42. /** 桩:MatrixClientService */
  43. const mxStub = {
  44. connectSession: jasmine.createSpy('connectSession').and.returnValue(Promise.resolve()),
  45. disconnect: jasmine.createSpy('disconnect'),
  46. ensureRoom: jasmine.createSpy('ensureRoom'),
  47. syncState: jasmine.createSpy('syncState').and.returnValue('connected'),
  48. hasInitialSync: true,
  49. paginationToken: jasmine.createSpy('paginationToken').and.returnValue('tok-b'),
  50. timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]),
  51. onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined),
  52. onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined),
  53. onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined),
  54. sendText: jasmine.createSpy('sendText').and.returnValue(Promise.resolve({ event_id: '$e' })),
  55. setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
  56. paginateBack: jasmine.createSpy('paginateBack').and.returnValue(Promise.resolve(true)),
  57. };
  58. const PROV: MatrixProvision = {
  59. cached: true,
  60. bindingId: 'b1',
  61. status: 'active',
  62. userMxId: '@sr2wipsdyq:fmode.cn',
  63. agentMxId: '@zgdcmgnqcd:fmode.cn',
  64. homeserver: 'https://matrix.fmode.cn',
  65. accessToken: 'tok',
  66. deviceId: 'DEV1',
  67. roomId: '!room1:fmode.cn',
  68. };
  69. const AGENT: Agent = {
  70. id: 'agent-node-lj-test',
  71. name: 'lj-test',
  72. status: 'online' as Agent['status'],
  73. matrixReady: true,
  74. unread: 0,
  75. };
  76. beforeEach(() => {
  77. localStorage.clear();
  78. for (const k of Object.keys(apiStub)) {
  79. (apiStub as Record<string, jasmine.Spy>)[k].calls.reset();
  80. }
  81. liveStub.ensureLoaded.calls.reset();
  82. liveStub.agentById.calls.reset();
  83. for (const k of Object.keys(mxStub)) {
  84. const v = (mxStub as Record<string, unknown>)[k];
  85. if (v && typeof (v as jasmine.Spy).calls === 'object') (v as jasmine.Spy).calls.reset();
  86. }
  87. mxStub.connectSession.and.returnValue(Promise.resolve());
  88. mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
  89. mxStub.syncState.and.returnValue('connected');
  90. mxStub.paginationToken.and.returnValue('tok-b');
  91. mxStub.timelineToMessages.and.returnValue([]);
  92. mxStub.onMessage.and.returnValue(() => undefined);
  93. mxStub.onAnyMessage.and.returnValue(() => undefined);
  94. mxStub.onTyping.and.returnValue(() => undefined);
  95. mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$e' }));
  96. mxStub.paginateBack.and.returnValue(Promise.resolve(true));
  97. liveStub.agentById.and.returnValue(AGENT);
  98. apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
  99. TestBed.configureTestingModule({
  100. providers: [
  101. ChatService,
  102. { provide: FmodeApi, useValue: apiStub },
  103. { provide: LiveService, useValue: liveStub },
  104. { provide: MatrixClientService, useValue: mxStub },
  105. { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'r:t' } },
  106. { provide: UnreadService, useClass: UnreadService },
  107. ],
  108. });
  109. svc = TestBed.inject(ChatService);
  110. });
  111. afterEach(() => localStorage.clear());
  112. /** 测试统一走真实态(演示态由 MockApi 覆盖,另测) */
  113. function realMode(): void {
  114. (environment as { useMockData: boolean }).useMockData = false;
  115. }
  116. describe('open() 真实态编排', () => {
  117. it('provision → connectSession → ensureRoom 顺序执行', async () => {
  118. realMode();
  119. const order: string[] = [];
  120. apiStub.provisionMatrix.and.callFake(() => {
  121. order.push('provision');
  122. return Promise.resolve(PROV);
  123. });
  124. mxStub.connectSession.and.callFake(() => {
  125. order.push('connect');
  126. return Promise.resolve();
  127. });
  128. mxStub.ensureRoom.and.callFake(() => {
  129. order.push('room');
  130. return Promise.resolve('!room1:fmode.cn');
  131. });
  132. await svc.open('agent-node-lj-test');
  133. expect(order).toEqual(['provision', 'connect', 'room']);
  134. expect(svc.agentId()).toBe('agent-node-lj-test');
  135. expect(svc.connection()).toBe('connected');
  136. });
  137. it('空房间 → state=empty(不是 error)', async () => {
  138. realMode();
  139. mxStub.timelineToMessages.and.returnValue([]);
  140. await svc.open('agent-node-lj-test');
  141. expect(svc.state()).toBe('empty');
  142. });
  143. it('有消息 → state=ready 且按 sender 区分 me/agent', async () => {
  144. realMode();
  145. mxStub.timelineToMessages.and.returnValue([
  146. { roomId: 'r', eventId: '$1', sender: '@sr2wipsdyq:fmode.cn', body: '我发的', ts: 1, kind: 'text', live: false },
  147. { roomId: 'r', eventId: '$2', sender: '@zgdcmgnqcd:fmode.cn', body: '它回的', ts: 2, kind: 'text', live: false },
  148. ]);
  149. await svc.open('agent-node-lj-test');
  150. expect(svc.state()).toBe('ready');
  151. expect(svc.messages().map((m) => m.sender)).toEqual(['me', 'agent']);
  152. expect(svc.messages()[0].text).toBe('我发的');
  153. });
  154. it('数字生命不存在 → 报明确错误', async () => {
  155. realMode();
  156. liveStub.agentById.and.returnValue(undefined);
  157. await svc.open('nope');
  158. expect(svc.state()).toBe('error');
  159. expect(svc.error()).toContain('不存在');
  160. });
  161. it('🔴 数据仍在加载 → 不报「不存在」(刷新页面的真实场景)', async () => {
  162. realMode();
  163. liveStub.agentById.and.returnValue(undefined);
  164. liveStub.state.and.returnValue('loading');
  165. await svc.open('agent-node-lj-test');
  166. expect(svc.error()).not.toContain('不存在');
  167. expect(svc.error()).toContain('加载');
  168. liveStub.state.and.returnValue('ready'); // 复位
  169. });
  170. it('🔴 数据加载失败 → 报加载失败而非「不存在」', async () => {
  171. realMode();
  172. liveStub.agentById.and.returnValue(undefined);
  173. liveStub.state.and.returnValue('error');
  174. liveStub.error.and.returnValue('网络异常');
  175. await svc.open('agent-node-lj-test');
  176. expect(svc.error()).toContain('加载失败');
  177. expect(svc.error()).toContain('网络异常');
  178. liveStub.state.and.returnValue('ready');
  179. liveStub.error.and.returnValue(null);
  180. });
  181. });
  182. describe('失败分环节报错(FR-04:不得笼统)', () => {
  183. it('provision 失败 → 文案含「FMODE 开通失败」', async () => {
  184. realMode();
  185. apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('SessionToken 无效')));
  186. await svc.open('a');
  187. expect(svc.error()).toContain('FMODE 开通失败');
  188. expect(svc.error()).toContain('SessionToken 无效');
  189. });
  190. it('SDK 登录失败 → 文案含「Matrix 登录失败」', async () => {
  191. realMode();
  192. mxStub.connectSession.and.returnValue(Promise.reject(new Error('凭据不完整')));
  193. await svc.open('a');
  194. expect(svc.error()).toContain('Matrix 登录失败');
  195. });
  196. it('房间创建失败 → 文案含「房间创建失败」', async () => {
  197. realMode();
  198. mxStub.ensureRoom.and.returnValue(Promise.reject(new Error('403')));
  199. await svc.open('a');
  200. expect(svc.error()).toContain('房间创建失败');
  201. });
  202. it('失败时 connection=disconnected(不假装连着)', async () => {
  203. realMode();
  204. apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('x')));
  205. await svc.open('a');
  206. expect(svc.connection()).toBe('disconnected');
  207. });
  208. });
  209. describe('seq 防串扰(切会话丢弃过期回包)', () => {
  210. it('慢的旧请求返回时不覆盖新会话', async () => {
  211. realMode();
  212. let resolveSlow: (v: MatrixProvision) => void = () => undefined;
  213. apiStub.provisionMatrix.and.callFake((id: string) =>
  214. id === 'slow'
  215. ? new Promise<MatrixProvision>((res) => (resolveSlow = res))
  216. : Promise.resolve(PROV),
  217. );
  218. mxStub.ensureRoom.and.callFake(() => Promise.resolve('!room1:fmode.cn'));
  219. const slowOpen = svc.open('slow'); // 悬而未决
  220. await svc.open('fast'); // 后发先至,成为当前会话
  221. resolveSlow(PROV); // 旧请求此刻才返回
  222. await slowOpen;
  223. // 当前会话仍是 fast,未被 slow 的迟到回包覆盖
  224. expect(svc.agentId()).toBe('fast');
  225. });
  226. });
  227. describe('close() 必须**不断开**会话级连接', () => {
  228. it('close 后 sync 仍常驻(未读计数依赖)', async () => {
  229. realMode();
  230. await svc.open('agent-node-lj-test');
  231. svc.close();
  232. // 🔴 关键:close 不得调用 disconnect —— 否则离开会话就收不到消息
  233. expect(mxStub.disconnect).not.toHaveBeenCalled();
  234. expect(svc.agentId()).toBeNull();
  235. expect(svc.messages()).toEqual([]);
  236. expect(svc.connection()).toBe('disconnected'); // 仅 UI 状态复位
  237. });
  238. it('close 会解绑本房间的事件订阅(防串扰)', async () => {
  239. realMode();
  240. const unbindMsg = jasmine.createSpy('unbindMsg');
  241. const unbindTyping = jasmine.createSpy('unbindTyping');
  242. mxStub.onMessage.and.returnValue(unbindMsg);
  243. mxStub.onTyping.and.returnValue(unbindTyping);
  244. await svc.open('a');
  245. svc.close();
  246. expect(unbindMsg).toHaveBeenCalled();
  247. expect(unbindTyping).toHaveBeenCalled();
  248. });
  249. });
  250. describe('sendText(AC-04.2 / AC-04.8)', () => {
  251. beforeEach(async () => {
  252. realMode();
  253. await svc.open('agent-node-lj-test');
  254. });
  255. it('乐观插入 → 调用 SDK 发送 → 标记 sent', async () => {
  256. await svc.sendText(' 你好 ');
  257. const last = svc.messages().at(-1)!;
  258. expect(last.text).toBe('你好'); // 已 trim
  259. expect(last.state).toBe('sent');
  260. expect(mxStub.sendText).toHaveBeenCalledWith('!room1:fmode.cn', '你好');
  261. });
  262. it('发送后清空草稿', async () => {
  263. svc.setDraft('待发');
  264. await svc.sendText('待发');
  265. expect(svc.draft()).toBe('');
  266. });
  267. it('空文本不发送(也不插气泡)', async () => {
  268. const before = svc.messages().length;
  269. await svc.sendText(' ');
  270. expect(svc.messages().length).toBe(before);
  271. expect(mxStub.sendText).not.toHaveBeenCalled();
  272. });
  273. it('失败 → 留痕 failed 且带错误原因(不丢消息)', async () => {
  274. mxStub.sendText.and.returnValue(Promise.reject(new Error('网络错误')));
  275. await svc.sendText('会失败');
  276. const last = svc.messages().at(-1)!;
  277. expect(last.state).toBe('failed');
  278. expect(last.error).toContain('网络错误');
  279. });
  280. it('retry 把 failed 消息重发并复位为 sent', async () => {
  281. mxStub.sendText.and.returnValue(Promise.reject(new Error('临时故障')));
  282. await svc.sendText('重试我');
  283. const failed = svc.messages().at(-1)!;
  284. expect(failed.state).toBe('failed');
  285. mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$ok' }));
  286. await svc.retry(failed.id);
  287. const after = svc.messages().find((m) => m.id === failed.id)!;
  288. expect(after.state).toBe('sent');
  289. expect(after.error).toBeUndefined();
  290. });
  291. it('非 failed 状态的消息 retry 不做事', async () => {
  292. await svc.sendText('已成功的');
  293. const ok = svc.messages().at(-1)!;
  294. mxStub.sendText.calls.reset();
  295. await svc.retry(ok.id);
  296. expect(mxStub.sendText).not.toHaveBeenCalled();
  297. });
  298. });
  299. describe('paginate(AC-04.4 翻历史)', () => {
  300. beforeEach(async () => {
  301. realMode();
  302. await svc.open('agent-node-lj-test');
  303. });
  304. it('分页后条数增长 → hasMoreHistory=true', async () => {
  305. // 注:open() 已调用过一次 timelineToMessages(返回 []);
  306. // paginate() 只再调用一次,故此处用 returnValue(单值)而非 returnValues。
  307. mxStub.timelineToMessages.and.returnValue(
  308. Array.from({ length: 5 }, (_, i) => ({
  309. roomId: 'r',
  310. eventId: `$h${i}`,
  311. sender: '@zgdcmgnqcd:fmode.cn',
  312. body: `历史${i}`,
  313. ts: i,
  314. kind: 'text' as const,
  315. live: false,
  316. })),
  317. );
  318. const grew = await svc.paginate();
  319. expect(grew).toBeTrue();
  320. expect(svc.messages().length).toBe(5);
  321. expect(svc.hasMoreHistory()).toBeTrue();
  322. });
  323. it('条数未增长 → hasMoreHistory=false(到最早了,停止请求)', async () => {
  324. mxStub.timelineToMessages.and.returnValue([]);
  325. const grew = await svc.paginate();
  326. expect(grew).toBeFalse();
  327. expect(svc.hasMoreHistory()).toBeFalse();
  328. });
  329. it('🔴 防重入:并发调用只触发一次 SDK 分页', async () => {
  330. let resolveIt: (v: boolean) => void = () => undefined;
  331. mxStub.paginateBack.and.callFake(() => new Promise<boolean>((res) => (resolveIt = res)));
  332. const a = svc.paginate();
  333. const b = svc.paginate(); // 立刻重入
  334. resolveIt(true);
  335. await Promise.all([a, b]);
  336. expect(mxStub.paginateBack.calls.count()).toBe(1);
  337. });
  338. it('paginating 在完成后复位', async () => {
  339. await svc.paginate();
  340. expect(svc.paginating()).toBeFalse();
  341. });
  342. });
  343. describe('setDraft 触发 typing 通知', () => {
  344. it('有内容 → typing=true,清空 → typing=false', async () => {
  345. realMode();
  346. await svc.open('a');
  347. svc.setDraft('在输入');
  348. expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', true);
  349. svc.setDraft('');
  350. expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', false);
  351. });
  352. });
  353. describe('sendFiles(AC-04.3)', () => {
  354. it('上传成功后追加文件卡片消息', async () => {
  355. realMode();
  356. await svc.open('a');
  357. apiStub.uploadFile.and.returnValue(
  358. Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
  359. );
  360. const f = new File(['x'], 'doc.txt', { type: 'text/plain' });
  361. await svc.sendFiles([f]);
  362. const last = svc.messages().at(-1)!;
  363. expect(last.file?.name).toBe('doc.txt');
  364. expect(last.file?.url).toBe('https://s3.fmode.cn/k');
  365. expect(svc.uploading()).toBeFalse();
  366. });
  367. it('上传失败 → 记录错误且不上传中', async () => {
  368. realMode();
  369. await svc.open('a');
  370. apiStub.uploadFile.and.returnValue(Promise.reject(new Error('签名获取失败')));
  371. await svc.sendFiles([new File(['x'], 'a.txt', { type: 'text/plain' })]);
  372. expect(svc.error()).toContain('签名获取失败');
  373. expect(svc.uploading()).toBeFalse();
  374. });
  375. });
  376. describe('进入会话即已读(清未读计数)', () => {
  377. it('open 成功后该 agent 未读归零', async () => {
  378. realMode();
  379. const unread = TestBed.inject(UnreadService);
  380. // 先造出未读
  381. unread.onIncoming('agent-node-lj-test', 1000, true);
  382. unread.onIncoming('agent-node-lj-test', 2000, false);
  383. expect(unread.countOf('agent-node-lj-test')).toBe(1);
  384. await svc.open('agent-node-lj-test');
  385. expect(unread.countOf('agent-node-lj-test')).toBe(0);
  386. });
  387. });
  388. });