angular/packages/common/http/test/fetch_spec.ts
Charles Lyding e149ebf228 build: update rxjs build version to v7 (#53500)
The version of rxjs used to build the repository has been updated to v7.
This required only minimal changes to the code. Most of which were type
related only due to more strict types in v7. The behavior in those cases
was left intact. The most common type related change was to handle the
possibility of `undefined` with `toPromise` which was always possible with
v6 but the types did not reflect the runtime behavior. The one change that
was not type related was to provide a parameter value to the `defaultIfEmpty`
operator. It no longer defaults to a value of `null` if no default is provided.
To provide the same behavior the value of `null` is now passed to the operator.

PR Close #53500
2023-12-18 16:25:37 +00:00

508 lines
18 KiB
TypeScript

/**
* @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.io/license
*/
import {HttpEvent, HttpEventType, HttpRequest, HttpResponse} from '@angular/common/http';
import {TestBed} from '@angular/core/testing';
import {Observable, of, Subject} from 'rxjs';
import {catchError, retry, scan, skip, take, toArray} from 'rxjs/operators';
import {HttpDownloadProgressEvent, HttpErrorResponse, HttpHeaderResponse, HttpParams, HttpStatusCode} from '../public_api';
import {FetchBackend, FetchFactory} from '../src/fetch';
function trackEvents(obs: Observable<any>): Promise<any[]> {
return obs.pipe(
// We don't want the promise to fail on HttpErrorResponse
catchError((e) => of(e)),
scan(
(acc, event) => {
acc.push(event);
return acc;
},
[] as any[]),
)
.toPromise() as Promise<any[]>;
}
const TEST_POST = new HttpRequest('POST', '/test', 'some body', {
responseType: 'text',
});
const TEST_POST_WITH_JSON_BODY = new HttpRequest('POST', '/test', {'some': 'body'}, {
responseType: 'text',
});
const XSSI_PREFIX = ')]}\'\n';
describe('FetchBackend', async () => {
let fetchMock: MockFetchFactory = null!;
let backend: FetchBackend = null!;
let fetchSpy: jasmine.Spy<typeof fetch>;
function callFetchAndFlush(req: HttpRequest<any>): void {
backend.handle(req).pipe(take(1)).subscribe();
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'some response');
}
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{provide: FetchFactory, useClass: MockFetchFactory},
FetchBackend,
]
});
fetchMock = TestBed.inject(FetchFactory) as MockFetchFactory;
fetchSpy = spyOn(fetchMock, 'fetch').and.callThrough();
backend = TestBed.inject(FetchBackend);
});
it('emits status immediately', () => {
let event!: HttpEvent<any>;
// subscribe is sync
backend.handle(TEST_POST).pipe(take(1)).subscribe((e) => event = e);
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'some response');
expect(event.type).toBe(HttpEventType.Sent);
});
it('should not call fetch without a subscribe', () => {
const handle = backend.handle(TEST_POST);
expect(fetchSpy).not.toHaveBeenCalled();
handle.subscribe();
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'some response');
expect(fetchSpy).toHaveBeenCalled();
});
it('should be able to retry', ((done) => {
const handle = backend.handle(TEST_POST);
// Skipping both HttpSentEvent (from the 1st subscription + retry)
handle.pipe(retry(1), skip(2)).subscribe((response) => {
expect(response.type).toBe(HttpEventType.Response);
expect((response as HttpResponse<any>).body).toBe('some response');
done();
});
fetchMock.mockErrorEvent('Error 1');
fetchMock.resetFetchPromise();
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'some response');
}));
it('sets method, url, and responseType correctly', () => {
callFetchAndFlush(TEST_POST);
expect(fetchMock.request.method).toBe('POST');
expect(fetchMock.request.url).toBe('/test');
});
it('use query params from request', () => {
const requestWithQuery = new HttpRequest('GET', '/test', 'some body', {
params: new HttpParams({fromObject: {query: 'foobar'}}),
responseType: 'text',
});
callFetchAndFlush(requestWithQuery);
expect(fetchMock.request.method).toBe('GET');
expect(fetchMock.request.url).toBe('/test?query=foobar');
});
it('sets outgoing body correctly', () => {
callFetchAndFlush(TEST_POST);
expect(fetchMock.request.body).toBe('some body');
});
it('sets outgoing body correctly when request payload is json', () => {
callFetchAndFlush(TEST_POST_WITH_JSON_BODY);
expect(fetchMock.request.body).toBe('{"some":"body"}');
});
it('sets outgoing headers, including default headers', () => {
const post = TEST_POST.clone({
setHeaders: {
'Test': 'Test header',
},
});
callFetchAndFlush(post);
expect(fetchMock.request.headers).toEqual({
'Test': 'Test header',
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'text/plain',
});
});
it('sets outgoing headers, including overriding defaults', () => {
const setHeaders = {
'Test': 'Test header',
'Accept': 'text/html',
'Content-Type': 'text/css',
};
callFetchAndFlush(TEST_POST.clone({setHeaders}));
expect(fetchMock.request.headers).toEqual(setHeaders);
});
it('passes withCredentials through', () => {
callFetchAndFlush(TEST_POST.clone({withCredentials: true}));
expect(fetchMock.request.credentials).toBe('include');
});
it('handles a text response', (async () => {
const promise = trackEvents(backend.handle(TEST_POST));
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'some response');
const events = await promise;
expect(events.length).toBe(2);
expect(events[1].type).toBe(HttpEventType.Response);
expect(events[1] instanceof HttpResponse).toBeTruthy();
const res = events[1] as HttpResponse<string>;
expect(res.body).toBe('some response');
expect(res.status).toBe(HttpStatusCode.Ok);
expect(res.statusText).toBe('OK');
}));
it('handles a json response', async () => {
const promise = trackEvents(backend.handle(TEST_POST.clone({responseType: 'json'})));
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', JSON.stringify({data: 'some data'}));
const events = await promise;
expect(events.length).toBe(2);
const res = events[1] as HttpResponse<{data: string}>;
expect(res.body!.data).toBe('some data');
});
it('handles a blank json response', async () => {
const promise = trackEvents(backend.handle(TEST_POST.clone({responseType: 'json'})));
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', '');
const events = await promise;
expect(events.length).toBe(2);
const res = events[1] as HttpResponse<{data: string}>;
expect(res.body).toBeNull();
});
it('handles a json error response', async () => {
const promise = trackEvents(backend.handle(TEST_POST.clone({responseType: 'json'})));
fetchMock.mockFlush(
HttpStatusCode.InternalServerError, 'Error', JSON.stringify({data: 'some data'}));
const events = await promise;
expect(events.length).toBe(2);
const res = events[1] as any as HttpErrorResponse;
expect(res.error.data).toBe('some data');
});
it('handles a json error response with XSSI prefix', async () => {
const promise = trackEvents(backend.handle(TEST_POST.clone({responseType: 'json'})));
fetchMock.mockFlush(
HttpStatusCode.InternalServerError, 'Error',
XSSI_PREFIX + JSON.stringify({data: 'some data'}));
const events = await promise;
expect(events.length).toBe(2);
const res = events[1] as any as HttpErrorResponse;
expect(res.error.data).toBe('some data');
});
it('handles a json string response', async () => {
const promise = trackEvents(backend.handle(TEST_POST.clone({responseType: 'json'})));
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', JSON.stringify('this is a string'));
const events = await promise;
expect(events.length).toBe(2);
const res = events[1] as HttpResponse<string>;
expect(res.body).toEqual('this is a string');
});
it('handles a json response with an XSSI prefix', async () => {
const promise = trackEvents(backend.handle(TEST_POST.clone({responseType: 'json'})));
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', XSSI_PREFIX + JSON.stringify({data: 'some data'}));
const events = await promise;
expect(events.length).toBe(2);
const res = events[1] as HttpResponse<{data: string}>;
expect(res.body!.data).toBe('some data');
});
it('handles a blob with a mime type', async () => {
const promise = trackEvents(backend.handle(TEST_POST.clone({responseType: 'blob'})));
const type = 'aplication/pdf';
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', new Blob(), {'Content-Type': type});
const events = await promise;
expect(events.length).toBe(2);
const res = events[1] as HttpResponse<Blob>;
expect(res.body?.type).toBe(type);
});
it('emits unsuccessful responses via the error path', done => {
backend.handle(TEST_POST).subscribe({
error: (err: HttpErrorResponse) => {
expect(err instanceof HttpErrorResponse).toBe(true);
expect(err.error).toBe('this is the error');
done();
}
});
fetchMock.mockFlush(HttpStatusCode.BadRequest, 'Bad Request', 'this is the error');
});
it('emits real errors via the error path', done => {
// Skipping the HttpEventType.Sent that is sent first
backend.handle(TEST_POST).pipe(skip(1)).subscribe({
error: (err: HttpErrorResponse) => {
expect(err instanceof HttpErrorResponse).toBe(true);
expect(err.error instanceof Error).toBeTrue();
expect(err.url).toBe('/test');
done();
}
});
fetchMock.mockErrorEvent(new Error('blah'));
});
it('emits an error when browser cancels a request', done => {
backend.handle(TEST_POST).subscribe({
error: (err: HttpErrorResponse) => {
expect(err instanceof HttpErrorResponse).toBe(true);
expect(err.error instanceof DOMException).toBeTruthy();
done();
}
});
fetchMock.mockAbortEvent();
});
describe('progress events', () => {
it('are emitted for download progress', done => {
backend.handle(TEST_POST.clone({reportProgress: true})).pipe(toArray()).subscribe(events => {
expect(events.map(event => event.type)).toEqual([
HttpEventType.Sent,
HttpEventType.ResponseHeader,
HttpEventType.DownloadProgress,
HttpEventType.DownloadProgress,
HttpEventType.DownloadProgress,
HttpEventType.Response,
]);
const [progress1, progress2, response] = [
events[2] as HttpDownloadProgressEvent, events[3] as HttpDownloadProgressEvent,
events[5] as HttpResponse<string>
];
expect(progress1.partialText).toBe('down');
expect(progress1.loaded).toBe(4);
expect(progress1.total).toBe(10);
expect(progress2.partialText).toBe('download');
expect(progress2.loaded).toBe(8);
expect(progress2.total).toBe(10);
expect(response.body).toBe('downloaded');
done();
});
fetchMock.mockProgressEvent(4);
fetchMock.mockProgressEvent(8);
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'downloaded');
});
it('include ResponseHeader with headers and status', done => {
backend.handle(TEST_POST.clone({reportProgress: true})).pipe(toArray()).subscribe(events => {
expect(events.map(event => event.type)).toEqual([
HttpEventType.Sent,
HttpEventType.ResponseHeader,
HttpEventType.DownloadProgress,
HttpEventType.DownloadProgress,
HttpEventType.Response,
]);
const partial = events[1] as HttpHeaderResponse;
expect(partial.type).toEqual(HttpEventType.ResponseHeader);
expect(partial.headers.get('Content-Type')).toEqual('text/plain');
expect(partial.headers.get('Test')).toEqual('Test header');
done();
});
fetchMock.response.headers = {'Test': 'Test header', 'Content-Type': 'text/plain'};
fetchMock.mockProgressEvent(200);
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'Done');
});
});
describe('gets response URL', async () => {
it('from the response URL', done => {
backend.handle(TEST_POST).pipe(toArray()).subscribe(events => {
expect(events.length).toBe(2);
expect(events[1].type).toBe(HttpEventType.Response);
const response = events[1] as HttpResponse<string>;
expect(response.url).toBe('/response/url');
done();
});
fetchMock.response.url = '/response/url';
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'Test');
});
it('from X-Request-URL header if the response URL is not present', done => {
backend.handle(TEST_POST).pipe(toArray()).subscribe(events => {
expect(events.length).toBe(2);
expect(events[1].type).toBe(HttpEventType.Response);
const response = events[1] as HttpResponse<string>;
expect(response.url).toBe('/response/url');
done();
});
fetchMock.response.headers = {'X-Request-URL': '/response/url'};
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'Test');
});
it('falls back on Request.url if neither are available', done => {
backend.handle(TEST_POST).pipe(toArray()).subscribe(events => {
expect(events.length).toBe(2);
expect(events[1].type).toBe(HttpEventType.Response);
const response = events[1] as HttpResponse<string>;
expect(response.url).toBe('/test');
done();
});
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK', 'Test');
});
});
describe('corrects for quirks', async () => {
it('by normalizing 0 status to 200 if a body is present', done => {
backend.handle(TEST_POST).pipe(toArray()).subscribe(events => {
expect(events.length).toBe(2);
expect(events[1].type).toBe(HttpEventType.Response);
const response = events[1] as HttpResponse<string>;
expect(response.status).toBe(HttpStatusCode.Ok);
done();
});
fetchMock.mockFlush(0, 'CORS 0 status', 'Test');
});
it('by leaving 0 status as 0 if a body is not present', done => {
backend.handle(TEST_POST).pipe(toArray()).subscribe({
error: (error: HttpErrorResponse) => {
expect(error.status).toBe(0);
done();
}
});
fetchMock.mockFlush(0, 'CORS 0 status');
});
});
});
export class MockFetchFactory extends FetchFactory {
public readonly response = new MockFetchResponse();
public readonly request = new MockFetchRequest();
private resolve!: Function;
private reject!: Function;
private clearWarningTimeout?: VoidFunction;
private promise = new Promise<Response>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
override fetch = (input: RequestInfo|URL, init?: RequestInit):
Promise<Response> => {
this.request.method = init?.method;
this.request.url = input;
this.request.body = init?.body;
this.request.headers = init?.headers;
this.request.credentials = init?.credentials;
if (init?.signal) {
init?.signal.addEventListener('abort', () => {
this.reject();
this.clearWarningTimeout?.();
});
}
// Fetch uses a Macrotask to keep the NgZone unstable during the fetch
// If the promise is not resolved/rejected the unit will succeed but the test suite will
// fail with a timeout
const timeoutId = setTimeout(() => {
console.error('********* You forgot to resolve/reject the promise ********* ');
this.reject();
}, 5000);
this.clearWarningTimeout = () => clearTimeout(timeoutId);
return this.promise;
}
mockFlush(
status: number, statusText: string, body?: string|Blob, headers?: Record<string, string>):
void {
this.clearWarningTimeout?.();
if (typeof body === 'string') {
this.response.setupBodyStream(body);
} else {
this.response.setBody(body);
}
const response = new Response(
this.response.stream,
{statusText, headers: {...this.response.headers, ...(headers ?? {})}});
// Have to be set outside the constructor because it might throw
// RangeError: init["status"] must be in the range of 200 to 599, inclusive
Object.defineProperty(response, 'status', {value: status});
if (this.response.url) {
// url is readonly
Object.defineProperty(response, 'url', {value: this.response.url});
}
this.resolve(response);
}
mockProgressEvent(loaded: number): void {
this.response.progress.push(loaded);
}
mockErrorEvent(error: any) {
this.reject(error);
}
mockAbortEvent() {
// When `abort()` is called, the fetch() promise rejects with an Error of type DOMException,
// with name AbortError. see
// https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
this.reject(new DOMException('', 'AbortError'));
}
resetFetchPromise() {
this.promise = new Promise<Response>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
class MockFetchRequest {
public url!: RequestInfo|URL;
public method?: string;
public body: any;
public credentials?: RequestCredentials;
public headers?: HeadersInit;
}
class MockFetchResponse {
public url?: string;
public headers: Record<string, string> = {};
public progress: number[] = [];
private sub$ = new Subject<any>();
public stream = new ReadableStream({
start: (controller) => {
this.sub$.subscribe({
next: (val) => {
controller.enqueue(new TextEncoder().encode(val));
},
complete: () => {
controller.close();
}
});
},
});
public setBody(body: any) {
this.sub$.next(body);
this.sub$.complete();
}
public setupBodyStream(body?: string) {
if (body && this.progress.length) {
this.headers['content-length'] = `${body.length}`;
let shift = 0;
this.progress.forEach((loaded) => {
this.sub$.next(body.substring(shift, loaded));
shift = loaded;
});
this.sub$.next(body.substring(shift, body.length));
} else {
this.sub$.next(body);
}
this.sub$.complete();
}
}