Files
Andrew Scott 6993146a25 refactor(http): Do not contribute to stability when using provideHttpClientTesting (#54974)
`HttpClient` uses the `PendingTasks` service to contribute to
application stability. This was added in v16 to support SSR without
relying on an infinite `setTimeout` with ZoneJS like it did pre-v16.
Prior to version 16, this was also only done on the server and did not
affect clients or unit tests (https://github.com/angular/angular/commit/28c68f709cdc930e12bac51a266e7bf790656034).

Today, `PendingTasks` contribute to `ApplicationRef.isStable` but do not
contribute to the stability of `ComponentFixture`. This divergence in
stability behavior was not intended and we plan to make these two
stability indicators the same again, like they were when it was solely
based on the state of the Zone.

By aligning the two behaviors again, this would include all pending
tasks in the stability of fixtures. After investigation, this seems
likely to be a pretty large breaking change. Tests appear to quite often use
`await fixture.whenStable` when there are unfinished requests that have
not been mocked or flushed.

This change prevents request in `HttpClient` from contributing to
stability through the `PendingTasks` automatically but only when using
`HttpClientTesting`. In this scenario, requests need to be expected and
flushed manually for them to resolve. When the test backend and controllers
aren't used, requests should resolve on their own so `await fixture.whenStable`
shouldn't be particularly affected or problematic.

PR Close #54974
2024-04-10 09:27:40 -07:00

594 lines
19 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.sonpCallbackContext
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {DOCUMENT, XhrFactory} from '@angular/common';
import {
FetchBackend,
HTTP_INTERCEPTORS,
HttpBackend,
HttpClient,
HttpClientModule,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
HttpResponse,
HttpXhrBackend,
JsonpClientBackend,
} from '@angular/common/http';
import {
HttpClientTestingModule,
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import {
ApplicationRef,
createEnvironmentInjector,
EnvironmentInjector,
inject,
InjectionToken,
PLATFORM_ID,
Provider,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {EMPTY, Observable, from} from 'rxjs';
import {HttpInterceptorFn, resetFetchBackendWarningFlag} from '../src/interceptor';
import {
provideHttpClient,
withFetch,
withInterceptors,
withInterceptorsFromDi,
withJsonpSupport,
withNoXsrfProtection,
withRequestsMadeViaParent,
withXsrfConfiguration,
} from '../src/provider';
describe('without provideHttpClientTesting', () => {
it('should contribute to stability', async () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([() => from(Promise.resolve(new HttpResponse()))])),
],
});
let stable = false;
TestBed.inject(ApplicationRef).isStable.subscribe((v) => {
stable = v;
});
expect(stable).toBe(true);
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
expect(stable).toBe(false);
await Promise.resolve();
expect(stable).toBe(true);
});
});
describe('provideHttpClient', () => {
beforeEach(() => {
setCookie('');
TestBed.resetTestingModule();
});
afterEach(() => {
let controller: HttpTestingController;
try {
controller = TestBed.inject(HttpTestingController);
} catch (err) {
// A failure here means that TestBed wasn't successfully configured. Some tests intentionally
// test configuration errors and therefore exit without setting up TestBed for HTTP, so just
// exit here without performing verification on the `HttpTestingController` in that case.
return;
}
controller.verify();
});
it('should configure HttpClient', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
TestBed.inject(HttpTestingController).expectOne('/test').flush('');
});
it('should not contribute to stability', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
let stable = false;
TestBed.inject(ApplicationRef).isStable.subscribe((v) => {
stable = v;
});
expect(stable).toBe(true);
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
expect(stable).toBe(true);
TestBed.inject(HttpTestingController).expectOne('/test').flush('');
expect(stable).toBe(true);
});
it('should not use legacy interceptors by default', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideLegacyInterceptor('legacy'),
provideHttpClientTesting(),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.has('X-Tag')).toBeFalse();
req.flush('');
});
it('withInterceptorsFromDi() should enable legacy interceptors', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideLegacyInterceptor('alpha'),
provideLegacyInterceptor('beta'),
provideHttpClientTesting(),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('alpha,beta');
req.flush('');
});
describe('interceptor functions', () => {
it('should allow configuring interceptors', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(
withInterceptors([
makeLiteralTagInterceptorFn('alpha'),
makeLiteralTagInterceptorFn('beta'),
]),
),
provideHttpClientTesting(),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('alpha,beta');
req.flush('');
});
it('should accept multiple separate interceptor configs', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(
withInterceptors([makeLiteralTagInterceptorFn('alpha')]),
withInterceptors([makeLiteralTagInterceptorFn('beta')]),
),
provideHttpClientTesting(),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('alpha,beta');
req.flush('');
});
it('should allow injection from an interceptor context', () => {
const ALPHA = new InjectionToken<string>('alpha', {
providedIn: 'root',
factory: () => 'alpha',
});
const BETA = new InjectionToken<string>('beta', {providedIn: 'root', factory: () => 'beta'});
TestBed.configureTestingModule({
providers: [
provideHttpClient(
withInterceptors([makeTokenTagInterceptorFn(ALPHA), makeTokenTagInterceptorFn(BETA)]),
),
provideHttpClientTesting(),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('alpha,beta');
req.flush('');
});
it('should allow combination with legacy interceptors, before the legacy stack', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(
withInterceptors([makeLiteralTagInterceptorFn('functional')]),
withInterceptorsFromDi(),
),
provideHttpClientTesting(),
provideLegacyInterceptor('legacy'),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('functional,legacy');
req.flush('');
});
it('should allow combination with legacy interceptors, after the legacy stack', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(
withInterceptorsFromDi(),
withInterceptors([makeLiteralTagInterceptorFn('functional')]),
),
provideHttpClientTesting(),
provideLegacyInterceptor('legacy'),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('legacy,functional');
req.flush('');
});
});
describe('xsrf protection', () => {
it('should enable xsrf protection by default', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{provide: PLATFORM_ID, useValue: 'test'},
],
});
setXsrfToken('abcdefg');
TestBed.inject(HttpClient).post('/test', '', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-XSRF-TOKEN')).toEqual('abcdefg');
req.flush('');
});
it('withXsrfConfiguration() should allow customization of xsrf config', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(
withXsrfConfiguration({
cookieName: 'XSRF-CUSTOM-COOKIE',
headerName: 'X-Custom-Xsrf-Header',
}),
),
provideHttpClientTesting(),
{provide: PLATFORM_ID, useValue: 'test'},
],
});
setCookie('XSRF-CUSTOM-COOKIE=abcdefg');
TestBed.inject(HttpClient).post('/test', '', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Custom-Xsrf-Header')).toEqual('abcdefg');
req.flush('');
});
it('withNoXsrfProtection() should disable xsrf protection', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withNoXsrfProtection()),
provideHttpClientTesting(),
{provide: PLATFORM_ID, useValue: 'test'},
],
});
setXsrfToken('abcdefg');
TestBed.inject(HttpClient).post('/test', '', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.has('X-Custom-Xsrf-Header')).toBeFalse();
req.flush('');
});
it('should error if withXsrfConfiguration() and withNoXsrfProtection() are combined', () => {
expect(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withNoXsrfProtection(), withXsrfConfiguration({})),
provideHttpClientTesting(),
{provide: PLATFORM_ID, useValue: 'test'},
],
});
}).toThrow();
});
});
describe('JSONP support', () => {
it('should not be enabled by default', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
TestBed.inject(HttpClient).jsonp('/test', 'callback').subscribe();
// Because no JSONP interceptor should be registered, this request should go to the testing
// backend.
TestBed.inject(HttpTestingController).expectOne('/test?callback=JSONP_CALLBACK').flush('');
});
it('should be enabled when using withJsonpSupport()', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withJsonpSupport()),
provideHttpClientTesting(),
FAKE_JSONP_BACKEND_PROVIDER,
],
});
TestBed.inject(HttpClient).jsonp('/test', 'callback').subscribe();
TestBed.inject(HttpTestingController).expectNone('/test?callback=JSONP_CALLBACK');
});
});
describe('withRequestsMadeViaParent()', () => {
it('should have independent HTTP setups if not explicitly specified', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const child = createEnvironmentInjector(
[
provideHttpClient(),
{
provide: XhrFactory,
useValue: {
build: () => {
throw new Error('Request reached the "backend".');
},
},
},
],
TestBed.inject(EnvironmentInjector),
);
// Because `child` is an entirely independent HTTP context, it is not connected to the
// HTTP testing backend from the parent injector, and requests attempted via the child's
// `HttpClient` will fail.
await expectAsync(child.get(HttpClient).get('/test').toPromise()).toBeRejected();
});
it('should connect child to parent configuration if specified', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const child = createEnvironmentInjector(
[provideHttpClient(withRequestsMadeViaParent())],
TestBed.inject(EnvironmentInjector),
);
// `child` is now to the parent HTTP context and therefore the testing backend, and so a
// request made via its `HttpClient` can be made.
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
req.flush('');
});
it('should include interceptors from both parent and child contexts', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([makeLiteralTagInterceptorFn('parent')])),
provideHttpClientTesting(),
],
});
const child = createEnvironmentInjector(
[
provideHttpClient(
withRequestsMadeViaParent(),
withInterceptors([makeLiteralTagInterceptorFn('child')]),
),
],
TestBed.inject(EnvironmentInjector),
);
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('child,parent');
req.flush('');
});
it('should be able to connect to a legacy-provided HttpClient context', () => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [provideLegacyInterceptor('parent')],
});
const child = createEnvironmentInjector(
[
provideHttpClient(
withRequestsMadeViaParent(),
withInterceptors([makeLiteralTagInterceptorFn('child')]),
),
],
TestBed.inject(EnvironmentInjector),
);
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('child,parent');
req.flush('');
});
});
describe('compatibility with Http NgModules', () => {
it('should function when configuring HTTP both ways in the same injector', () => {
TestBed.configureTestingModule({
imports: [HttpClientModule],
providers: [
provideHttpClient(),
// Interceptor support from HttpClientModule should be functional
provideLegacyInterceptor('alpha'),
provideLegacyInterceptor('beta'),
provideHttpClientTesting(),
],
});
TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();
const req = TestBed.inject(HttpTestingController).expectOne('/test');
expect(req.request.headers.get('X-Tag')).toEqual('alpha,beta');
req.flush('');
});
});
describe('fetch support', () => {
it('withFetch', () => {
resetFetchBackendWarningFlag();
const consoleWarnSpy = spyOn(console, 'warn');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
// Setting this flag to verify that there are no
// `console.warn` produced for cases when `fetch`
// is enabled and we are running in a browser.
{provide: PLATFORM_ID, useValue: 'browser'},
provideHttpClient(withFetch()),
],
});
const fetchBackend = TestBed.inject(HttpBackend);
expect(fetchBackend).toBeInstanceOf(FetchBackend);
// Make sure there are no warnings produced.
expect(consoleWarnSpy.calls.count()).toBe(0);
});
it('withFetch should always override the backend', () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withFetch()),
// This emulates a situation when `provideHttpClient()` is used
// later in a different part of an app. We want to make sure that
// the `FetchBackend` is enabled in that case as well.
{provide: HttpBackend, useClass: HttpXhrBackend},
],
});
const handler = TestBed.inject(HttpHandler);
expect((handler as any).backend).toBeInstanceOf(FetchBackend);
});
it('should not warn if fetch is not configured when running in a browser', () => {
resetFetchBackendWarningFlag();
const consoleWarnSpy = spyOn(console, 'warn');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
// Setting this flag to verify that there are no
// `console.warn` produced for cases when `fetch`
// is enabled and we are running in a browser.
{provide: PLATFORM_ID, useValue: 'browser'},
provideHttpClient(),
],
});
TestBed.inject(HttpHandler);
// Make sure there are no warnings produced.
expect(consoleWarnSpy.calls.count()).toBe(0);
});
it('should warn during SSR if fetch is not configured', () => {
resetFetchBackendWarningFlag();
const consoleWarnSpy = spyOn(console, 'warn');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
// Setting this flag to verify that there is a
// `console.warn` produced in case `fetch` is not
// enabled while running code on the server.
{provide: PLATFORM_ID, useValue: 'server'},
provideHttpClient(),
],
});
TestBed.inject(HttpHandler);
expect(consoleWarnSpy.calls.count()).toBe(1);
expect(consoleWarnSpy.calls.argsFor(0)[0]).toContain(
'NG02801: Angular detected that `HttpClient` is not configured to use `fetch` APIs.',
);
});
});
});
function setXsrfToken(token: string): void {
setCookie(`XSRF-TOKEN=${token}`);
}
function setCookie(cookie: string): void {
Object.defineProperty(TestBed.inject(DOCUMENT), 'cookie', {
get: () => cookie,
configurable: true,
});
}
function provideLegacyInterceptor(tag: string): Provider {
class LegacyTagInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(addTagToRequest(req, tag));
}
}
const token = new InjectionToken(`LegacyTagInterceptor[${tag}]`, {
providedIn: 'root',
factory: () => new LegacyTagInterceptor(),
});
return {
provide: HTTP_INTERCEPTORS,
useExisting: token,
multi: true,
};
}
function makeLiteralTagInterceptorFn(tag: string): HttpInterceptorFn {
return (req, next) => next(addTagToRequest(req, tag));
}
function makeTokenTagInterceptorFn(tag: InjectionToken<string>): HttpInterceptorFn {
return (req, next) => next(addTagToRequest(req, inject(tag)));
}
function addTagToRequest(req: HttpRequest<unknown>, tag: string): HttpRequest<unknown> {
const prevTagHeader = req.headers.get('X-Tag') ?? '';
const tagHeader = prevTagHeader.length > 0 ? prevTagHeader + ',' + tag : tag;
return req.clone({
setHeaders: {
'X-Tag': tagHeader,
},
});
}
const FAKE_JSONP_BACKEND_PROVIDER = {
provide: JsonpClientBackend,
useValue: {
handle: (req: HttpRequest<never>) => EMPTY,
},
};