@lion/ajax
Advanced tools
Comparing version 0.14.0 to 0.15.0
{ | ||
"name": "@lion/ajax", | ||
"version": "0.14.0", | ||
"version": "0.15.0", | ||
"description": "Thin wrapper around fetch with support for interceptors.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
@@ -17,3 +17,3 @@ /** | ||
/** | ||
* @type {Partial<AjaxConfig>} | ||
* @type {AjaxConfig} | ||
* @private | ||
@@ -28,6 +28,6 @@ */ | ||
* Configures the Ajax instance | ||
* @param {Partial<AjaxConfig>} config configuration for the Ajax instance | ||
* @param {AjaxConfig} config configuration for the Ajax instance | ||
*/ | ||
set options(arg: Partial<import("../types/types.js").AjaxConfig>); | ||
get options(): Partial<import("../types/types.js").AjaxConfig>; | ||
set options(arg: import("../types/types.js").AjaxConfig); | ||
get options(): import("../types/types.js").AjaxConfig; | ||
/** @param {RequestInterceptor} requestInterceptor */ | ||
@@ -34,0 +34,0 @@ addRequestInterceptor(requestInterceptor: RequestInterceptor): void; |
@@ -26,3 +26,3 @@ /* eslint-disable consistent-return */ | ||
/** | ||
* @type {Partial<AjaxConfig>} | ||
* @type {AjaxConfig} | ||
* @private | ||
@@ -32,2 +32,3 @@ */ | ||
addAcceptLanguage: true, | ||
addCaching: false, | ||
xsrfCookieName: 'XSRF-TOKEN', | ||
@@ -58,3 +59,3 @@ xsrfHeaderName: 'X-XSRF-TOKEN', | ||
const { cacheOptions } = this.__config; | ||
if (cacheOptions?.useCache) { | ||
if (cacheOptions.useCache || this.__config.addCaching) { | ||
const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors( | ||
@@ -71,3 +72,3 @@ cacheOptions.getCacheIdentifier, | ||
* Configures the Ajax instance | ||
* @param {Partial<AjaxConfig>} config configuration for the Ajax instance | ||
* @param {AjaxConfig} config configuration for the Ajax instance | ||
*/ | ||
@@ -74,0 +75,0 @@ set options(config) { |
@@ -13,6 +13,6 @@ /** | ||
export function resetCacheSession(cacheId: string): void; | ||
export function extendCacheOptions({ useCache, methods, maxAge, requestIdFunction, invalidateUrls, invalidateUrlsRegex, }: CacheOptions): ValidatedCacheOptions; | ||
export function validateCacheOptions({ useCache, methods, maxAge, requestIdFunction, invalidateUrls, invalidateUrlsRegex, }?: CacheOptions): void; | ||
export function extendCacheOptions({ useCache, methods, maxAge, requestIdFunction, invalidateUrls, invalidateUrlsRegex, contentTypes, maxResponseSize, }: CacheOptions): ValidatedCacheOptions; | ||
export function validateCacheOptions({ useCache, methods, maxAge, requestIdFunction, invalidateUrls, invalidateUrlsRegex, contentTypes, maxResponseSize, }?: CacheOptions): void; | ||
export function invalidateMatchingCache(requestId: string, { invalidateUrls, invalidateUrlsRegex }: CacheOptions): void; | ||
import Cache from "./Cache.js"; | ||
import PendingRequestStore from "./PendingRequestStore.js"; |
@@ -85,2 +85,4 @@ import './typedef.js'; | ||
invalidateUrlsRegex, | ||
contentTypes, | ||
maxResponseSize, | ||
}) => ({ | ||
@@ -93,2 +95,4 @@ useCache, | ||
invalidateUrlsRegex, | ||
contentTypes, | ||
maxResponseSize, | ||
}); | ||
@@ -106,2 +110,4 @@ | ||
invalidateUrlsRegex, | ||
contentTypes, | ||
maxResponseSize, | ||
} = {}) => { | ||
@@ -118,6 +124,6 @@ if (useCache !== undefined && typeof useCache !== 'boolean') { | ||
if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) { | ||
throw new Error('Property `invalidateUrls` must be an `Array` or `falsy`'); | ||
throw new Error('Property `invalidateUrls` must be an `Array` or `undefined`'); | ||
} | ||
if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) { | ||
throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); | ||
throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`'); | ||
} | ||
@@ -127,2 +133,8 @@ if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') { | ||
} | ||
if (contentTypes !== undefined && !Array.isArray(contentTypes)) { | ||
throw new Error('Property `contentTypes` must be an `Array` or `undefined`'); | ||
} | ||
if (maxResponseSize !== undefined && !Number.isFinite(maxResponseSize)) { | ||
throw new Error('Property `maxResponseSize` must be a finite `number`'); | ||
} | ||
}; | ||
@@ -129,0 +141,0 @@ |
@@ -14,2 +14,38 @@ /* eslint-disable no-param-reassign */ | ||
/** | ||
* Tests whether the request method is supported according to the `cacheOptions` | ||
* @param {ValidatedCacheOptions} cacheOptions | ||
* @param {string} method | ||
* @returns {boolean} | ||
*/ | ||
const isMethodSupported = (cacheOptions, method) => | ||
cacheOptions.methods.includes(method.toLowerCase()); | ||
/** | ||
* Tests whether the response content type is supported by the `contentTypes` whitelist | ||
* @param {Response} response | ||
* @param {CacheOptions} cacheOptions | ||
* @returns {boolean} `true` if the contentTypes property is not an array, or if the value of the Content-Type header is in the array | ||
*/ | ||
const isResponseContentTypeSupported = (response, { contentTypes } = {}) => { | ||
if (!Array.isArray(contentTypes)) return true; | ||
return contentTypes.includes(String(response.headers.get('Content-Type'))); | ||
}; | ||
/** | ||
* Tests whether the response size is not too large to be cached according to the `maxResponseSize` property | ||
* @param {Response} response | ||
* @param {CacheOptions} cacheOptions | ||
* @returns {boolean} `true` if the `maxResponseSize` property is not larger than zero, or if the Content-Length header is not present, or if the value of the header is not larger than the `maxResponseSize` property | ||
*/ | ||
const isResponseSizeSupported = (response, { maxResponseSize } = {}) => { | ||
const responseSize = +(response.headers.get('Content-Length') || 0); | ||
if (!maxResponseSize) return true; | ||
if (!responseSize) return true; | ||
return responseSize <= maxResponseSize; | ||
}; | ||
/** | ||
* Request interceptor to return relevant cached requests | ||
@@ -40,5 +76,4 @@ * @param {function(): string} getCacheId used to invalidate cache if identifier is changed | ||
const requestId = cacheOptions.requestIdFunction(request); | ||
const isMethodSupported = cacheOptions.methods.includes(request.method.toLowerCase()); | ||
if (!isMethodSupported) { | ||
if (!isMethodSupported(cacheOptions, request.method)) { | ||
invalidateMatchingCache(requestId, cacheOptions); | ||
@@ -55,3 +90,7 @@ return request; | ||
const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge); | ||
if (cachedResponse) { | ||
if ( | ||
cachedResponse && | ||
isResponseContentTypeSupported(cachedResponse, cacheOptions) && | ||
isResponseSizeSupported(cachedResponse, cacheOptions) | ||
) { | ||
// Return the response from cache | ||
@@ -87,9 +126,10 @@ request.cacheOptions = request.cacheOptions ?? { useCache: false }; | ||
const requestId = cacheOptions.requestIdFunction(response.request); | ||
const isAlreadyFromCache = !!response.fromCache; | ||
const isCacheActive = cacheOptions.useCache; | ||
const isMethodSupported = cacheOptions.methods.includes(response.request?.method.toLowerCase()); | ||
if (!response.fromCache && isMethodSupported(cacheOptions, response.request.method)) { | ||
const requestId = cacheOptions.requestIdFunction(response.request); | ||
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) { | ||
if (isCurrentSessionId(response.request.cacheSessionId)) { | ||
if ( | ||
isCurrentSessionId(response.request.cacheSessionId) && | ||
isResponseContentTypeSupported(response, cacheOptions) && | ||
isResponseSizeSupported(response, cacheOptions) | ||
) { | ||
// Cache the response | ||
@@ -102,2 +142,3 @@ ajaxCache.set(requestId, response.clone()); | ||
} | ||
return response; | ||
@@ -104,0 +145,0 @@ }; |
@@ -11,5 +11,16 @@ import { expect } from '@open-wc/testing'; | ||
let responseId = 1; | ||
const responseInit = () => ({ | ||
headers: { | ||
// eslint-disable-next-line no-plusplus | ||
'x-request-id': `${responseId++}`, | ||
'content-type': 'application/json', | ||
'x-custom-header': 'y-custom-value', | ||
}, | ||
}); | ||
beforeEach(() => { | ||
fetchStub = stub(window, 'fetch'); | ||
fetchStub.returns(Promise.resolve(new Response('mock response'))); | ||
fetchStub.callsFake(() => Promise.resolve(new Response('mock response', responseInit()))); | ||
ajax = new Ajax(); | ||
@@ -36,2 +47,3 @@ }); | ||
addAcceptLanguage: true, | ||
addCaching: false, | ||
xsrfCookieName: 'XSRF-TOKEN', | ||
@@ -73,3 +85,4 @@ xsrfHeaderName: 'X-XSRF-TOKEN', | ||
it('calls fetch with the given args, returning the result', async () => { | ||
const response = await (await ajax.fetch('/foo', { method: 'POST' })).text(); | ||
const response = await ajax.fetch('/foo', { method: 'POST' }); | ||
const responseText = await response.text(); | ||
@@ -80,3 +93,5 @@ expect(fetchStub).to.have.been.calledOnce; | ||
expect(request.method).to.equal('POST'); | ||
expect(response).to.equal('mock response'); | ||
expect(responseText).to.equal('mock response'); | ||
expect(response.headers.get('Content-Type')).to.equal('application/json'); | ||
expect(response.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
}); | ||
@@ -119,3 +134,3 @@ | ||
describe('fetchtJson', () => { | ||
describe('fetchJson', () => { | ||
beforeEach(() => { | ||
@@ -132,5 +147,7 @@ fetchStub.returns(Promise.resolve(new Response('{}'))); | ||
it('decodes response from json', async () => { | ||
fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}'))); | ||
fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}', responseInit()))); | ||
const response = await ajax.fetchJson('/foo'); | ||
expect(response.body).to.eql({ a: 1, b: 2 }); | ||
expect(response.response.headers.get('Content-Type')).to.equal('application/json'); | ||
expect(response.response.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
}); | ||
@@ -296,6 +313,17 @@ | ||
it('allows configuring cache interceptors on the Ajax config', async () => { | ||
newCacheId(); | ||
it('does not add cache interceptors when useCache is turned off', () => { | ||
const customAjax = new Ajax({ | ||
cacheOptions: { | ||
maxAge: 100, | ||
getCacheIdentifier, | ||
}, | ||
}); | ||
expect(customAjax._requestInterceptors.length).to.equal(2); | ||
expect(customAjax._responseInterceptors.length).to.equal(0); | ||
}); | ||
it('adds cache interceptors when useCache is turned on', () => { | ||
const customAjax = new Ajax({ | ||
cacheOptions: { | ||
useCache: true, | ||
@@ -307,25 +335,100 @@ maxAge: 100, | ||
const clock = useFakeTimers({ | ||
shouldAdvanceTime: true, | ||
expect(customAjax._requestInterceptors.length).to.equal(3); | ||
expect(customAjax._responseInterceptors.length).to.equal(1); | ||
}); | ||
it('adds cache interceptors when addCaching is turned on', () => { | ||
const customAjax = new Ajax({ | ||
addCaching: true, | ||
cacheOptions: { | ||
maxAge: 100, | ||
getCacheIdentifier, | ||
}, | ||
}); | ||
// Smoke test 1: verify caching works | ||
await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(1); | ||
await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(1); | ||
expect(customAjax._requestInterceptors.length).to.equal(3); | ||
expect(customAjax._responseInterceptors.length).to.equal(1); | ||
}); | ||
// Smoke test 2: verify caching is invalidated on non-get method | ||
await customAjax.fetch('/foo', { method: 'POST' }); | ||
expect(fetchStub.callCount).to.equal(2); | ||
await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(3); | ||
describe('caching interceptors', async () => { | ||
/** | ||
* @type {Ajax} | ||
*/ | ||
let customAjax; | ||
// Smoke test 3: verify caching is invalidated after TTL has passed | ||
await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(3); | ||
clock.tick(101); | ||
await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(4); | ||
clock.restore(); | ||
beforeEach(async () => { | ||
newCacheId(); | ||
customAjax = new Ajax({ | ||
cacheOptions: { | ||
useCache: true, | ||
maxAge: 100, | ||
getCacheIdentifier, | ||
}, | ||
}); | ||
}); | ||
it('works', async () => { | ||
await customAjax.fetch('/foo'); | ||
const secondResponse = await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(1); | ||
expect(await secondResponse.text()).to.equal('mock response'); | ||
expect(secondResponse.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
expect(secondResponse.headers.get('Content-Type')).to.equal('application/json'); | ||
}); | ||
it('works with fetchJson', async () => { | ||
fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}', responseInit()))); | ||
const firstResponse = await customAjax.fetchJson('/foo'); | ||
expect(firstResponse.body).to.deep.equal({ a: 1, b: 2 }); | ||
expect(firstResponse.response.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
expect(firstResponse.response.headers.get('Content-Type')).to.equal('application/json'); | ||
const secondResponse = await customAjax.fetchJson('/foo'); | ||
expect(fetchStub.callCount).to.equal(1); | ||
expect(secondResponse.body).to.deep.equal({ a: 1, b: 2 }); | ||
expect(secondResponse.response.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
expect(secondResponse.response.headers.get('Content-Type')).to.equal('application/json'); | ||
}); | ||
it('is invalidated on non-get method', async () => { | ||
await customAjax.fetch('/foo'); | ||
const secondResponse = await customAjax.fetch('/foo', { method: 'POST' }); | ||
expect(fetchStub.callCount).to.equal(2); | ||
expect(await secondResponse.text()).to.equal('mock response'); | ||
expect(secondResponse.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
expect(secondResponse.headers.get('Content-Type')).to.equal('application/json'); | ||
const thirdResponse = await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(3); | ||
expect(await thirdResponse.text()).to.equal('mock response'); | ||
expect(thirdResponse.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
expect(thirdResponse.headers.get('Content-Type')).to.equal('application/json'); | ||
}); | ||
it('is invalidated after TTL has passed', async () => { | ||
const clock = useFakeTimers({ | ||
shouldAdvanceTime: true, | ||
}); | ||
await customAjax.fetch('/foo'); | ||
const secondResponse = await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(1); | ||
expect(await secondResponse.text()).to.equal('mock response'); | ||
expect(secondResponse.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
expect(secondResponse.headers.get('Content-Type')).to.equal('application/json'); | ||
clock.tick(101); | ||
const thirdResponse = await customAjax.fetch('/foo'); | ||
expect(fetchStub.callCount).to.equal(2); | ||
expect(await thirdResponse.text()).to.equal('mock response'); | ||
expect(thirdResponse.headers.get('X-Custom-Header')).to.equal('y-custom-value'); | ||
expect(thirdResponse.headers.get('Content-Type')).to.equal('application/json'); | ||
clock.restore(); | ||
}); | ||
}); | ||
@@ -332,0 +435,0 @@ }); |
@@ -77,2 +77,4 @@ // @ts-nocheck | ||
invalidateUrlsRegex: invalidateUrlsRegexResult, | ||
contentTypes, | ||
maxResponseSize, | ||
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex }); | ||
@@ -86,2 +88,4 @@ // Assert | ||
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex); | ||
expect(contentTypes).to.be.undefined; | ||
expect(maxResponseSize).to.be.undefined; | ||
}); | ||
@@ -134,2 +138,3 @@ | ||
}); | ||
it('accepts an empty object', () => { | ||
@@ -140,2 +145,3 @@ expect(() => validateCacheOptions({})).not.to.throw( | ||
}); | ||
describe('the useCache property', () => { | ||
@@ -145,5 +151,7 @@ it('accepts a boolean', () => { | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
@@ -156,2 +164,3 @@ // @ts-ignore | ||
}); | ||
describe('the methods property', () => { | ||
@@ -161,5 +170,7 @@ it('accepts an array with the value `get`', () => { | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ methods: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
@@ -177,2 +188,3 @@ expect(() => validateCacheOptions({ methods: [] })).to.throw( | ||
}); | ||
describe('the maxAge property', () => { | ||
@@ -182,5 +194,7 @@ it('accepts a finite number', () => { | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
@@ -196,2 +210,3 @@ // @ts-ignore | ||
}); | ||
describe('the invalidateUrls property', () => { | ||
@@ -204,12 +219,15 @@ it('accepts an array', () => { | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
// @ts-ignore | ||
expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw( | ||
'Property `invalidateUrls` must be an `Array` or `falsy`', | ||
'Property `invalidateUrls` must be an `Array` or `undefined`', | ||
); | ||
}); | ||
}); | ||
describe('the invalidateUrlsRegex property', () => { | ||
@@ -220,5 +238,7 @@ it('accepts a regular expression', () => { | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
@@ -228,5 +248,6 @@ // @ts-ignore | ||
validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }), | ||
).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); | ||
).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`'); | ||
}); | ||
}); | ||
describe('the requestIdFunction property', () => { | ||
@@ -239,5 +260,7 @@ it('accepts a function', () => { | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
@@ -250,2 +273,41 @@ // @ts-ignore | ||
}); | ||
describe('the contentTypes property', () => { | ||
it('accepts an array', () => { | ||
// @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions | ||
expect(() => validateCacheOptions({ contentTypes: [6, 'elements', 'in', 1, true, Array] })) | ||
.not.to.throw; | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ contentTypes: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
// @ts-ignore | ||
expect(() => validateCacheOptions({ contentTypes: 'not-an-array' })).to.throw( | ||
'Property `contentTypes` must be an `Array` or `undefined`', | ||
); | ||
}); | ||
}); | ||
describe('the maxResponseSize property', () => { | ||
it('accepts a finite number', () => { | ||
expect(() => validateCacheOptions({ maxResponseSize: 42 })).not.to.throw; | ||
}); | ||
it('accepts undefined', () => { | ||
expect(() => validateCacheOptions({ maxResponseSize: undefined })).not.to.throw; | ||
}); | ||
it('does not accept anything else', () => { | ||
// @ts-ignore | ||
expect(() => validateCacheOptions({ maxResponseSize: 'string' })).to.throw( | ||
'Property `maxResponseSize` must be a finite `number`', | ||
); | ||
expect(() => validateCacheOptions({ maxResponseSize: Infinity })).to.throw( | ||
'Property `maxResponseSize` must be a finite `number`', | ||
); | ||
}); | ||
}); | ||
}); | ||
@@ -252,0 +314,0 @@ |
@@ -25,2 +25,4 @@ import { expect } from '@open-wc/testing'; | ||
let fetchStub; | ||
/** @type {Response} */ | ||
let mockResponse; | ||
const getCacheIdentifier = () => String(cacheId); | ||
@@ -55,4 +57,5 @@ /** @type {sinon.SinonSpy} */ | ||
ajax = new Ajax(); | ||
mockResponse = new Response('mock response'); | ||
fetchStub = sinon.stub(window, 'fetch'); | ||
fetchStub.returns(Promise.resolve(new Response('mock response'))); | ||
fetchStub.resolves(mockResponse); | ||
ajaxRequestSpy = sinon.spy(ajax, 'fetch'); | ||
@@ -155,4 +158,3 @@ }); | ||
// TODO: Check if this is the behaviour we want | ||
it('all calls with non-default `maxAge` are cached proactively', async () => { | ||
it('all calls are cached proactively', async () => { | ||
// Given | ||
@@ -163,3 +165,2 @@ newCacheId(); | ||
useCache: false, | ||
maxAge: 100, | ||
}); | ||
@@ -176,7 +177,3 @@ | ||
// When | ||
await ajax.fetch('/test', { | ||
cacheOptions: { | ||
useCache: true, | ||
}, | ||
}); | ||
await ajax.fetch('/test'); | ||
@@ -305,3 +302,3 @@ // Then | ||
// @ts-ignore not an actual valid CacheResponse object | ||
await cacheResponseInterceptor({ request: { method: 'get' } }) | ||
await cacheResponseInterceptor({ request: { method: 'get' }, headers: new Headers() }) | ||
.then(() => expect('everything').to.be.ok) | ||
@@ -314,4 +311,257 @@ .catch(err => | ||
}); | ||
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 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', () => { | ||
@@ -459,97 +709,2 @@ it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => { | ||
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 () => { | ||
@@ -580,25 +735,3 @@ newCacheId(); | ||
}); | ||
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'); | ||
}); | ||
}); | ||
}); |
@@ -15,2 +15,3 @@ /** | ||
addAcceptLanguage: boolean; | ||
addCaching: boolean; | ||
xsrfCookieName: string | null; | ||
@@ -43,2 +44,4 @@ xsrfHeaderName: string | null; | ||
requestIdFunction?: RequestIdFunction; | ||
contentTypes?: string[]; | ||
maxResponseSize?: number; | ||
} | ||
@@ -45,0 +48,0 @@ |
120325
2683