api.service.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { provideHttpClient } from '@angular/common/http';
  2. import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
  3. import { fakeAsync, TestBed, tick } from '@angular/core/testing';
  4. import { Router } from '@angular/router';
  5. import { API_CONFIG } from './app.constants';
  6. import { ApiError, ApiService } from './api.service';
  7. import { SessionService } from './session.service';
  8. describe('ApiService', () => {
  9. let api: ApiService;
  10. let http: HttpTestingController;
  11. let sessionToken = '';
  12. let sessionLogout: jasmine.Spy;
  13. let routerNavigate: jasmine.Spy;
  14. beforeEach(() => {
  15. sessionToken = '';
  16. sessionLogout = jasmine.createSpy('logout');
  17. routerNavigate = jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true));
  18. TestBed.configureTestingModule({ providers: [
  19. provideHttpClient(),
  20. provideHttpClientTesting(),
  21. { provide: SessionService, useValue: { token: () => sessionToken, user: () => ({ objectId: 'member-42', userId: 42 }), logout: sessionLogout } },
  22. { provide: Router, useValue: { url: '/pages/his/his?date=2026-09-02', navigate: routerNavigate } },
  23. ] });
  24. api = TestBed.inject(ApiService);
  25. http = TestBed.inject(HttpTestingController);
  26. });
  27. afterEach(() => http.verify());
  28. it('routes migrated actions through the cloud gateway and parses JSON result strings', () => {
  29. let actual: unknown;
  30. api.get('node_list', { cpage: 1 }).subscribe((response) => actual = response.result);
  31. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  32. expect(request.request.method).toBe('POST');
  33. expect(request.request.headers.get('X-Parse-Application-Id')).toBe(API_CONFIG.parseAppId);
  34. expect(request.request.body).toEqual({ params: { action: 'node_list', cpage: 1 } });
  35. request.flush({ retcode: 0, result: '[{"ID":1}]' });
  36. expect(actual).toEqual([{ ID: 1 }]);
  37. });
  38. it('treats lesson courses as a read and does not trigger appointment refresh loops', () => {
  39. sessionToken = 'r:session-token';
  40. let changes = 0;
  41. api.dataChanges$.subscribe(() => changes++);
  42. api.get('app_lesson_courses', { orderId: 901 }).subscribe();
  43. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  44. expect(request.request.body.params.action).toBe('app_lesson_courses');
  45. request.flush({ retcode: 0, result: { items: [] } });
  46. expect(changes).toBe(0);
  47. });
  48. it('coalesces concurrent overview reads but fetches fresh data after completion', () => {
  49. sessionToken = 'r:session-token';
  50. const first: unknown[] = [];
  51. api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
  52. api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
  53. const shared = http.expectOne(API_CONFIG.cloudFunctionUrl);
  54. expect(shared.request.body).toEqual({ token: 'r:session-token', params: { action: 'app_account_overview', uid: 42 } });
  55. shared.flush({ retcode: 0, result: { profile: { userId: 42 } } });
  56. expect(first.length).toBe(2);
  57. api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
  58. const next = http.expectOne(API_CONFIG.cloudFunctionUrl);
  59. next.flush({ retcode: 0, result: { profile: { userId: 42, version: 2 } } });
  60. expect(first.length).toBe(3);
  61. api.post('app_account_overview', { uid: 42, refresh: true }).subscribe();
  62. const refreshed = http.expectOne(API_CONFIG.cloudFunctionUrl);
  63. expect(refreshed.request.body).toEqual({ token: 'r:session-token', params: { action: 'app_account_overview', uid: 42, refresh: true } });
  64. refreshed.flush({ retcode: 0, result: { profile: { userId: 42 } } });
  65. });
  66. it('routes not-yet-implemented actions through the cloud gateway for an explicit migration error', () => {
  67. api.get('order_list', { cpage: 2 }).subscribe();
  68. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  69. expect(request.request.method).toBe('POST');
  70. expect(request.request.body).toEqual({ params: { action: 'order_list', cpage: 2 } });
  71. request.flush({ retcode: 0, result: [] });
  72. });
  73. it('retries transient gateway failures before surfacing a friendly error', fakeAsync(() => {
  74. let actual: unknown;
  75. api.get('order_list', { cpage: 1 }).subscribe({ error: (error) => actual = error });
  76. http.expectOne(API_CONFIG.cloudFunctionUrl)
  77. .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
  78. tick(400);
  79. http.expectOne(API_CONFIG.cloudFunctionUrl)
  80. .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
  81. tick(800);
  82. http.expectOne(API_CONFIG.cloudFunctionUrl)
  83. .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
  84. expect(actual instanceof ApiError).toBeTrue();
  85. expect((actual as ApiError).message).toBe('请求暂未完成,请检查网络后重试;已填写内容会保留。');
  86. }));
  87. it('nests reserved id parameters inside params', () => {
  88. api.get('node_get', { id: 81 }).subscribe();
  89. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  90. expect(request.request.body).toEqual({ params: { action: 'node_get', id: 81 } });
  91. request.flush({ retcode: 0, result: { id: 81 } });
  92. });
  93. it('routes public content details and hit increments through the cloud gateway', () => {
  94. api.get('content_get', { id: 931 }).subscribe();
  95. const detail = http.expectOne(API_CONFIG.cloudFunctionUrl);
  96. expect(detail.request.body).toEqual({ params: { action: 'content_get', id: 931 } });
  97. detail.flush({ retcode: 0, result: [{ GeneralID: 931, Title: '文章' }] });
  98. api.post('content_uphis', { id: 931 }).subscribe();
  99. const hits = http.expectOne(API_CONFIG.cloudFunctionUrl);
  100. expect(hits.request.body).toEqual({ params: { action: 'content_uphis', id: 931 } });
  101. hits.flush({ retcode: 0, result: { hits: 2 } });
  102. });
  103. it('passes Parse sessions to private-capable content actions and never falls back to the old gateway', () => {
  104. sessionToken = 'r:session-token';
  105. api.get('content_get', { id: 931 }).subscribe();
  106. const cloud = http.expectOne(API_CONFIG.cloudFunctionUrl);
  107. expect(cloud.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_get', id: 931 } });
  108. cloud.flush({ retcode: 0, result: [{ GeneralID: 931 }] });
  109. sessionToken = 'legacy-session';
  110. api.get('content_get', { id: 931 }).subscribe();
  111. const migratedDetail = http.expectOne(API_CONFIG.cloudFunctionUrl);
  112. expect(migratedDetail.request.body).toEqual({ params: { action: 'content_get', id: 931 } });
  113. migratedDetail.flush({ retcode: 0, result: [{ GeneralID: 931 }] });
  114. api.post('content_uphis', { id: 931 }).subscribe();
  115. const migratedHits = http.expectOne(API_CONFIG.cloudFunctionUrl);
  116. expect(migratedHits.request.body).toEqual({ params: { action: 'content_uphis', id: 931 } });
  117. migratedHits.flush({ retcode: 0, result: null });
  118. });
  119. it('routes private addon and blocked product queries through the cloud gateway', () => {
  120. api.get('content_list', { modelId: 56, myfield2: 'UserId=42' }).subscribe();
  121. api.get('product_list', { cpage: 1 }).subscribe();
  122. const [content, products] = http.match(API_CONFIG.cloudFunctionUrl);
  123. expect(content.request.body).toEqual({ params: { action: 'content_list', modelId: 56, myfield2: 'UserId=42' } });
  124. expect(products.request.body).toEqual({ params: { action: 'product_list', cpage: 1 } });
  125. content.flush({ retcode: 0, result: [] });
  126. products.flush({ retcode: 0, result: [] });
  127. });
  128. it('routes supported private addon content with a Parse session', () => {
  129. sessionToken = 'r:session-token';
  130. api.get('content_list', { modelId: 56, myfield2: 'UserId=42|dqrq=20260818' }).subscribe();
  131. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  132. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_list', modelId: 56, myfield2: 'UserId=42|dqrq=20260818' } });
  133. request.flush({ retcode: 0, result: [] });
  134. });
  135. it('routes verified learning detail reads with a Parse session', () => {
  136. sessionToken = 'r:session-token';
  137. api.get('e_record_detail', { id: 931 }).subscribe();
  138. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  139. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_record_detail', id: 931 } });
  140. request.flush({ retcode: 0, result: [{ GeneralID: 149, detail: [{ Title: 'beautiful' }] }] });
  141. });
  142. it('routes course word progress reads with a Parse session', () => {
  143. sessionToken = 'r:session-token';
  144. api.get('e_ck_list', { uid: 58, nids: '40,41' }).subscribe();
  145. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  146. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_ck_list', uid: 58, nids: '40,41' } });
  147. request.flush({ retcode: 0, result: [{ GeneralID: 2505, w_learned: 7 }] });
  148. });
  149. it('routes team member, team statistics, and coach student reads with a Parse session', () => {
  150. sessionToken = 'r:session-token';
  151. api.get('user_list', { puid: 58, cpage: 1 }).subscribe();
  152. api.get('user_dept', { uid: 58 }).subscribe();
  153. api.get('e_user_list', { cpage: 1 }).subscribe();
  154. const [members, statistics, students] = http.match(API_CONFIG.cloudFunctionUrl);
  155. expect(members.request.body).toEqual({ token: 'r:session-token', params: { action: 'user_list', puid: 58, cpage: 1 } });
  156. expect(statistics.request.body).toEqual({ token: 'r:session-token', params: { action: 'user_dept', uid: 58 } });
  157. expect(students.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_user_list', cpage: 1 } });
  158. members.flush({ retcode: 0, result: [] });
  159. statistics.flush({ retcode: 0, result: { childs: 0, v0: 0 } });
  160. students.flush({ retcode: 0, result: [] });
  161. });
  162. it('routes typed virtual-currency history reads with a Parse session', () => {
  163. sessionToken = 'r:session-token';
  164. api.get('user_point_list', { uid: 58, stype: 3, cpage: 1 }).subscribe();
  165. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  166. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'user_point_list', uid: 58, stype: 3, cpage: 1 } });
  167. request.flush({ retcode: 0, result: [{ ExpHisID: 1, score: 2 }] });
  168. });
  169. it('routes legacy model-based feedback submissions with a Parse session', () => {
  170. sessionToken = 'r:session-token';
  171. const model = JSON.stringify({ UserID: 58, Title: '小树 反馈的意见', TContent: '请假一天', Cateid: 22 });
  172. api.post('guestbook_add', { model }).subscribe();
  173. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  174. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'guestbook_add', model } });
  175. request.flush({ retcode: 0, result: { GID: 1, TContent: '请假一天' } });
  176. });
  177. it('routes legacy mu profile updates with a Parse session', () => {
  178. sessionToken = 'r:session-token';
  179. const mu = JSON.stringify({ honeyName: '小树', Email: 'tree@example.test' });
  180. api.post('user_update', { uid: 58, mu, inviCode: '' }).subscribe();
  181. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  182. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'user_update', uid: 58, mu, inviCode: '' } });
  183. request.flush({ retcode: 0, result: { userId: 58, honeyName: '小树' } });
  184. });
  185. it('routes learning word state writes with a Parse session', () => {
  186. sessionToken = 'r:session-token';
  187. api.post('e_add_words', { uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 }).subscribe();
  188. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  189. expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'e_add_words', uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 } });
  190. request.flush({ retcode: 0, result: { affected: 2, newWordState: 1 } });
  191. });
  192. it('routes study record upserts with a Parse session', () => {
  193. sessionToken = 'r:session-token';
  194. api.post('stu_record_update_v2', { orderId: 918, uid: 23, addon: '{"xxqs":[]}' }).subscribe();
  195. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  196. expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'stu_record_update_v2', orderId: 918, uid: 23, addon: '{"xxqs":[]}' } });
  197. request.flush({ retcode: 0, result: { GeneralID: 123456, created: true } });
  198. });
  199. it('routes scheduled study record creation with a Parse session', () => {
  200. sessionToken = 'r:session-token';
  201. api.post('content_add_zt', { content: '{"ModelID":56}', addon: '{"UserID":23,"xxqs":"[]"}' }).subscribe();
  202. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  203. expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'content_add_zt', content: '{"ModelID":56}', addon: '{"UserID":23,"xxqs":"[]"}' } });
  204. request.flush({ retcode: 0, result: '123456' });
  205. });
  206. it('routes normalized content updates with a Parse session', () => {
  207. sessionToken = 'r:session-token';
  208. api.post('content_update', { content: '{"GeneralID":123456}', addon: '{"learned":2}' }).subscribe();
  209. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  210. expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'content_update', content: '{"GeneralID":123456}', addon: '{"learned":2}' } });
  211. request.flush({ retcode: 0, result: '123456' });
  212. });
  213. it('routes normalized content creation with a Parse session', () => {
  214. sessionToken = 'r:session-token';
  215. api.post('content_add', { content: '{"ModelID":61,"nodeId":389}', addon: '{"UserID":23,"df":8}' }).subscribe();
  216. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  217. expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'content_add', content: '{"ModelID":61,"nodeId":389}', addon: '{"UserID":23,"df":8}' } });
  218. request.flush({ retcode: 0, result: '123456' });
  219. });
  220. it('routes appointment statistics with a Parse session', () => {
  221. sessionToken = 'r:session-token';
  222. api.post('e_order_tongji', { uid: 23 }).subscribe();
  223. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  224. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_order_tongji', uid: 23 } });
  225. request.flush({ retcode: 0, result: { t30: '1', t_shichang: '0.5', t_total: '1' } });
  226. });
  227. it('routes complete appointment transitions with a Parse session', () => {
  228. sessionToken = 'r:session-token';
  229. api.post('e_order_update_v2', { uid: 23, status: 11, content: '{"GeneralID":918}' }).subscribe();
  230. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  231. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_order_update_v2', uid: 23, status: 11, content: '{"GeneralID":918}' } });
  232. request.flush({ retcode: 0, result: { GeneralID: '918', status: 11, reviewCount: 15, periodDeducted: true } });
  233. });
  234. it('routes learning detail reads to cloud even when a stale legacy token remains locally', () => {
  235. sessionToken = 'legacy-session';
  236. api.get('e_words_list', { uid: 58 }).subscribe();
  237. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  238. expect(request.request.body).toEqual({ params: { action: 'e_words_list', uid: 58 } });
  239. request.flush({ retcode: 0, result: [] });
  240. });
  241. it('routes public vocabulary addon content without a session', () => {
  242. api.get('content_list', { modelId: 52, nid: 327 }).subscribe();
  243. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  244. expect(request.request.body).toEqual({ params: { action: 'content_list', modelId: 52, nid: 327 } });
  245. request.flush({ retcode: 0, result: [] });
  246. });
  247. it('requires a Parse session for user-specific unit progress', () => {
  248. sessionToken = 'r:session-token';
  249. api.get('node_list', { pid: 16, ifunit: 1, userId: 58 }).subscribe();
  250. const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
  251. expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'node_list', pid: 16, ifunit: 1, userId: 58 } });
  252. request.flush({ retcode: 0, result: [{ NodeID: 40, total: 20, yx_word: 5, process: 25 }] });
  253. });
  254. it('turns retcode -1 responses into ApiError', () => {
  255. let actual: unknown;
  256. api.post('user_login_passwd', {}).subscribe({ error: (error) => actual = error });
  257. http.expectOne(API_CONFIG.cloudFunctionUrl).flush({ retcode: -1, retmsg: '账号或密码错误', result: null }, { status: 401, statusText: 'Unauthorized' });
  258. expect(actual instanceof ApiError).toBeTrue();
  259. expect((actual as ApiError).message).toBe('账号或密码错误');
  260. });
  261. it('clears an expired Parse session and returns to login after an authenticated 401', fakeAsync(() => {
  262. sessionToken = 'r:expired-session';
  263. let actual: unknown;
  264. api.post('app_learning_overview', { uid: 42 }).subscribe({ error: (error) => actual = error });
  265. http.expectOne(API_CONFIG.cloudFunctionUrl).flush(
  266. { retmsg: '会话已失效' },
  267. { status: 401, statusText: 'Unauthorized' },
  268. );
  269. tick();
  270. expect(actual instanceof ApiError).toBeTrue();
  271. expect(sessionLogout).toHaveBeenCalledTimes(1);
  272. expect(routerNavigate).toHaveBeenCalledOnceWith(
  273. ['/pages/member/wxauth'],
  274. {
  275. queryParams: { returnUrl: '/pages/his/his?date=2026-09-02', expired: 1 },
  276. replaceUrl: true,
  277. },
  278. );
  279. }));
  280. it('returns to login when a protected request has no usable session left', fakeAsync(() => {
  281. let actual: unknown;
  282. api.post('e_get_21list', { uid: 42 }).subscribe({ error: (error) => actual = error });
  283. http.expectOne(API_CONFIG.cloudFunctionUrl).flush(
  284. { retmsg: '需要登录' },
  285. { status: 401, statusText: 'Unauthorized' },
  286. );
  287. tick();
  288. expect(actual instanceof ApiError).toBeTrue();
  289. expect(sessionLogout).toHaveBeenCalledTimes(1);
  290. expect(routerNavigate).toHaveBeenCalledTimes(1);
  291. }));
  292. it('keeps a successful password login on the cloud response', () => {
  293. let actual: unknown;
  294. api.post('user_login_passwd', { name: 'tree', passwd: 'password' }).subscribe((response) => actual = response.result);
  295. http.expectOne(API_CONFIG.cloudFunctionUrl).flush({ retcode: 0, result: { userId: 42, sessionToken: 'r:token' } });
  296. expect(actual).toEqual({ userId: 42, sessionToken: 'r:token' });
  297. });
  298. it('routes account availability checks and classic registration to the cloud', () => {
  299. api.post('user_info_name', { uname: '13800138000' }).subscribe({ error: () => undefined });
  300. const lookup = http.expectOne(API_CONFIG.cloudFunctionUrl);
  301. expect(lookup.request.body).toEqual({ params: { action: 'user_info_name', uname: '13800138000' } });
  302. lookup.flush({ retcode: -1, retmsg: '用户不存在', result: null }, { status: 404, statusText: 'Not Found' });
  303. api.post('user_register', { name: '13800138000', passwd: 'Ab1234', code: '' }).subscribe();
  304. const register = http.expectOne(API_CONFIG.cloudFunctionUrl);
  305. expect(register.request.body).toEqual({ params: { action: 'user_register', name: '13800138000', passwd: 'Ab1234', code: '' } });
  306. register.flush({ retcode: 0, result: { userId: 42, sessionToken: 'r:token' } });
  307. });
  308. });
  309. describe('teaching request reliability',()=>{
  310. let api:ApiService,http:HttpTestingController;
  311. beforeEach(()=>{TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:SessionService,useValue:{token:()=> 'r:teacher',user:()=>({userId:88}),logout:()=>{}}}]});api=TestBed.inject(ApiService);http=TestBed.inject(HttpTestingController);});
  312. afterEach(()=>http.verify());
  313. for(const action of ['e_order_complete_feedback','vote_add','unit_record_update','e_get_21list_tj'])it(action+' carries the current session',()=>{api.post(action,{uid:1761}).subscribe();const req=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(req.request.body.token).toBe('r:teacher');req.flush({retcode:0,result:{}});});
  314. it('does not automatically retry a write and retains its request key for manual retry',fakeAsync(()=>{const body={uid:1761,wordsId:'7',save:1};api.post('e_add_words',body).subscribe({error:()=>{}});const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;expect(key).toBeTruthy();first.flush('',{status:503,statusText:'Unavailable'});tick(2000);http.expectNone(API_CONFIG.cloudFunctionUrl);api.post('e_add_words',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{}});}));
  315. it('fetches a fresh learning summary after a successful teaching write',()=>{const results:Array<{records:Array<{id:number}>}>=[];api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[]}});api.post('e_order_complete_feedback',{orderId:7}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{}});api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[{id:1}]}});expect(results).toEqual([{records:[]},{records:[{id:1}]}]);});
  316. it('times out completion without retrying and keeps the idempotency key for a manual retry',fakeAsync(()=>{
  317. const body={orderId:901,expectedUpdatedAt:'2026-10-11T00:00:00Z',feedback:{contentSummary:'Unit 1'}};let message='';
  318. api.post('app_lesson_complete',body).subscribe({error:e=>message=e.message});
  319. const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;
  320. tick(20_001);expect(message).toContain('请求暂未完成');expect(first.cancelled).toBeTrue();http.expectNone(API_CONFIG.cloudFunctionUrl);
  321. api.post('app_lesson_complete',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);
  322. expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{status:30}});
  323. }));
  324. });