chat.service.spec.ts 16 KB

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