/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { FetchBackend, HTTP_INTERCEPTORS, HttpBackend, HttpClient, HttpClientModule, HttpEvent, HttpEventType, HttpHandler, HttpHeaders, HttpInterceptor, HttpRequest, HttpResponse, provideHttpClient, withFetch, } from '@angular/common/http'; import {importProvidersFrom, Injectable} from '@angular/core'; import {TestBed, waitForAsync} from '@angular/core/testing'; import {HttpClientBackendService, HttpClientInMemoryWebApiModule} from 'angular-in-memory-web-api'; import {Observable, of, zip} from 'rxjs'; import {concatMap, map, tap} from 'rxjs/operators'; import {Hero} from './fixtures/hero'; import {HeroInMemDataOverrideService} from './fixtures/hero-in-mem-data-override-service'; import {HeroInMemDataService} from './fixtures/hero-in-mem-data-service'; import {HeroService} from './fixtures/hero-service'; import {HttpClientHeroService} from './fixtures/http-client-hero-service'; describe('HttpClient Backend Service', () => { const delay = 1; // some minimal simulated latency delay describe('raw Angular HttpClient', () => { let http: HttpClient; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay}), ], }); http = TestBed.inject(HttpClient); }); it('can get heroes', waitForAsync(() => { http .get('api/heroes') .subscribe( (heroes) => expect(heroes.length).toBeGreaterThan(0, 'should have heroes'), failRequest, ); })); it('GET should be a "cold" observable', waitForAsync(() => { const httpBackend = TestBed.inject(HttpBackend); const spy = spyOn(httpBackend, 'collectionHandler').and.callThrough(); const get$ = http.get('api/heroes'); // spy on `collectionHandler` should not be called before subscribe expect(spy).not.toHaveBeenCalled(); get$.subscribe((heroes) => { expect(spy).toHaveBeenCalled(); expect(heroes.length).toBeGreaterThan(0, 'should have heroes'); }, failRequest); })); it('GET should wait until after delay to respond', waitForAsync(() => { // to make test fail, set `delay=0` above let gotResponse = false; http.get('api/heroes').subscribe((heroes) => { gotResponse = true; expect(heroes.length).toBeGreaterThan(0, 'should have heroes'); }, failRequest); expect(gotResponse).toBe(false, 'should delay before response'); })); it('Should only initialize the db once', waitForAsync(() => { const httpBackend = TestBed.inject(HttpBackend); const spy = spyOn(httpBackend, 'resetDb').and.callThrough(); // Simultaneous backend.handler calls // Only the first should initialize by calling `resetDb` // All should wait until the db is "ready" // then they share the same db instance. http.get('api/heroes').subscribe(); http.get('api/heroes').subscribe(); http.get('api/heroes').subscribe(); http.get('api/heroes').subscribe(); expect(spy.calls.count()).toBe(1); })); it('can get heroes (w/ a different base path)', waitForAsync(() => { http.get('some-base-path/heroes').subscribe((heroes) => { expect(heroes.length).toBeGreaterThan(0, 'should have heroes'); }, failRequest); })); it('should 404 when GET unknown collection (after delay)', waitForAsync(() => { let gotError = false; const url = 'api/unknown-collection'; http.get(url).subscribe( () => fail(`should not have found data for '${url}'`), (err) => { gotError = true; expect(err.status).toBe(404, 'should have 404 status'); }, ); expect(gotError).toBe(false, 'should not get error until after delay'); })); it('should return the hero w/id=1 for GET app/heroes/1', waitForAsync(() => { http .get('api/heroes/1') .subscribe((hero) => expect(hero).toBeDefined('should find hero with id=1'), failRequest); })); // test where id is string that looks like a number it('should return the stringer w/id="10" for GET app/stringers/10', waitForAsync(() => { http .get('api/stringers/10') .subscribe( (hero) => expect(hero).toBeDefined('should find string with id="10"'), failRequest, ); })); it('should return 1-item array for GET app/heroes/?id=1', waitForAsync(() => { http .get('api/heroes/?id=1') .subscribe( (heroes) => expect(heroes.length).toBe(1, 'should find one hero w/id=1'), failRequest, ); })); it('should return 1-item array for GET app/heroes?id=1', waitForAsync(() => { http .get('api/heroes?id=1') .subscribe( (heroes) => expect(heroes.length).toBe(1, 'should find one hero w/id=1'), failRequest, ); })); it('should return undefined for GET app/heroes?id=not-found-id', waitForAsync(() => { http .get('api/heroes?id=123456') .subscribe((heroes) => expect(heroes.length).toBe(0), failRequest); })); it('should return 404 for GET app/heroes/not-found-id', waitForAsync(() => { const url = 'api/heroes/123456'; http.get(url).subscribe( () => fail(`should not have found data for '${url}'`), (err) => expect(err.status).toBe(404, 'should have 404 status'), ); })); it('can generate the id when add a hero with no id', waitForAsync(() => { const hero = new Hero(undefined, 'SuperDooper'); http.post('api/heroes', hero).subscribe((replyHero) => { expect(replyHero.id).toBeDefined('added hero should have an id'); expect(replyHero).not.toBe(hero, 'reply hero should not be the request hero'); }, failRequest); })); it('can get nobodies (empty collection)', waitForAsync(() => { http.get('api/nobodies').subscribe((nobodies) => { expect(nobodies.length).toBe(0, 'should have no nobodies'); }, failRequest); })); it('can add a nobody with an id to empty nobodies collection', waitForAsync(() => { const id = 'g-u-i-d'; http .post('api/nobodies', {id, name: 'Noman'}) .pipe(concatMap(() => http.get<{id: string; name: string}[]>('api/nobodies'))) .subscribe((nobodies) => { expect(nobodies.length).toBe(1, 'should a nobody'); expect(nobodies[0].name).toBe('Noman', 'should be "Noman"'); expect(nobodies[0].id).toBe(id, 'should preserve the submitted, ' + id); }, failRequest); })); it('should fail when add a nobody without an id to empty nobodies collection', waitForAsync(() => { http.post('api/nobodies', {name: 'Noman'}).subscribe( () => fail(`should not have been able to add 'Norman' to 'nobodies'`), (err) => { expect(err.status).toBe(422, 'should have 422 status'); expect(err.body.error).toContain('id type is non-numeric'); }, ); })); describe('can reset the database', () => { it('to empty (object db)', waitForAsync(() => resetDatabaseTest('object'))); it('to empty (observable db)', waitForAsync(() => resetDatabaseTest('observable'))); it('to empty (promise db)', waitForAsync(() => resetDatabaseTest('promise'))); function resetDatabaseTest(returnType: string) { // Observable of the number of heroes and nobodies const sizes$ = zip( http.get('api/heroes'), http.get('api/nobodies'), http.get('api/stringers'), ).pipe(map(([h, n, s]) => ({heroes: h.length, nobodies: n.length, stringers: s.length}))); // Add a nobody so that we have one http .post('api/nobodies', {id: 42, name: 'Noman'}) .pipe( // Reset database with "clear" option concatMap(() => http.post('commands/resetDb', {clear: true, returnType})), // get the number of heroes and nobodies concatMap(() => sizes$), ) .subscribe((sizes) => { expect(sizes.heroes).toBe(0, 'reset should have cleared the heroes'); expect(sizes.nobodies).toBe(0, 'reset should have cleared the nobodies'); expect(sizes.stringers).toBe(0, 'reset should have cleared the stringers'); }, failRequest); } }); }); describe('raw Angular HttpClient w/ override service', () => { let http: HttpClient; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataOverrideService, {delay}), ], }); http = TestBed.inject(HttpClient); }); it('can get heroes', waitForAsync(() => { http .get('api/heroes') .subscribe( (heroes) => expect(heroes.length).toBeGreaterThan(0, 'should have heroes'), failRequest, ); })); it('can translate `foo/heroes` to `heroes` via `parsedRequestUrl` override', waitForAsync(() => { http .get('api/foo/heroes') .subscribe( (heroes) => expect(heroes.length).toBeGreaterThan(0, 'should have heroes'), failRequest, ); })); it('can get villains', waitForAsync(() => { http .get('api/villains') .subscribe( (villains) => expect(villains.length).toBeGreaterThan(0, 'should have villains'), failRequest, ); })); it('should 404 when POST to villains', waitForAsync(() => { const url = 'api/villains'; http.post(url, {id: 42, name: 'Dr. Evil'}).subscribe( () => fail(`should not have POSTed data for '${url}'`), (err) => expect(err.status).toBe(404, 'should have 404 status'), ); })); it('should 404 when GET unknown collection', waitForAsync(() => { const url = 'api/unknown-collection'; http.get(url).subscribe( () => fail(`should not have found data for '${url}'`), (err) => expect(err.status).toBe(404, 'should have 404 status'), ); })); it('should use genId override to add new hero, "Maxinius"', waitForAsync(() => { http .post('api/heroes', {name: 'Maxinius'}) .pipe(concatMap(() => http.get('api/heroes?name=Maxi'))) .subscribe((heroes) => { expect(heroes.length).toBe(1, 'should have found "Maxinius"'); expect(heroes[0].name).toBe('Maxinius'); expect(heroes[0].id).toBeGreaterThan(1000); }, failRequest); })); it('should use genId override guid generator for a new nobody without an id', waitForAsync(() => { http .post('api/nobodies', {name: 'Noman'}) .pipe(concatMap(() => http.get<{id: string; name: string}[]>('api/nobodies'))) .subscribe((nobodies) => { expect(nobodies.length).toBe(1, 'should a nobody'); expect(nobodies[0].name).toBe('Noman', 'should be "Noman"'); expect(typeof nobodies[0].id).toBe('string', 'should create a string (guid) id'); }, failRequest); })); describe('can reset the database', () => { it('to empty (object db)', waitForAsync(() => resetDatabaseTest('object'))); it('to empty (observable db)', waitForAsync(() => resetDatabaseTest('observable'))); it('to empty (promise db)', waitForAsync(() => resetDatabaseTest('promise'))); function resetDatabaseTest(returnType: string) { // Observable of the number of heroes, nobodies and villains const sizes$ = zip( http.get('api/heroes'), http.get('api/nobodies'), http.get('api/stringers'), http.get('api/villains'), ).pipe( map(([h, n, s, v]) => ({ heroes: h.length, nobodies: n.length, stringers: s.length, villains: v.length, })), ); // Add a nobody so that we have one http .post('api/nobodies', {id: 42, name: 'Noman'}) .pipe( // Reset database with "clear" option concatMap(() => http.post('commands/resetDb', {clear: true, returnType})), // count all the collections concatMap(() => sizes$), ) .subscribe((sizes) => { expect(sizes.heroes).toBe(0, 'reset should have cleared the heroes'); expect(sizes.nobodies).toBe(0, 'reset should have cleared the nobodies'); expect(sizes.stringers).toBe(0, 'reset should have cleared the stringers'); expect(sizes.villains).toBeGreaterThan(0, 'reset should NOT clear villains'); }, failRequest); } }); }); describe('HttpClient HeroService', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay}), ], providers: [{provide: HeroService, useClass: HttpClientHeroService}], }); }); describe('HeroService core', () => { let heroService: HeroService; beforeEach(() => { heroService = TestBed.inject(HeroService); }); it('can get heroes', waitForAsync(() => { heroService.getHeroes().subscribe((heroes) => { expect(heroes.length).toBeGreaterThan(0, 'should have heroes'); }, failRequest); })); it('can get hero w/ id=1', waitForAsync(() => { heroService.getHero(1).subscribe( (hero) => { expect(hero.name).toBe('Windstorm'); }, () => fail('getHero failed'), ); })); it('should 404 when hero id not found', waitForAsync(() => { const id = 123456; heroService.getHero(id).subscribe( () => fail(`should not have found hero for id='${id}'`), (err) => { expect(err.status).toBe(404, 'should have 404 status'); }, ); })); it('can add a hero', waitForAsync(() => { heroService .addHero('FunkyBob') .pipe( tap((hero) => expect(hero.name).toBe('FunkyBob')), // Get the new hero by its generated id concatMap((hero) => heroService.getHero(hero.id)), ) .subscribe( (hero) => { expect(hero.name).toBe('FunkyBob'); }, () => failRequest('re-fetch of new hero failed'), ); }), 10000); it('can delete a hero', waitForAsync(() => { const id = 1; heroService.deleteHero(id).subscribe((_: {}) => expect(_).toBeDefined(), failRequest); })); it('should allow delete of non-existent hero', waitForAsync(() => { const id = 123456; heroService.deleteHero(id).subscribe((_: {}) => expect(_).toBeDefined(), failRequest); })); it('can search for heroes by name containing "a"', waitForAsync(() => { heroService.searchHeroes('a').subscribe((heroes: Hero[]) => { expect(heroes.length).toBe(3, 'should find 3 heroes with letter "a"'); }, failRequest); })); it('can update existing hero', waitForAsync(() => { const id = 1; heroService .getHero(id) .pipe( concatMap((hero) => { hero.name = 'Thunderstorm'; return heroService.updateHero(hero); }), concatMap(() => heroService.getHero(id)), ) .subscribe( (hero) => expect(hero.name).toBe('Thunderstorm'), () => fail('re-fetch of updated hero failed'), ); }), 10000); it('should create new hero when try to update non-existent hero', waitForAsync(() => { const falseHero = new Hero(12321, 'DryMan'); heroService .updateHero(falseHero) .subscribe((hero) => expect(hero.name).toBe(falseHero.name), failRequest); })); }); }); describe('HttpClient interceptor', () => { let http: HttpClient; let interceptors: HttpInterceptor[]; let httpBackend: HttpClientBackendService; /** * Test interceptor adds a request header and a response header */ @Injectable() class TestHeaderInterceptor implements HttpInterceptor { intercept(req: HttpRequest, next: HttpHandler): Observable> { const reqClone = req.clone({setHeaders: {'x-test-req': 'req-test-header'}}); return next.handle(reqClone).pipe( map((event) => { if (event instanceof HttpResponse) { event = event.clone({headers: event.headers.set('x-test-res', 'res-test-header')}); } return event; }), ); } } beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay}), ], providers: [ // Add test interceptor just for this test suite {provide: HTTP_INTERCEPTORS, useClass: TestHeaderInterceptor, multi: true}, ], }); http = TestBed.inject(HttpClient); httpBackend = TestBed.inject(HttpBackend); interceptors = TestBed.inject(HTTP_INTERCEPTORS); }); // sanity test it('TestingModule should provide the test interceptor', () => { const ti = interceptors.find((i) => i instanceof TestHeaderInterceptor); expect(ti).toBeDefined(); }); it('should have GET request header from test interceptor', waitForAsync(() => { const handle = spyOn(httpBackend, 'handle').and.callThrough(); http.get('api/heroes').subscribe((heroes) => { // HttpRequest is first arg of the first call to in-mem backend `handle` const req: HttpRequest = handle.calls.argsFor(0)[0]; const reqHeader = req.headers.get('x-test-req'); expect(reqHeader).toBe('req-test-header'); expect(heroes.length).toBeGreaterThan(0, 'should have heroes'); }, failRequest); })); it('should have GET response header from test interceptor', waitForAsync(() => { let gotResponse = false; const req = new HttpRequest('GET', 'api/heroes'); http.request(req).subscribe( (event) => { if (event.type === HttpEventType.Response) { gotResponse = true; const resHeader = event.headers.get('x-test-res'); expect(resHeader).toBe('res-test-header'); const heroes = event.body as Hero[]; expect(heroes.length).toBeGreaterThan(0, 'should have heroes'); } }, failRequest, () => expect(gotResponse).toBe(true, 'should have seen Response event'), ); })); }); describe('HttpClient passThru', () => { let http: HttpClient; let httpBackend: HttpClientBackendService; let createPassThruBackend: jasmine.Spy; const mockPassThroughResponse = of({ status: 200, headers: new HttpHeaders({'Content-Type': 'application/json'}), body: JSON.stringify([{id: 42, name: 'Dude'}]), } as HttpEvent); beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, { delay, passThruUnknownUrl: true, }), ], }); http = TestBed.inject(HttpClient); httpBackend = TestBed.inject(HttpBackend); createPassThruBackend = spyOn(httpBackend, 'createPassThruBackend').and.callThrough(); spyOn(httpBackend, 'handle').and.returnValue(mockPassThroughResponse); }); it('can get heroes (no passthru)', waitForAsync(() => { http.get('api/heroes').subscribe((heroes) => { expect(createPassThruBackend).not.toHaveBeenCalled(); expect(heroes.length).toBeGreaterThan(0, 'should have heroes'); }, failRequest); })); // `passthru` is NOT a collection in the data store // so requests for it should pass thru to the "real" server it('can GET passthru', waitForAsync(() => { http.get('api/passthru').subscribe((passthru) => { expect(passthru.length).toBeGreaterThan(0, 'should have passthru data'); }, failRequest); })); it('can ADD to passthru', waitForAsync(() => { http.post('api/passthru', {name: 'Dude'}).subscribe((passthru) => { expect(passthru).toBeDefined('should have passthru data'); expect(passthru.id).toBe(42, 'passthru object should have id 42'); }, failRequest); })); }); describe('HttpClient passThru creation', () => { it('should create the passThru backend', () => { TestBed.configureTestingModule({ imports: [ HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, { delay, passThruUnknownUrl: true, }), ], }); const httpBackend = TestBed.inject(HttpBackend); expect(() => httpBackend.createPassThruBackend()).not.toThrow(); }); }); describe('Http dataEncapsulation = true', () => { let http: HttpClient; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, { delay, dataEncapsulation: true, }), ], }); http = TestBed.inject(HttpClient); }); it('can get heroes (encapsulated)', waitForAsync(() => { http .get<{data: any}>('api/heroes') .pipe(map((data) => data.data as Hero[])) .subscribe( (heroes) => expect(heroes.length).toBeGreaterThan(0, 'should have data.heroes'), failRequest, ); })); }); describe('when using the FetchBackend', () => { it('should be the an InMemory Service', () => { TestBed.configureTestingModule({ providers: [ provideHttpClient(withFetch()), importProvidersFrom( HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay}), ), {provide: HeroService, useClass: HttpClientHeroService}, ], }); expect(TestBed.inject(HttpBackend)).toBeInstanceOf(HttpClientBackendService); }); it('should be a FetchBackend', () => { // In this test, providers order matters TestBed.configureTestingModule({ providers: [ importProvidersFrom( HttpClientInMemoryWebApiModule.forRoot(HeroInMemDataService, {delay}), ), provideHttpClient(withFetch()), {provide: HeroService, useClass: HttpClientHeroService}, ], }); expect(TestBed.inject(HttpBackend)).toBeInstanceOf(FetchBackend); }); }); }); /** * Fail a Jasmine test such that it displays the error object, * typically passed in the error path of an Observable.subscribe() */ function failRequest(err: any) { fail(JSON.stringify(err)); }