blob: a212e9de849ab200bee4ad49044113a9e5d5442e [file] [log] [blame]
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../../test/common-test-setup';
import {
SiteBasedCache,
FetchPromisesCache,
GrRestApiHelper,
} from './gr-rest-api-helper';
import {
addListenerForTest,
assertFails,
waitEventLoop,
} from '../../../../test/test-utils';
import {FakeScheduler} from '../../../../services/scheduler/fake-scheduler';
import {RetryScheduler} from '../../../../services/scheduler/retry-scheduler';
import {HttpMethod} from '../../../../api/rest-api';
import {SinonFakeTimers} from 'sinon';
import {assert} from '@open-wc/testing';
import {AuthService} from '../../../../services/gr-auth/gr-auth';
import {GrAuthMock} from '../../../../services/gr-auth/gr-auth_mock';
import {ParsedJSON} from '../../../../types/common';
function makeParsedJSON<T>(val: T): ParsedJSON {
return val as unknown as ParsedJSON;
}
suite('gr-rest-api-helper tests', () => {
let clock: SinonFakeTimers;
let helper: GrRestApiHelper;
let cache: SiteBasedCache;
let fetchPromisesCache: FetchPromisesCache;
let originalCanonicalPath: string | undefined;
let authFetchStub: sinon.SinonStub;
let readScheduler: FakeScheduler<Response>;
let writeScheduler: FakeScheduler<Response>;
let authService: AuthService;
setup(() => {
clock = sinon.useFakeTimers();
cache = new SiteBasedCache();
fetchPromisesCache = new FetchPromisesCache();
originalCanonicalPath = window.CANONICAL_PATH;
window.CANONICAL_PATH = 'testhelper';
const testJSON = ')]}\'\n{"hello": "bonjour"}';
authService = new GrAuthMock();
authFetchStub = sinon.stub(authService, 'fetch').returns(
Promise.resolve({
...new Response(),
ok: true,
text() {
return Promise.resolve(testJSON);
},
})
);
readScheduler = new FakeScheduler<Response>();
writeScheduler = new FakeScheduler<Response>();
helper = new GrRestApiHelper(
cache,
authService,
fetchPromisesCache,
readScheduler,
writeScheduler
);
});
teardown(() => {
window.CANONICAL_PATH = originalCanonicalPath;
});
async function assertReadRequest() {
assert.equal(readScheduler.scheduled.length, 1);
await readScheduler.resolve();
await waitEventLoop();
}
async function assertWriteRequest() {
assert.equal(writeScheduler.scheduled.length, 1);
await writeScheduler.resolve();
await waitEventLoop();
}
suite('fetch()', () => {
setup(() => {
authFetchStub.returns(
Promise.resolve({
...new Response(),
ok: true,
text() {
return Promise.resolve('Yay');
},
})
);
});
test('GET are sent to readScheduler', async () => {
const promise = helper.fetch({
fetchOptions: {
method: HttpMethod.GET,
},
url: '/dummy/url',
});
assert.equal(writeScheduler.scheduled.length, 0);
await assertReadRequest();
const res: Response = await promise;
assert.equal(await res.text(), 'Yay');
});
test('PUT are sent to writeScheduler', async () => {
const promise = helper.fetch({
fetchOptions: {
method: HttpMethod.PUT,
},
url: '/dummy/url',
});
assert.equal(readScheduler.scheduled.length, 0);
await assertWriteRequest();
const res: Response = await promise;
assert.equal(await res.text(), 'Yay');
});
});
suite('fetchJSON()', () => {
test('Sets header to accept application/json', async () => {
helper.fetchJSON({url: '/dummy/url'});
assert.isFalse(authFetchStub.called);
await assertReadRequest();
assert.isTrue(authFetchStub.called);
assert.equal(
authFetchStub.lastCall.args[1].headers.get('Accept'),
'application/json'
);
});
test('Use header option accept when provided', async () => {
const headers = new Headers();
headers.append('Accept', '*/*');
const fetchOptions = {headers};
helper.fetchJSON({url: '/dummy/url', fetchOptions});
assert.isFalse(authFetchStub.called);
await assertReadRequest();
assert.isTrue(authFetchStub.called);
assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'), '*/*');
});
test('JSON prefix is properly removed', async () => {
const promise = helper.fetchJSON({url: '/dummy/url'});
await assertReadRequest();
const obj = await promise;
assert.deepEqual(obj, makeParsedJSON({hello: 'bonjour'}));
});
suite('error handling', () => {
let serverErrorCalled: boolean;
let networkErrorCalled: boolean;
setup(() => {
serverErrorCalled = false;
networkErrorCalled = false;
addListenerForTest(document, 'server-error', () => {
serverErrorCalled = true;
});
addListenerForTest(document, 'network-error', () => {
networkErrorCalled = true;
});
});
test('network error, promise rejects, event thrown', async () => {
authFetchStub.rejects(new Error('No response'));
const promise = helper.fetchJSON({url: '/dummy/url'});
await assertReadRequest();
const err = await assertFails(promise);
assert.equal((err as Error).message, 'No response');
await waitEventLoop();
assert.isTrue(networkErrorCalled);
assert.isFalse(serverErrorCalled);
});
test('network error, promise rejects, errFn called, no event', async () => {
const errFn = sinon.stub();
authFetchStub.rejects(new Error('No response'));
const promise = helper.fetchJSON({
url: '/dummy/url',
errFn,
});
await assertReadRequest();
const err = await assertFails(promise);
assert.equal((err as Error).message, 'No response');
await waitEventLoop();
assert.isTrue(errFn.called);
assert.isFalse(networkErrorCalled);
assert.isFalse(serverErrorCalled);
});
test('server error, promise resolves undefined, event thrown', async () => {
authFetchStub.returns(
Promise.resolve({
...new Response(),
status: 400,
ok: false,
text() {
return Promise.resolve('Nope');
},
})
);
const promise = helper.fetchJSON({url: '/dummy/url'});
await assertReadRequest();
const resp = await promise;
assert.isUndefined(resp);
await waitEventLoop();
assert.isFalse(networkErrorCalled);
assert.isTrue(serverErrorCalled);
});
test('server error, promise resolves undefined, errFn called, no event', async () => {
authFetchStub.returns(
Promise.resolve({
...new Response(),
status: 400,
ok: false,
text() {
return Promise.resolve('Nope');
},
})
);
const errFn = sinon.stub();
const promise = helper.fetchJSON({url: '/dummy/url', errFn});
await assertReadRequest();
const resp = await promise;
assert.isUndefined(resp);
await waitEventLoop();
assert.isTrue(errFn.called);
assert.isFalse(networkErrorCalled);
assert.isFalse(serverErrorCalled);
});
test('parsing error, promise rejects', async () => {
authFetchStub.returns(
Promise.resolve({
...new Response(),
ok: true,
text() {
return Promise.resolve('not a prefixed json');
},
})
);
const errFn = sinon.stub();
const promise = helper.fetchJSON({url: '/dummy/url', errFn});
await assertReadRequest();
await assertFails(promise);
await waitEventLoop();
assert.isFalse(errFn.called);
assert.isFalse(networkErrorCalled);
assert.isFalse(serverErrorCalled);
});
});
});
test('params are properly encoded', () => {
let url = helper.urlWithParams('/path/', {
sp: 'hola',
gr: 'guten tag',
noval: null,
novaltoo: undefined,
});
assert.equal(
url,
`${window.CANONICAL_PATH}/path/?sp=hola&gr=guten%20tag&noval&novaltoo`
);
url = helper.urlWithParams('/path/', {
sp: 'hola',
en: ['hey', 'hi'],
});
assert.equal(url, `${window.CANONICAL_PATH}/path/?sp=hola&en=hey&en=hi`);
// Order must be maintained with array params.
url = helper.urlWithParams('/path/', {
l: ['c', 'b', 'a'],
});
assert.equal(url, `${window.CANONICAL_PATH}/path/?l=c&l=b&l=a`);
});
suite('throwing in errFn', () => {
function throwInPromise(response?: Response | null, _?: Error) {
return response?.text().then(text => {
throw new Error(text);
});
}
function throwImmediately(_1?: Response | null, _2?: Error) {
throw new Error('Error Callback error');
}
setup(() => {
authFetchStub.returns(
Promise.resolve({
...new Response(),
status: 400,
ok: false,
text() {
return Promise.resolve('Nope');
},
})
);
});
test('errFn with Promise throw cause fetchJSON to reject on error', async () => {
const promise = helper.fetchJSON({
url: '/dummy/url',
errFn: throwInPromise,
});
await assertReadRequest();
const err = await assertFails(promise);
assert.equal((err as Error).message, 'Nope');
});
test('errFn with immediate throw cause fetchJSON to reject on error', async () => {
const promise = helper.fetchJSON({
url: '/dummy/url',
errFn: throwImmediately,
});
await assertReadRequest();
const err = await assertFails(promise);
assert.equal((err as Error).message, 'Error Callback error');
});
});
suite('429 errors', () => {
setup(() => {
authFetchStub.returns(
Promise.resolve({
...new Response(),
status: 429,
ok: false,
})
);
});
test('non-retry scheduler errFn is called on 429 error', async () => {
const errFn = sinon.stub();
const promise = helper.fetchJSON({
url: '/dummy/url',
errFn,
});
await assertReadRequest();
// But we expect the result from the network to return a 429 error when
// it's no longer being retried.
await promise;
assert.isTrue(errFn.called);
});
test('non-retry scheduler 429 error is returned without retrying', async () => {
const promise = helper.fetch({
url: '/dummy/url',
});
await assertReadRequest();
// With RetryScheduler we retry if the server returns response with 429
// status.
// If we are not using RetryScheduler the response with 429 should simply
// be returned from fetch without retrying.
const res: Response = await promise;
assert.equal(res.status, 429);
});
test('With RetryScheduler 429 errors are retried', async () => {
helper = new GrRestApiHelper(
cache,
authService,
fetchPromisesCache,
new RetryScheduler<Response>(readScheduler, 1, 50),
writeScheduler
);
const promise = helper.fetch({
url: '/dummy/url',
});
await assertReadRequest();
authFetchStub.returns(
Promise.resolve({
...new Response(),
ok: true,
text() {
return Promise.resolve('Yay');
},
})
);
// Flush the retry scheduler
clock.tick(50);
await waitEventLoop();
// We expect a retry.
await assertReadRequest();
const res: Response = await promise;
assert.equal(await res.text(), 'Yay');
});
});
});