import { EndpointError } from './errors.js';
import {
queryXmlDocument,
setFetchOptions,
setQueryParams,
sharedFetch,
} from './http-utils.js';
import { fetchDocument } from '../ogc-api/link-utils.js';
import WfsEndpoint from '../wfs/endpoint.js';
// @ts-expect-error ts-migrate(7016)
import capabilities200 from '../../fixtures/wfs/capabilities-pigma-2-0-0.xml';
const global = window as any;
jest.useFakeTimers();
afterEach(() => {
jest.clearAllMocks();
});
describe('HTTP utils', () => {
describe('queryXmlDocument', () => {
const sampleXml = '';
let fetchBehaviour:
| 'ok'
| 'httpError'
| 'corsError'
| 'networkError'
| 'delay';
let originalFetch;
beforeAll(() => {
fetchBehaviour = 'ok';
originalFetch = global.fetch; // keep reference of native impl
global.fetch = jest.fn().mockImplementation((xmlString, opts) => {
const noCors = opts && opts.mode === 'no-cors';
const headers = { get: () => null };
switch (fetchBehaviour) {
case 'ok':
return Promise.resolve({
arrayBuffer: () =>
Promise.resolve(Buffer.from(xmlString, 'utf-8')),
status: 200,
ok: true,
headers,
clone: function () {
return this;
},
});
case 'httpError':
return Promise.resolve({
text: () => Promise.resolve('Random error'),
status: 401,
ok: false,
clone: function () {
return this;
},
});
case 'corsError':
if (noCors)
return Promise.resolve({
status: 200,
ok: true,
});
return Promise.reject(new Error('Cross origin headers missing'));
case 'networkError':
return Promise.reject(new Error('General network error'));
case 'delay':
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
ok: true,
status: 200,
headers,
arrayBuffer: () =>
Promise.resolve(Buffer.from(sampleXml, 'utf-8')),
clone: function () {
return this;
},
}),
10
);
});
}
});
});
afterAll(() => {
global.fetch = originalFetch; // restore original impl
});
describe('HTTP request returns success', () => {
beforeEach(() => {
fetchBehaviour = 'ok';
});
it('resolves with the endpoint object', async () => {
await expect(queryXmlDocument(sampleXml)).resolves.toMatchObject({
children: [
{
children: expect.any(Array),
name: 'sample-xml',
isRootNode: true,
type: 'element',
},
],
type: 'document',
});
});
});
describe('HTTP request returns error', () => {
beforeEach(() => {
fetchBehaviour = 'httpError';
});
it('rejects with an error', async () => {
await expect(queryXmlDocument(sampleXml)).rejects.toEqual(
new EndpointError(
'Received an error with code 401: Random error',
401,
false
)
);
});
});
describe('HTTP fails for CORS reasons', () => {
beforeEach(() => {
fetchBehaviour = 'corsError';
});
it('rejects with an error', async () => {
await expect(queryXmlDocument(sampleXml)).rejects.toThrowError(
new EndpointError(
`The document could not be fetched due to CORS limitations`
)
);
});
});
describe('HTTP fails for network reasons', () => {
beforeEach(() => {
fetchBehaviour = 'networkError';
});
it('rejects with an error', async () => {
await expect(queryXmlDocument(sampleXml)).rejects.toEqual(
new EndpointError(
'Fetching the document failed either due to network errors or unreachable host, error is: General network error'
)
);
});
});
describe('HTTP succeeds but XML is malformed', () => {
beforeEach(() => {
fetchBehaviour = 'ok';
});
it('rejects with an error related to XML parsing', async () => {
await expect(
queryXmlDocument('')
).rejects.toThrowError('Unclosed start tag for element `broken-xml`');
});
});
describe('multiple similar HTTP requests in parallel', () => {
beforeEach(() => {
fetchBehaviour = 'delay';
queryXmlDocument('https://abcd.com');
queryXmlDocument('https://abcd.com');
queryXmlDocument('https://abcd.com');
});
it('only fetches the document once', async () => {
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
});
describe('setQueryParams', () => {
it('adds new parameters if not present', () => {
expect(
setQueryParams('https://my.host/service?arg1=123', {
ARG2: '45',
Arg3: 'hello',
})
).toBe('https://my.host/service?arg1=123&ARG2=45&Arg3=hello');
});
it('replaces existing parameters regardless of case', () => {
expect(
setQueryParams('https://my.host/service?ARG1=123&Arg2=bla&arg3', {
ARG2: '45',
Arg3: 'hello',
})
).toBe('https://my.host/service?ARG1=123&ARG2=45&Arg3=hello');
});
it('sets a parameter without value if true', () => {
expect(
setQueryParams('https://my.host/service', {
ARG2: true,
})
).toBe('https://my.host/service?ARG2=');
});
it('appends an encoded URL if found (HTTP)', () => {
expect(
setQueryParams('http://bad.proxy/?url=http%3A%2F%2Fmy.host%2Fservice', {
ARG2: '45',
Arg3: 'hello',
})
).toBe(
'http://bad.proxy/?url=http%3A%2F%2Fmy.host%2Fservice%3FARG2%3D45%26Arg3%3Dhello'
);
});
it('appends an encoded URL if found (HTTPS)', () => {
expect(
setQueryParams(
'http://bad.proxy/?url=https%3A%2F%2Fmy.host%2Fservice',
{
ARG2: '45',
Arg3: 'hello',
}
)
).toBe(
'http://bad.proxy/?url=https%3A%2F%2Fmy.host%2Fservice%3FARG2%3D45%26Arg3%3Dhello'
);
});
});
describe('sharedFetch', () => {
let originalFetch;
beforeAll(() => {
originalFetch = global.fetch; // keep reference of native impl
global.fetch = jest.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
// return a different result every time
resolve(`request result: ${Math.floor(Math.random() * 100000)}`);
}, 10);
})
);
});
afterAll(() => {
global.fetch = originalFetch; // restore original impl
});
describe('multiple GET and HEAD requests on same resource', () => {
let getResults, headResults;
beforeEach(async () => {
getResults = [];
headResults = [];
// these requests will be shared
sharedFetch('http://test.org/resource1').then(
(r) => (getResults[0] = r)
);
sharedFetch('http://test.org/resource1', 'HEAD').then(
(r) => (headResults[0] = r)
);
jest.advanceTimersByTime(2);
sharedFetch('http://test.org/resource1').then(
(r) => (getResults[1] = r)
);
sharedFetch('http://test.org/resource1', 'HEAD').then(
(r) => (headResults[1] = r)
);
jest.advanceTimersByTime(3);
sharedFetch('http://test.org/resource1').then(
(r) => (getResults[2] = r)
);
sharedFetch('http://test.org/resource1', 'HEAD').then(
(r) => (headResults[2] = r)
);
await jest.advanceTimersByTime(10);
await jest.runOnlyPendingTimers();
// first batch has resolved
sharedFetch('http://test.org/resource1', 'HEAD').then(
(r) => (headResults[3] = r)
);
sharedFetch('http://test.org/resource1').then(
(r) => (getResults[3] = r)
);
await jest.advanceTimersByTime(10);
await jest.runOnlyPendingTimers();
});
it('only triggers two GET requests and two HEAD requests', () => {
expect(global.fetch).toHaveBeenCalledTimes(4);
expect((global.fetch as jest.Mock).mock.calls).toEqual([
[
'http://test.org/resource1',
expect.objectContaining({ method: 'GET' }),
],
[
'http://test.org/resource1',
expect.objectContaining({ method: 'HEAD' }),
],
[
'http://test.org/resource1',
expect.objectContaining({ method: 'HEAD' }),
],
[
'http://test.org/resource1',
expect.objectContaining({ method: 'GET' }),
],
]);
});
it('shares result for simultaneous GET requests and not subsequent ones', () => {
const sharedResult = getResults[0];
expect(getResults).toEqual([
sharedResult,
sharedResult,
sharedResult,
expect.not.stringContaining(sharedResult),
]);
});
it('shares result for simultaneous HEAD requests and not subsequent ones', () => {
const sharedResult = headResults[0];
expect(headResults).toEqual([
sharedResult,
sharedResult,
sharedResult,
expect.not.stringContaining(sharedResult),
]);
});
});
describe('GET and HEAD requests on different resources', () => {
let getResults, headResults;
beforeEach(async () => {
getResults = [];
headResults = [];
// these requests will be shared
sharedFetch('http://test.org/resource1').then(
(r) => (getResults[0] = r)
);
sharedFetch('http://test.org/resource2', 'HEAD').then(
(r) => (headResults[0] = r)
);
sharedFetch('http://test.org/resource3').then(
(r) => (getResults[1] = r)
);
sharedFetch('http://test.org/resource4', 'HEAD').then(
(r) => (headResults[1] = r)
);
await jest.advanceTimersByTime(40);
await jest.runOnlyPendingTimers();
});
it('triggers two GET requests and two HEAD requests', () => {
expect(global.fetch).toHaveBeenCalledTimes(4);
expect((global.fetch as jest.Mock).mock.calls).toEqual([
[
'http://test.org/resource1',
expect.objectContaining({ method: 'GET' }),
],
[
'http://test.org/resource2',
expect.objectContaining({ method: 'HEAD' }),
],
[
'http://test.org/resource3',
expect.objectContaining({ method: 'GET' }),
],
[
'http://test.org/resource4',
expect.objectContaining({ method: 'HEAD' }),
],
]);
});
it('does not share GET results', () => {
expect(getResults[0]).not.toBe(getResults[1]);
});
it('does not share HEAD results', () => {
expect(headResults[0]).not.toBe(headResults[1]);
});
});
});
describe('fetch options', () => {
const sampleOptions = {
referrer: 'abcd',
mode: 'cors',
headers: { hello: 'world' },
credentials: 'include',
integrity: 'abcdefg',
redirect: 'follow',
} as const;
describe('used in queryXmlDocument', () => {
beforeEach(() => {
setFetchOptions(sampleOptions);
queryXmlDocument('./hello.xml');
});
it('is used in the fetch() call', () => {
expect(global.fetch).toHaveBeenCalledWith('./hello.xml', {
...sampleOptions,
method: 'GET',
});
});
});
describe('used in sharedFetch', () => {
beforeEach(() => {
setFetchOptions(sampleOptions);
sharedFetch('./hello.xml', 'HEAD');
});
it('is used in the fetch() call', () => {
expect(global.fetch).toHaveBeenCalledWith('./hello.xml', {
...sampleOptions,
method: 'HEAD',
});
});
});
describe('used in ogc-api fetchDocument', () => {
beforeEach(() => {
setFetchOptions(sampleOptions);
global.fetchResponseFactory = () => '{ "hello": "world" }';
fetchDocument('./hello.json');
});
it('is used in the fetch() call', () => {
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost/hello.json?f=json',
{
...sampleOptions,
method: 'GET',
headers: {
...sampleOptions.headers,
Accept: 'application/json',
},
}
);
});
});
describe('used in worker', () => {
let endpoint;
beforeEach(() => {
global.fetchResponseFactory = () => capabilities200;
setFetchOptions(sampleOptions);
endpoint = new WfsEndpoint(
'https://my.test.service/ogc/wfs?service=wfs&request=DescribeFeatureType'
);
});
it('is used in the fetch() call', async () => {
await endpoint.isReady();
expect(global.fetch).toHaveBeenCalledWith(
'https://my.test.service/ogc/wfs?SERVICE=WFS&REQUEST=GetCapabilities',
{
...sampleOptions,
method: 'GET',
}
);
});
});
});
});