lion/packages/ajax/test/interceptors/cacheInterceptors.test.js
Ahmet Yesil 879598506a Race condition fix for on the fly requests, improve cache implementation and tests
Co-authored-by: Goffert van Gool <ruphin@ruphin.net>
Co-authored-by: Martin Pool <martin.pool@ing.com>
2021-09-21 13:59:08 +02:00

595 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);
});
// TODO: Check if this is the behaviour we want
it('all calls with non-default `maxAge` are cached proactively', async () => {
// Given
newCacheId();
addCacheInterceptors(ajax, {
useCache: false,
maxAge: 100,
});
// 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', {
cacheOptions: {
useCache: true,
},
});
// 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');
});
});
});