cloud-functions.service.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. import { provideHttpClient } from '@angular/common/http';
  2. import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
  3. import { TestBed, fakeAsync, tick } from '@angular/core/testing';
  4. import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
  5. import { AdminSessionService } from './admin-session.service';
  6. import { AppMigrationStatus } from './admin.models';
  7. import { CloudFunctionsService } from './cloud-functions.service';
  8. describe('CloudFunctionsService', () => {
  9. let service: CloudFunctionsService;
  10. let http: HttpTestingController;
  11. beforeEach(() => {
  12. TestBed.configureTestingModule({
  13. providers: [
  14. provideHttpClient(),
  15. provideHttpClientTesting(),
  16. { provide: AdminSessionService, useValue: { session: () => ({ sessionToken: 'r:admin-session' }), logout: jasmine.createSpy('logout'), invalidateSession:jasmine.createSpy('invalidateSession'), verifySessionAfterFailure:jasmine.createSpy('verifySessionAfterFailure').and.resolveTo() } },
  17. ],
  18. });
  19. service = TestBed.inject(CloudFunctionsService);
  20. http = TestBed.inject(HttpTestingController);
  21. });
  22. afterEach(() => http.verify());
  23. it('loads the live app migration matrix with the admin Parse session', async () => {
  24. const pending = service.app<AppMigrationStatus>('migration_status');
  25. const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`);
  26. expect(request.request.body).toEqual({ token: 'r:admin-session', params: { action: 'migration_status' } });
  27. request.flush({ retcode: 0, retmsg: '', result: { implemented: ['app_update'], blocked: { cart_list: '目标 Schema 无购物车类' } } });
  28. await expectAsync(pending).toBeResolvedTo({ implemented: ['app_update'], blocked: { cart_list: '目标 Schema 无购物车类' } });
  29. });
  30. it('calls the isolated operations gateway', async () => {
  31. const pending = service.operations('ops/users/list', { page: 1, pageSize: 20 });
  32. const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
  33. expect(request.request.body).toEqual({ token: 'r:admin-session', params: { operation: 'ops/users/list', page: 1, pageSize: 20 } });
  34. request.flush({ success: true, data: { page: 1, pageSize: 20, total: 0, items: [] } });
  35. await expectAsync(pending).toBeResolvedTo({ page: 1, pageSize: 20, total: 0, items: [] });
  36. });
  37. it('does not log out an authenticated user for a forbidden account-management action', async () => {
  38. const pending=service.admin('operatorAccounts/list');
  39. const assertion=expectAsync(pending).toBeRejected();
  40. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`).flush({message:'只有运营经理可以管理账号'},{status:403,statusText:'Forbidden'});
  41. await assertion; expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
  42. });
  43. it('does not treat an app permission denial as a lost session', async()=>{
  44. const pending=service.app('migration_status'),assertion=expectAsync(pending).toBeRejected();
  45. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`).flush({retmsg:'需要管理员权限'},{status:403,statusText:'Forbidden'});
  46. await assertion;const sessions=TestBed.inject(AdminSessionService);
  47. expect(sessions.invalidateSession).not.toHaveBeenCalled();expect(sessions.verifySessionAfterFailure).not.toHaveBeenCalled();expect(sessions.logout).not.toHaveBeenCalled();
  48. });
  49. it('verifies a gateway 401 using the token belonging to that request', async()=>{
  50. const pending=service.operations('ops/live/versions'),assertion=expectAsync(pending).toBeRejected();
  51. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({message:'管理员会话已失效'},{status:401,statusText:'Unauthorized'});
  52. await assertion;expect(TestBed.inject(AdminSessionService).verifySessionAfterFailure).toHaveBeenCalledWith('r:admin-session');
  53. expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
  54. });
  55. it('honors an explicit disabled-account response',async()=>{
  56. const pending=service.app('migration_status'),assertion=expectAsync(pending).toBeRejected();
  57. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`).flush({retmsg:'账号已停用'},{status:403,statusText:'Forbidden'});
  58. await assertion;expect(TestBed.inject(AdminSessionService).invalidateSession).toHaveBeenCalledWith('r:admin-session');
  59. });
  60. it('releases a hanging write after 20 seconds without retrying or logging out',fakeAsync(()=>{
  61. let failure:unknown;service.operations('ops/payroll/create',{idempotencyKey:'keep-this-key'}).catch(e=>failure=e);
  62. const req=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(20001);expect(req.cancelled).toBeTrue();expect(String(failure)).toContain('结果待核对');http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
  63. }));
  64. it('allows bounded vocabulary import calls to finish after the ordinary 20 second limit',fakeAsync(()=>{
  65. let result:unknown;service.operations('ops/vocabulary/import-preview').then(value=>result=value);
  66. const req=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(27500);expect(req.cancelled).toBeFalse();req.flush({success:true,data:{batchId:'batch'}});tick();expect(result).toEqual({batchId:'batch'});
  67. }));
  68. it('recovers one interrupted calendar read without losing the session',fakeAsync(()=>{
  69. let result:unknown;service.operations('ops/schedule/calendar').then(v=>result=v);
  70. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
  71. tick(500);
  72. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({success:true,data:{appointments:[]}});
  73. tick();expect(result).toEqual({appointments:[]});
  74. expect(TestBed.inject(AdminSessionService).verifySessionAfterFailure).not.toHaveBeenCalled();
  75. }));
  76. it('bounds retries for a temporarily unavailable member list and hides transport internals',fakeAsync(()=>{
  77. let failure:Error|undefined;service.operations('ops/users/list').catch(e=>failure=e);
  78. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush('unavailable',{status:503,statusText:'Service Unavailable'});
  79. tick(500);
  80. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
  81. tick(1000);http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
  82. expect(failure?.message).toContain('加载');expect(failure?.message).not.toContain('https://');expect(failure?.message).not.toContain('结果待核对');
  83. }));
  84. for(const operation of ['ops/appointments/create','ops/users/reset-password','ops/member-credits/post','ops/unknown/action']){
  85. it(`does not replay an uncertain write: ${operation}`,fakeAsync(()=>{
  86. let failure:Error|undefined;service.operations(operation,{idempotencyKey:'same-command'}).catch(e=>failure=e);
  87. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
  88. tick(1000);http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
  89. expect(failure?.message).toContain('结果待核对');expect(failure?.message).not.toContain('Unknown Error');
  90. }));
  91. }
  92. it('does not retry a slow read and labels it as a load failure',fakeAsync(()=>{
  93. let failure:Error|undefined;service.operations('ops/schedule/calendar').catch(e=>failure=e);
  94. const request=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(21000);
  95. expect(request.cancelled).toBeTrue();http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
  96. expect(failure?.message).toContain('加载');expect(failure?.message).not.toContain('结果待核对');
  97. }));
  98. it('cannot turn a safe read into a retried write through params',fakeAsync(()=>{
  99. let result:unknown;service.operations('ops/users/list',{operation:'ops/users/create'}).then(v=>result=v);
  100. const request=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
  101. expect(request.request.body.params.operation).toBe('ops/users/list');
  102. request.flush({success:true,data:[]});tick();expect(result).toEqual([]);
  103. }));
  104. it('does not retry with an old session when the user logs out during backoff',fakeAsync(()=>{
  105. let failure:unknown;service.operations('ops/users/list').catch(e=>failure=e);
  106. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
  107. spyOn(TestBed.inject(AdminSessionService),'session').and.returnValue(null);
  108. tick(500);http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);expect(failure).toBeDefined();
  109. }));
  110. it('coalesces batch mutation notifications until the batch ends',async()=>{
  111. let changes=0;service.dataChanges$.subscribe(()=>changes++);
  112. await service.batchDataChanges(async()=>{
  113. for(let i=0;i<3;i++){
  114. const pending=service.operations('ops/appointments/cancel');
  115. http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({success:true,data:{}});
  116. await pending;expect(changes).toBe(0);
  117. }
  118. });
  119. expect(changes).toBe(1);
  120. });
  121. it('announces one final reconciliation even when a whole batch fails',async()=>{
  122. let changes=0;service.dataChanges$.subscribe(()=>changes++);
  123. await expectAsync(service.batchDataChanges(async()=>{throw Error('uncertain');})).toBeRejectedWithError('uncertain');
  124. expect(changes).toBe(1);
  125. });
  126. });
  127. describe('课时查询与记账区分',()=>{
  128. for(const operation of ['ops/users/credit-preview','ops/users/credit-accounts','ops/users/credit-ledger','ops/users/credit-status'])it(`${operation} 超时不提示已经提交`,fakeAsync(()=>{TestBed.configureTestingModule({providers:[CloudFunctionsService,provideHttpClient(),provideHttpClientTesting(),{provide:AdminSessionService,useValue:{session:()=>({sessionToken:'token'})}}]});const service=TestBed.inject(CloudFunctionsService),http=TestBed.inject(HttpTestingController);let message='';service.operations(operation).catch(e=>message=e.message);http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(20001);expect(message).toContain('加载');expect(message).not.toContain('提交结果');http.verify();}));
  129. });