589 lines
17 KiB
JavaScript
589 lines
17 KiB
JavaScript
import { expect } from '@open-wc/testing';
|
|
import * as sinon from 'sinon';
|
|
import '../../src/typedef.js';
|
|
import { Ajax } from '../../index.js';
|
|
import { extendCacheOptions, resetCacheSession, ajaxCache } from '../../src/cacheManager.js';
|
|
import { createCacheInterceptors } from '../../src/interceptors/cacheInterceptors.js';
|
|
|
|
/** @type {Ajax} */
|
|
let ajax;
|
|
|
|
describe('cache interceptors', () => {
|
|
/**
|
|
* @param {number | undefined} timeout
|
|
* @param {number} i
|
|
*/
|
|
const returnResponseOnTick = (timeout, i) =>
|
|
new Promise(resolve =>
|
|
window.setTimeout(() => resolve(new Response(`mock response ${i}`)), timeout),
|
|
);
|
|
|
|
/** @type {number | undefined} */
|
|
let cacheId;
|
|
/** @type {sinon.SinonStub} */
|
|
let fetchStub;
|
|
const getCacheIdentifier = () => String(cacheId);
|
|
/** @type {sinon.SinonSpy} */
|
|
let ajaxRequestSpy;
|
|
|
|
const newCacheId = () => {
|
|
if (!cacheId) {
|
|
cacheId = 1;
|
|
} else {
|
|
cacheId += 1;
|
|
}
|
|
return cacheId;
|
|
};
|
|
|
|
/**
|
|
* @param {Ajax} ajaxInstance
|
|
* @param {CacheOptions} options
|
|
*/
|
|
const addCacheInterceptors = (ajaxInstance, options) => {
|
|
const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
|
|
getCacheIdentifier,
|
|
options,
|
|
);
|
|
|
|
ajaxInstance._requestInterceptors.push(cacheRequestInterceptor);
|
|
ajaxInstance._responseInterceptors.push(cacheResponseInterceptor);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
ajax = new Ajax();
|
|
fetchStub = sinon.stub(window, 'fetch');
|
|
fetchStub.returns(Promise.resolve(new Response('mock response')));
|
|
ajaxRequestSpy = sinon.spy(ajax, 'fetch');
|
|
});
|
|
|
|
afterEach(() => {
|
|
sinon.restore();
|
|
});
|
|
|
|
describe('Original ajax instance', () => {
|
|
it('allows direct ajax calls without cache interceptors configured', async () => {
|
|
await ajax.fetch('/test');
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
await ajax.fetch('/test');
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
});
|
|
});
|
|
|
|
describe('Cache config validation', () => {
|
|
it('validates `useCache`', () => {
|
|
newCacheId();
|
|
const test = () => {
|
|
addCacheInterceptors(ajax, {
|
|
// @ts-ignore needed for test
|
|
useCache: 'fakeUseCacheType',
|
|
});
|
|
};
|
|
expect(test).to.throw();
|
|
});
|
|
|
|
it('validates property `maxAge` throws if not type `number`', () => {
|
|
newCacheId();
|
|
expect(() => {
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
// @ts-ignore needed for test
|
|
maxAge: '',
|
|
});
|
|
}).to.throw();
|
|
});
|
|
|
|
it('validates cache identifier function', async () => {
|
|
const cacheSessionId = cacheId;
|
|
// @ts-ignore needed for test
|
|
cacheId = '';
|
|
|
|
addCacheInterceptors(ajax, { useCache: true });
|
|
await ajax
|
|
.fetch('/test')
|
|
.then(() => expect.fail('fetch should not resolve here'))
|
|
.catch(
|
|
/** @param {Error} err */ err => {
|
|
expect(err.message).to.equal('Invalid cache identifier');
|
|
},
|
|
)
|
|
.finally(() => {});
|
|
cacheId = cacheSessionId;
|
|
});
|
|
|
|
it("throws when using methods other than `['get']`", () => {
|
|
newCacheId();
|
|
|
|
expect(() => {
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
methods: ['get', 'post'],
|
|
});
|
|
}).to.throw(/Cache can only be utilized with `GET` method/);
|
|
});
|
|
|
|
it('throws error when requestIdFunction is not a function', () => {
|
|
newCacheId();
|
|
|
|
expect(() => {
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
// @ts-ignore needed for test
|
|
requestIdFunction: 'not a function',
|
|
});
|
|
}).to.throw(/Property `requestIdFunction` must be a `function`/);
|
|
});
|
|
});
|
|
|
|
describe('Cached responses', () => {
|
|
it('returns the cached object on second call with `useCache: true`', async () => {
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 100,
|
|
});
|
|
|
|
await ajax.fetch('/test');
|
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
await ajax.fetch('/test');
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
});
|
|
|
|
it('all calls are cached proactively', async () => {
|
|
// Given
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: false,
|
|
});
|
|
|
|
// When
|
|
await ajax.fetch('/test');
|
|
|
|
// Then
|
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
// When
|
|
await ajax.fetch('/test');
|
|
|
|
// Then
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
|
|
// When
|
|
await ajax.fetch('/test', {
|
|
cacheOptions: {
|
|
useCache: true,
|
|
},
|
|
});
|
|
|
|
// Then
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
});
|
|
|
|
it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => {
|
|
// Given
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 100,
|
|
});
|
|
|
|
// When
|
|
await ajax.fetch('/test', {
|
|
params: {
|
|
q: 'test',
|
|
page: 1,
|
|
},
|
|
});
|
|
|
|
// Then
|
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
// When
|
|
await ajax.fetch('/test', {
|
|
params: {
|
|
q: 'test',
|
|
page: 1,
|
|
},
|
|
});
|
|
|
|
// Then
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
// a request with different param should not be cached
|
|
|
|
// When
|
|
await ajax.fetch('/test', {
|
|
params: {
|
|
q: 'test',
|
|
page: 2,
|
|
},
|
|
});
|
|
|
|
// Then
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
});
|
|
|
|
it('uses cache when inside `maxAge: 5000` window', async () => {
|
|
newCacheId();
|
|
const clock = sinon.useFakeTimers({
|
|
shouldAdvanceTime: true,
|
|
});
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 5000,
|
|
});
|
|
|
|
await ajax.fetch('/test');
|
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
clock.tick(4900);
|
|
await ajax.fetch('/test');
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
clock.tick(5100);
|
|
await ajax.fetch('/test');
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
clock.restore();
|
|
});
|
|
|
|
it('uses custom requestIdFunction when passed', async () => {
|
|
newCacheId();
|
|
|
|
const customRequestIdFn = /** @type {RequestIdFunction} */ (request, serializer) => {
|
|
let serializedRequestParams = '';
|
|
if (request.params) {
|
|
// @ts-ignore assume serializer is defined
|
|
serializedRequestParams = `?${serializer(request.params)}`;
|
|
}
|
|
return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get(
|
|
'x-id',
|
|
)}${serializedRequestParams}`;
|
|
};
|
|
const reqIdSpy = sinon.spy(customRequestIdFn);
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
requestIdFunction: reqIdSpy,
|
|
});
|
|
|
|
await ajax.fetch('/test', { headers: { 'x-id': '1' } });
|
|
expect(reqIdSpy.calledOnce);
|
|
expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`);
|
|
});
|
|
|
|
it('throws when the request object is missing from the response', async () => {
|
|
const { cacheResponseInterceptor } = createCacheInterceptors(() => 'cache-id', {});
|
|
|
|
// @ts-ignore not an actual valid CacheResponse object
|
|
await cacheResponseInterceptor({})
|
|
.then(() => expect.fail('cacheResponseInterceptor should not resolve here'))
|
|
.catch(
|
|
/** @param {Error} err */ err => {
|
|
expect(err.message).to.equal('Missing request in response');
|
|
},
|
|
);
|
|
|
|
// @ts-ignore not an actual valid CacheResponse object
|
|
await cacheResponseInterceptor({ request: { method: 'get' } })
|
|
.then(() => expect('everything').to.be.ok)
|
|
.catch(err =>
|
|
expect.fail(
|
|
`cacheResponseInterceptor should resolve here, but threw an error: ${err.message}`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Cache invalidation', () => {
|
|
it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => {
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 1000,
|
|
invalidateUrlsRegex: /foo/gi,
|
|
});
|
|
|
|
await ajax.fetch('/test'); // new url
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
await ajax.fetch('/test'); // cached
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
await ajax.fetch('/foo-request-1'); // new url
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
await ajax.fetch('/foo-request-1'); // cached
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
|
|
await ajax.fetch('/foo-request-3'); // new url
|
|
expect(fetchStub.callCount).to.equal(3);
|
|
|
|
await ajax.fetch('/test', { method: 'POST' }); // clear cache
|
|
expect(fetchStub.callCount).to.equal(4);
|
|
await ajax.fetch('/foo-request-1'); // not cached anymore
|
|
expect(fetchStub.callCount).to.equal(5);
|
|
await ajax.fetch('/foo-request-2'); // not cached anymore
|
|
expect(fetchStub.callCount).to.equal(6);
|
|
});
|
|
|
|
it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => {
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 1000,
|
|
invalidateUrlsRegex: /posts/gi,
|
|
});
|
|
|
|
await ajax.fetch('/test');
|
|
await ajax.fetch('/test'); // cached
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
await ajax.fetch('/posts');
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
await ajax.fetch('/posts'); // cached
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
await ajax.fetch('/posts/1');
|
|
expect(fetchStub.callCount).to.equal(3);
|
|
await ajax.fetch('/posts/1'); // cached
|
|
expect(fetchStub.callCount).to.equal(3);
|
|
// cleans cache for defined urls
|
|
await ajax.fetch('/test', { method: 'POST' });
|
|
expect(fetchStub.callCount).to.equal(4);
|
|
await ajax.fetch('/posts'); // no longer cached => new request
|
|
expect(fetchStub.callCount).to.equal(5);
|
|
await ajax.fetch('/posts/1'); // no longer cached => new request
|
|
expect(fetchStub.callCount).to.equal(6);
|
|
});
|
|
|
|
it('deletes cache after one hour', async () => {
|
|
newCacheId();
|
|
const clock = sinon.useFakeTimers({
|
|
shouldAdvanceTime: true,
|
|
});
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 1000 * 60 * 60,
|
|
});
|
|
|
|
await ajax.fetch('/test-hour');
|
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true;
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
clock.tick(1000 * 60 * 59); // 0:59 hour
|
|
await ajax.fetch('/test-hour');
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour
|
|
await ajax.fetch('/test-hour');
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
clock.restore();
|
|
});
|
|
|
|
it('invalidates invalidateUrls endpoints', async () => {
|
|
const { requestIdFunction } = extendCacheOptions({});
|
|
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 500,
|
|
});
|
|
|
|
const cacheOptions = {
|
|
invalidateUrls: [
|
|
requestIdFunction({
|
|
url: new URL('/test-invalid-url', window.location.href).toString(),
|
|
params: { foo: 1, bar: 2 },
|
|
}),
|
|
],
|
|
};
|
|
|
|
await ajax.fetch('/test-valid-url', { cacheOptions });
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
await ajax.fetch('/test-invalid-url?foo=1&bar=2');
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
|
|
await ajax.fetch('/test-invalid-url?foo=1&bar=2');
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
|
|
// 'post' will invalidate 'own' cache and the one mentioned in config
|
|
await ajax.fetch('/test-valid-url', { cacheOptions, method: 'POST' });
|
|
expect(fetchStub.callCount).to.equal(3);
|
|
|
|
await ajax.fetch('/test-invalid-url?foo=1&bar=2');
|
|
// indicates that 'test-invalid-url' cache was removed
|
|
// because the server registered new request
|
|
expect(fetchStub.callCount).to.equal(4);
|
|
});
|
|
|
|
it('invalidates cache on a post', async () => {
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 100,
|
|
});
|
|
|
|
await ajax.fetch('/test-post');
|
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
await ajax.fetch('/test-post', { method: 'POST', body: 'data-post' });
|
|
expect(ajaxRequestSpy.calledTwice).to.be.true;
|
|
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
await ajax.fetch('/test-post');
|
|
expect(fetchStub.callCount).to.equal(3);
|
|
});
|
|
|
|
it('caches response but does not return it when expiration time is 0', async () => {
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 0,
|
|
});
|
|
|
|
const clock = sinon.useFakeTimers();
|
|
|
|
await ajax.fetch('/test');
|
|
|
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
|
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
|
|
clock.tick(1);
|
|
|
|
await ajax.fetch('/test');
|
|
|
|
clock.restore();
|
|
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
});
|
|
|
|
it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => {
|
|
// Given
|
|
addCacheInterceptors(ajax, { useCache: true });
|
|
|
|
// When
|
|
await ajax.fetch('/test');
|
|
await ajax.fetch('/test');
|
|
|
|
// Then
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
// When
|
|
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
|
|
|
|
// Then
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
});
|
|
|
|
it('caches concurrent requests', async () => {
|
|
newCacheId();
|
|
|
|
const clock = sinon.useFakeTimers();
|
|
|
|
fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1));
|
|
fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2));
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 750,
|
|
});
|
|
|
|
const firstRequest = ajax.fetch('/test').then(r => r.text());
|
|
const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text());
|
|
const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text());
|
|
|
|
clock.tick(1000);
|
|
|
|
// firstRequest is cached at tick 1000 in the next line!
|
|
const firstResponses = await Promise.all([
|
|
firstRequest,
|
|
concurrentFirstRequest1,
|
|
concurrentFirstRequest2,
|
|
]);
|
|
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
const cachedFirstRequest = ajax.fetch('/test').then(r => r.text());
|
|
|
|
clock.tick(500);
|
|
|
|
const cachedFirstResponse = await cachedFirstRequest;
|
|
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
|
|
const secondRequest = ajax.fetch('/test').then(r => r.text());
|
|
const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text());
|
|
|
|
clock.tick(1000);
|
|
|
|
const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]);
|
|
|
|
expect(fetchStub.callCount).to.equal(2);
|
|
|
|
expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']);
|
|
|
|
expect(cachedFirstResponse).to.equal('mock response 1');
|
|
|
|
expect(secondResponses).to.eql(['mock response 2', 'mock response 2']);
|
|
});
|
|
|
|
it('discards responses that are requested in a different cache session', async () => {
|
|
newCacheId();
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 10000,
|
|
});
|
|
|
|
// Switch the cache after the cache request interceptor, but before the fetch
|
|
// @ts-ignore
|
|
ajax._requestInterceptors.push(async request => {
|
|
newCacheId();
|
|
resetCacheSession(getCacheIdentifier());
|
|
return request;
|
|
});
|
|
|
|
const firstRequest = ajax.fetch('/test').then(r => r.text());
|
|
|
|
const firstResponse = await firstRequest;
|
|
|
|
expect(firstResponse).to.equal('mock response');
|
|
// @ts-ignore
|
|
expect(ajaxCache._cachedRequests).to.deep.equal({});
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
});
|
|
|
|
it('preserves status and headers when returning cached response', async () => {
|
|
newCacheId();
|
|
fetchStub.returns(
|
|
Promise.resolve(
|
|
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
|
|
),
|
|
);
|
|
|
|
addCacheInterceptors(ajax, {
|
|
useCache: true,
|
|
maxAge: 100,
|
|
});
|
|
|
|
const response1 = await ajax.fetch('/test');
|
|
const response2 = await ajax.fetch('/test');
|
|
expect(fetchStub.callCount).to.equal(1);
|
|
expect(response1.status).to.equal(206);
|
|
expect(response1.headers.get('x-foo')).to.equal('x-bar');
|
|
expect(response2.status).to.equal(206);
|
|
expect(response2.headers.get('x-foo')).to.equal('x-bar');
|
|
});
|
|
});
|
|
});
|