import { expect } from '@open-wc/testing'; import * as sinon from 'sinon'; import { Ajax, createCacheInterceptors } from '@lion/ajax'; import { isResponseContentTypeSupported } from '../../src/interceptors/cacheInterceptors.js'; // TODO: these are private API? should they be exposed? if not why do we test them? import { extendCacheOptions, resetCacheSession, ajaxCache, setCacheSessionId, } from '../../src/cacheManager.js'; const MOCK_RESPONSE = 'mock response'; const getUrl = (/** @type {string} */ url) => new URL(url, window.location.href).toString(); /** @type {Ajax} */ let ajax; /** * @typedef {import('../../types/types.js').CacheOptions} CacheOptions * @typedef {import('../../types/types.js').RequestIdFunction} RequestIdFunction */ describe('cache interceptors', () => { /** * @param {number | undefined} timeout * @param {number} i */ const returnResponseOnTick = (timeout, i) => new Promise(resolve => // eslint-disable-next-line no-promise-executor-return window.setTimeout(() => resolve(new Response(`mock response ${i}`)), timeout), ); /** @type {number | undefined} */ let cacheId; /** @type {sinon.SinonStub} */ let fetchStub; /** @type {Response} */ let mockResponse; 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(); mockResponse = new Response(MOCK_RESPONSE); fetchStub = sinon.stub(window, 'fetch'); fetchStub.resolves(mockResponse); ajaxRequestSpy = sinon.spy(ajax, 'fetch'); }); afterEach(() => { sinon.restore(); }); describe('isResponseContentTypeSupported', () => { /** @type {Response} */ let r; beforeEach(() => { r = new Response(''); }); it('matches default content type', () => { r.headers.set('Content-Type', 'application/json'); expect(isResponseContentTypeSupported(r, ['application/json'])).to.equal(true); }); it('returns false when it doesnt match', () => { r.headers.set('Content-Type', 'text/json'); expect(isResponseContentTypeSupported(r, ['application/json'])).to.equal(false); }); it('partially matches content type', () => { r.headers.set('Content-Type', 'text/plain;charset=UTF-8;'); expect(isResponseContentTypeSupported(r, ['text/plain'])).to.equal(true); }); it('returns true if `contentTypes` is not an array', () => { r.headers.set('Content-Type', 'foo'); // @ts-ignore needed for test expect(isResponseContentTypeSupported(r, 'string')).to.equal(true); }); }); 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' }, headers: new Headers() }) .then(() => expect('everything').to.be.ok) .catch(err => expect.fail( `cacheResponseInterceptor should resolve here, but threw an error: ${err.message}`, ), ); }); 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']); clock.restore(); }); 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'); }); it('does save to the cache when `contentTypes` is specified and a supported content type is returned', async () => { // Given newCacheId(); mockResponse.headers.set('content-type', 'application/xml'); addCacheInterceptors(ajax, { useCache: true, contentTypes: ['application/json', 'application/xml'], }); // When await ajax.fetch('/test'); await ajax.fetch('/test'); // Then expect(fetchStub.callCount).to.equal(1); }); it('Does not use cached request when session ID changes during processing a pending request', async () => { addCacheInterceptors(ajax, { useCache: true, maxAge: 750, }); // Reset cache newCacheId(); /* Bump sessionID manually in an injected response interceptor. This will simulate the cache session ID getting changed while waiting for a pending request */ ajax._responseInterceptors.unshift(async (/** @type {Response} */ response) => { newCacheId(); setCacheSessionId(getCacheIdentifier()); return response; }); const requestOne = ajax.fetch('/foo').then(() => 'completedRequestOne'); const requestTwo = ajax.fetch('/foo').then(() => 'completedRequestTwo'); expect(await requestOne).to.equal('completedRequestOne'); // At this point the response interceptor of requestOne has called setCacheSessionId expect(await requestTwo).to.equal('completedRequestTwo'); /* Neither call should use the cache. During the first call there is no cache entry for '/foo'. During the second call there is, but since the first call's injected interceptor has bumped the cache session ID, it shouldn't use the cached response. */ expect(fetchStub.callCount).to.equal(2); }); it('does save to the cache when `maxResponseSize` is specified and the response size is within the threshold', async () => { // Given newCacheId(); mockResponse.headers.set('content-length', '20000'); addCacheInterceptors(ajax, { useCache: true, maxResponseSize: 50000, }); // When await ajax.fetch('/test'); await ajax.fetch('/test'); // Then expect(fetchStub.callCount).to.equal(1); }); it('does save to the cache when `maxResponseSize` is specified and the response size is unknown', async () => { // Given newCacheId(); addCacheInterceptors(ajax, { useCache: true, maxResponseSize: 50000, }); // When await ajax.fetch('/test'); await ajax.fetch('/test'); // Then expect(fetchStub.callCount).to.equal(1); }); }); describe('Bypassing the cache', () => { 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 newCacheId(); 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('does not save to the cache when `contentTypes` is specified and an unsupported content type is returned', async () => { // Given newCacheId(); mockResponse.headers.set('content-type', 'text/html'); addCacheInterceptors(ajax, { useCache: true, contentTypes: ['application/json', 'application/xml'], }); // When await ajax.fetch('/test'); await ajax.fetch('/test', { cacheOptions: { contentTypes: ['text/html'] } }); // Then expect(fetchStub.callCount).to.equal(2); }); it('does not read from the cache when `contentTypes` is specified and an unsupported content type is returned', async () => { // Given newCacheId(); mockResponse.headers.set('content-type', 'application/json'); addCacheInterceptors(ajax, { useCache: true, contentTypes: ['application/json', 'application/xml'], }); // When await ajax.fetch('/test'); await ajax.fetch('/test'); // Then expect(fetchStub.callCount).to.equal(1); // When await ajax.fetch('/test', { cacheOptions: { contentTypes: [] } }); // Then expect(fetchStub.callCount).to.equal(2); }); it('does not save to the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => { // Given newCacheId(); mockResponse.headers.set('content-length', '80000'); addCacheInterceptors(ajax, { useCache: true, maxResponseSize: 50000, }); // When await ajax.fetch('/test'); await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 100000 } }); // Then expect(fetchStub.callCount).to.equal(2); }); it('does not read from the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => { // Given newCacheId(); mockResponse.headers.set('content-length', '80000'); addCacheInterceptors(ajax, { useCache: true, maxResponseSize: 100000, }); // When await ajax.fetch('/test'); await ajax.fetch('/test'); // Then expect(fetchStub.callCount).to.equal(1); // When await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 50000 } }); // Then expect(fetchStub.callCount).to.equal(2); }); }); 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: getUrl('/test-invalid-url'), 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('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); }); describe('when maxCacheSize is set', () => { beforeEach(() => { newCacheId(); addCacheInterceptors(ajax, { useCache: true, maxCacheSize: MOCK_RESPONSE.length * 3 + 1, }); }); it('discards the last added request when the cache is full', async () => { // @ts-ignore use this private field expect(ajaxCache._size).to.equal(0); await ajax.fetch('/test1'); await ajax.fetch('/test2'); await ajax.fetch('/test3'); await ajax.fetch('/test4'); // @ts-ignore use this private field expect(Object.keys(ajaxCache._cachedRequests)).to.deep.equal( ['/test2', '/test3', '/test4'].map(getUrl), ); expect(fetchStub.callCount).to.equal(4); // @ts-ignore use this private field expect(ajaxCache._size).to.equal(MOCK_RESPONSE.length * 3); }); }); }); });