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>
This commit is contained in:
parent
dec9c7555a
commit
879598506a
22 changed files with 1768 additions and 959 deletions
5
.changeset/silly-lamps-flash.md
Normal file
5
.changeset/silly-lamps-flash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/ajax': patch
|
||||
---
|
||||
|
||||
Fix cache session race condition for in-flight requests
|
||||
8
.changeset/tall-adults-act.md
Normal file
8
.changeset/tall-adults-act.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
'@lion/ajax': minor
|
||||
---
|
||||
|
||||
**BREAKING** public API changes:
|
||||
|
||||
- Changed `timeToLive` to `maxAge`
|
||||
- Renamed `requestIdentificationFn` to `requestIdFunction`
|
||||
|
|
@ -15,9 +15,11 @@ const getCacheIdentifier = () => {
|
|||
return userId;
|
||||
};
|
||||
|
||||
const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds
|
||||
|
||||
const cacheOptions = {
|
||||
useCache: true,
|
||||
timeToLive: 1000 * 60 * 10, // 10 minutes
|
||||
maxAge: TEN_MINUTES,
|
||||
};
|
||||
|
||||
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
|
||||
|
|
@ -72,9 +74,13 @@ const newUser = await response.json();
|
|||
|
||||
### JSON requests
|
||||
|
||||
We usually deal with JSON requests and responses. With `fetchJson` you don't need to specifically stringify the request body or parse the response body.
|
||||
We usually deal with JSON requests and responses. `ajax.fetchJson` supports JSON by:
|
||||
|
||||
The result will have the Response object on `.response` property, and the decoded json will be available on `.body`.
|
||||
- Serializing request body as JSON
|
||||
- Deserializing response payload as JSON
|
||||
- Adding the correct Content-Type and Accept headers
|
||||
|
||||
> Note that, the result will have the Response object on `.response` property, and the parsed JSON will be available on `.body`.
|
||||
|
||||
## GET JSON request
|
||||
|
||||
|
|
@ -133,7 +139,7 @@ export const errorHandling = () => {
|
|||
}
|
||||
} else {
|
||||
// an error happened before receiving a response,
|
||||
// ex. an incorrect request or network error
|
||||
// Example: an incorrect request or network error
|
||||
actionLogger.log(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -157,32 +163,28 @@ For IE11 you will need a polyfill for fetch. You should add this on your top lev
|
|||
|
||||
[This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests)
|
||||
|
||||
## Ajax Cache
|
||||
## Ajax Caching Support
|
||||
|
||||
A caching library that uses `@lion/ajax` and adds cache interceptors to provide caching for use in
|
||||
frontend `services`.
|
||||
Ajax package provides in-memory cache support through interceptors. And cache interceptors can be added manually or by configuring the Ajax instance.
|
||||
|
||||
The **request interceptor**'s main goal is to determine whether or not to
|
||||
**return the cached object**. This is done based on the options that are being
|
||||
passed.
|
||||
The cache request interceptor and cache response interceptor are designed to work together to support caching of network requests/responses.
|
||||
|
||||
The **response interceptor**'s goal is to determine **when to cache** the
|
||||
requested response, based on the options that are being passed.
|
||||
> The **request interceptor** checks whether the response for this particular request is cached, and if so returns the cached response.
|
||||
> And the **response interceptor** caches the response for this particular request.
|
||||
|
||||
### Getting started
|
||||
|
||||
Consume the global `ajax` instance and add interceptors to it, using a cache configuration which is applied on application level. If a developer wants to add specifics to cache behaviour they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config,
|
||||
see examples below.
|
||||
|
||||
> **Note**: make sure to add the **interceptors** only **once**. This is usually
|
||||
> done on app-level
|
||||
> **Note**: make sure to add the **interceptors** only **once**. This is usually done on app-level
|
||||
|
||||
```js
|
||||
import { ajax, createCacheInterceptors } from '@lion-web/ajax';
|
||||
|
||||
const globalCacheOptions = {
|
||||
useCache: true,
|
||||
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||
maxAge: 1000 * 60 * 5, // 5 minutes
|
||||
};
|
||||
|
||||
// Cache is removed each time an identifier changes,
|
||||
|
|
@ -208,7 +210,7 @@ import { Ajax } from '@lion/ajax';
|
|||
export const ajax = new Ajax({
|
||||
cacheOptions: {
|
||||
useCache: true,
|
||||
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||
maxAge: 1000 * 60 * 5, // 5 minutes
|
||||
getCacheIdentifier: () => getActiveProfile().profileId,
|
||||
},
|
||||
});
|
||||
|
|
@ -218,8 +220,7 @@ export const ajax = new Ajax({
|
|||
|
||||
> Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors.
|
||||
|
||||
We can see if a response is served from the cache by checking the `response.fromCache` property,
|
||||
which is either undefined for normal requests, or set to true for responses that were served from cache.
|
||||
We can see if a response is served from the cache by checking the `response.fromCache` property, which is either undefined for normal requests, or set to true for responses that were served from cache.
|
||||
|
||||
```js preview-story
|
||||
export const cache = () => {
|
||||
|
|
@ -284,28 +285,28 @@ export const cacheActionOptions = () => {
|
|||
|
||||
Invalidating the cache, or cache busting, can be done in multiple ways:
|
||||
|
||||
- Going past the `timeToLive` of the cache object
|
||||
- Going past the `maxAge` of the cache object
|
||||
- Changing cache identifier (e.g. user session or active profile changes)
|
||||
- Doing a non GET request to the cached endpoint
|
||||
- Invalidates the cache of that endpoint
|
||||
- Invalidates the cache of all other endpoints matching `invalidatesUrls` and `invalidateUrlsRegex`
|
||||
|
||||
## Time to live
|
||||
## maxAge
|
||||
|
||||
In this demo we pass a timeToLive of three seconds.
|
||||
Try clicking the fetch button and watch fromCache change whenever TTL expires.
|
||||
In this demo we pass a maxAge of three seconds.
|
||||
Try clicking the fetch button and watch fromCache change whenever maxAge expires.
|
||||
|
||||
After TTL expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.
|
||||
After maxAge expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.
|
||||
|
||||
```js preview-story
|
||||
export const cacheTimeToLive = () => {
|
||||
export const cacheMaxAge = () => {
|
||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||
|
||||
const fetchHandler = () => {
|
||||
ajax
|
||||
.fetchJson(`../assets/pabu.json`, {
|
||||
cacheOptions: {
|
||||
timeToLive: 1000 * 3, // 3 seconds
|
||||
maxAge: 1000 * 3, // 3 seconds
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
# Tools >> Ajax >> Overview ||10
|
||||
|
||||
```js script
|
||||
import { html } from '@mdjs/mdjs-preview';
|
||||
import { renderLitAsNode } from '@lion/helpers';
|
||||
import { ajax, createCacheInterceptors } from '@lion/ajax';
|
||||
import '@lion/helpers/define';
|
||||
|
||||
const getCacheIdentifier = () => {
|
||||
let userId = localStorage.getItem('lion-ajax-cache-demo-user-id');
|
||||
|
|
@ -15,9 +12,11 @@ const getCacheIdentifier = () => {
|
|||
return userId;
|
||||
};
|
||||
|
||||
const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds
|
||||
|
||||
const cacheOptions = {
|
||||
useCache: true,
|
||||
timeToLive: 1000 * 60 * 10, // 10 minutes
|
||||
maxAge: TEN_MINUTES,
|
||||
};
|
||||
|
||||
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
|
||||
|
|
@ -33,8 +32,8 @@ ajax.addResponseInterceptor(cacheResponseInterceptor);
|
|||
|
||||
- Allows globally registering request and response interceptors
|
||||
- Throws on 4xx and 5xx status codes
|
||||
- Prevents network request if a request interceptor returns a response
|
||||
- Supports a JSON request which automatically encodes/decodes body request and response payload as JSON
|
||||
- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
|
||||
- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
|
||||
- Adds accept-language header to requests based on application language
|
||||
- Adds XSRF header to request if the cookie is present
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ import { AjaxFetchError } from './AjaxFetchError.js';
|
|||
import './typedef.js';
|
||||
|
||||
/**
|
||||
* HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which
|
||||
* intercept request and responses, for example to add authorization headers or logging. A
|
||||
* request can also be prevented from reaching the network at all by returning the Response directly.
|
||||
* A small wrapper around `fetch`.
|
||||
- Allows globally registering request and response interceptors
|
||||
- Throws on 4xx and 5xx status codes
|
||||
- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
|
||||
- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and
|
||||
deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
|
||||
- Adds accept-language header to requests based on application language
|
||||
- Adds XSRF header to request if the cookie is present
|
||||
*/
|
||||
export class Ajax {
|
||||
/**
|
||||
|
|
@ -49,18 +54,18 @@ export class Ajax {
|
|||
|
||||
const { cacheOptions } = this.__config;
|
||||
if (cacheOptions?.useCache) {
|
||||
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
|
||||
const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
|
||||
cacheOptions.getCacheIdentifier,
|
||||
cacheOptions,
|
||||
);
|
||||
this.addRequestInterceptor(/** @type {RequestInterceptor} */ (cacheRequestInterceptor));
|
||||
this.addResponseInterceptor(/** @type {ResponseInterceptor} */ (cacheResponseInterceptor));
|
||||
this.addRequestInterceptor(cacheRequestInterceptor);
|
||||
this.addResponseInterceptor(cacheResponseInterceptor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the config for the instance
|
||||
* @param {Partial<AjaxConfig>} config configuration for the AjaxClass instance
|
||||
* Configures the Ajax instance
|
||||
* @param {Partial<AjaxConfig>} config configuration for the Ajax instance
|
||||
*/
|
||||
set options(config) {
|
||||
this.__config = config;
|
||||
|
|
@ -95,8 +100,7 @@ export class Ajax {
|
|||
}
|
||||
|
||||
/**
|
||||
* Makes a fetch request, calling the registered fetch request and response
|
||||
* interceptors.
|
||||
* Fetch by calling the registered request and response interceptors.
|
||||
*
|
||||
* @param {RequestInfo} info
|
||||
* @param {RequestInit & Partial<CacheRequestExtension>} [init]
|
||||
|
|
@ -126,8 +130,11 @@ export class Ajax {
|
|||
}
|
||||
|
||||
/**
|
||||
* Makes a fetch request, calling the registered fetch request and response
|
||||
* interceptors. Encodes/decodes the request and response body as JSON.
|
||||
* Fetch by calling the registered request and response
|
||||
* interceptors. And supports JSON by:
|
||||
* - Serializing request body as JSON
|
||||
* - Deserializing response payload as JSON
|
||||
* - Adding the correct Content-Type and Accept headers
|
||||
*
|
||||
* @param {RequestInfo} info
|
||||
* @param {LionRequestInit} [init]
|
||||
|
|
@ -149,7 +156,7 @@ export class Ajax {
|
|||
lionInit.body = JSON.stringify(lionInit.body);
|
||||
}
|
||||
|
||||
// Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit
|
||||
// typecast LionRequestInit back to RequestInit
|
||||
const jsonInit = /** @type {RequestInit} */ (lionInit);
|
||||
const response = await this.fetch(info, jsonInit);
|
||||
let responseText = await response.text();
|
||||
|
|
|
|||
65
packages/ajax/src/Cache.js
Normal file
65
packages/ajax/src/Cache.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import './typedef.js';
|
||||
|
||||
export default class Cache {
|
||||
constructor() {
|
||||
/**
|
||||
* @type {{ [requestId: string]: { createdAt: number, response: CacheResponse } }}
|
||||
* @private
|
||||
*/
|
||||
this._cachedRequests = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an item in the cache
|
||||
* @param {string} requestId key by which the request is stored
|
||||
* @param {Response} response the cached response
|
||||
*/
|
||||
set(requestId, response) {
|
||||
this._cachedRequests[requestId] = {
|
||||
createdAt: Date.now(),
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an item from the cache
|
||||
* @param {string} requestId key by which the cache is stored
|
||||
* @param {number} maxAge maximum age of a cached request to serve from cache, in milliseconds
|
||||
* @returns {CacheResponse | undefined}
|
||||
*/
|
||||
get(requestId, maxAge = 0) {
|
||||
const cachedRequest = this._cachedRequests[requestId];
|
||||
if (!cachedRequest) {
|
||||
return;
|
||||
}
|
||||
const cachedRequestAge = Date.now() - cachedRequest.createdAt;
|
||||
if (Number.isFinite(maxAge) && cachedRequestAge < maxAge) {
|
||||
// eslint-disable-next-line consistent-return
|
||||
return cachedRequest.response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the item with the given requestId from the cache
|
||||
* @param {string } requestId the request id to delete from the cache
|
||||
*/
|
||||
delete(requestId) {
|
||||
delete this._cachedRequests[requestId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all items from the cache that match given regex
|
||||
* @param {RegExp} regex a regular expression to match cache entries
|
||||
*/
|
||||
deleteMatching(regex) {
|
||||
Object.keys(this._cachedRequests).forEach(requestId => {
|
||||
if (new RegExp(regex).test(requestId)) {
|
||||
this.delete(requestId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._cachedRequests = {};
|
||||
}
|
||||
}
|
||||
62
packages/ajax/src/PendingRequestStore.js
Normal file
62
packages/ajax/src/PendingRequestStore.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import './typedef.js';
|
||||
|
||||
export default class PendingRequestStore {
|
||||
constructor() {
|
||||
/**
|
||||
* @type {{ [requestId: string]: { promise: Promise<void>, resolve: (value?: any) => void } }}
|
||||
* @private
|
||||
*/
|
||||
this._pendingRequests = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise for a pending request with given key
|
||||
* @param {string} requestId
|
||||
*/
|
||||
set(requestId) {
|
||||
if (this._pendingRequests[requestId]) {
|
||||
return;
|
||||
}
|
||||
/** @type {(value?: any) => void } */
|
||||
let resolve;
|
||||
const promise = new Promise(_resolve => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
// @ts-ignore
|
||||
this._pendingRequests[requestId] = { promise, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the promise for a pending request with given key
|
||||
* @param {string} requestId
|
||||
* @returns {Promise<void> | undefined}
|
||||
*/
|
||||
get(requestId) {
|
||||
return this._pendingRequests[requestId]?.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the promise of a pending request that matches the given string
|
||||
* @param { string } requestId the requestId to resolve
|
||||
*/
|
||||
resolve(requestId) {
|
||||
this._pendingRequests[requestId]?.resolve();
|
||||
delete this._pendingRequests[requestId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the promise of pending requests that match the given regex
|
||||
* @param { RegExp } regex an regular expression to match store entries
|
||||
*/
|
||||
resolveMatching(regex) {
|
||||
Object.keys(this._pendingRequests).forEach(pendingRequestId => {
|
||||
if (regex.test(pendingRequestId)) {
|
||||
this.resolve(pendingRequestId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._pendingRequests = {};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
/* eslint-disable consistent-return */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import './typedef.js';
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = SECOND * 60;
|
||||
const HOUR = MINUTE * 60;
|
||||
const DEFAULT_TIME_TO_LIVE = HOUR;
|
||||
|
||||
class Cache {
|
||||
constructor() {
|
||||
this.expiration = new Date().getTime() + DEFAULT_TIME_TO_LIVE;
|
||||
/**
|
||||
* @type {{[url: string]: {expires: number, response: CacheResponse} }}
|
||||
* @private
|
||||
*/
|
||||
this._cacheObject = {};
|
||||
/**
|
||||
* @type {{ [url: string]: { promise: Promise<void>, resolve: (v?: any) => void } }}
|
||||
* @private
|
||||
*/
|
||||
this._pendingRequests = {};
|
||||
}
|
||||
|
||||
/** @param {string} url */
|
||||
setPendingRequest(url) {
|
||||
/** @type {(v: any) => void} */
|
||||
let resolve = () => {};
|
||||
const promise = new Promise(_resolve => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
this._pendingRequests[url] = { promise, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Promise<void> | undefined}
|
||||
*/
|
||||
getPendingRequest(url) {
|
||||
if (this._pendingRequests[url]) {
|
||||
return this._pendingRequests[url].promise;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} url */
|
||||
resolvePendingRequest(url) {
|
||||
if (this._pendingRequests[url]) {
|
||||
this._pendingRequests[url].resolve();
|
||||
delete this._pendingRequests[url];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an item in the cache
|
||||
* @param {string} url key by which the cache is stored
|
||||
* @param {Response} response the cached response
|
||||
*/
|
||||
set(url, response) {
|
||||
this._validateCache();
|
||||
this._cacheObject[url] = {
|
||||
expires: new Date().getTime(),
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an item from the cache
|
||||
* @param {string} url key by which the cache is stored
|
||||
* @param {number} timeToLive maximum time to allow cache to live
|
||||
* @returns {CacheResponse | false}
|
||||
*/
|
||||
get(url, timeToLive) {
|
||||
this._validateCache();
|
||||
|
||||
const cacheResult = this._cacheObject[url];
|
||||
if (!cacheResult) {
|
||||
return false;
|
||||
}
|
||||
const cacheAge = new Date().getTime() - cacheResult.expires;
|
||||
|
||||
if (timeToLive !== null && cacheAge > timeToLive) {
|
||||
return false;
|
||||
}
|
||||
return cacheResult.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all items from the cache that contain the given url
|
||||
* @param {string} url key by which the cache is stored
|
||||
*/
|
||||
delete(url) {
|
||||
this._validateCache();
|
||||
|
||||
Object.keys(this._cacheObject).forEach(key => {
|
||||
if (key.includes(url)) {
|
||||
delete this._cacheObject[key];
|
||||
this.resolvePendingRequest(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all items from the cache that match given regex
|
||||
* @param {RegExp} regex an regular expression to match cache entries
|
||||
*/
|
||||
deleteMatched(regex) {
|
||||
this._validateCache();
|
||||
|
||||
Object.keys(this._cacheObject).forEach(key => {
|
||||
const notMatch = !new RegExp(regex).test(key);
|
||||
if (notMatch) return;
|
||||
delete this._cacheObject[key];
|
||||
this.resolvePendingRequest(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cache on each call to the Cache
|
||||
* When the expiration date has passed, the _cacheObject will be replaced by an
|
||||
* empty object
|
||||
* @protected
|
||||
*/
|
||||
_validateCache() {
|
||||
if (new Date().getTime() > this.expiration) {
|
||||
this._cacheObject = {};
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let caches = {};
|
||||
|
||||
/**
|
||||
* Serialize search parameters into url query string parameters.
|
||||
* If params === null, returns ''
|
||||
* @param {Params} params query string parameters object
|
||||
* @returns {string} of querystring parameters WITHOUT `?` or empty string ''
|
||||
*/
|
||||
export const stringifySearchParams = (params = {}) =>
|
||||
typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : '';
|
||||
|
||||
/**
|
||||
* Returns the active cache instance for the current session
|
||||
* If 'cacheIdentifier' changes the cache is reset, we avoid situation of accessing old cache
|
||||
* and proactively clean it
|
||||
* @param {string} cacheIdentifier usually the refreshToken of the owner of the cache
|
||||
*/
|
||||
export const getCache = cacheIdentifier => {
|
||||
if (caches[cacheIdentifier]?._validateCache()) {
|
||||
return caches[cacheIdentifier];
|
||||
}
|
||||
// invalidate old caches
|
||||
caches = {};
|
||||
// create new cache
|
||||
caches[cacheIdentifier] = new Cache();
|
||||
return caches[cacheIdentifier];
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {CacheOptions} options Options to match cache
|
||||
* @returns {ValidatedCacheOptions}
|
||||
*/
|
||||
export const validateCacheOptions = ({
|
||||
useCache = false,
|
||||
methods = ['get'],
|
||||
timeToLive,
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
requestIdentificationFn,
|
||||
}) => {
|
||||
// validate 'cache'
|
||||
if (typeof useCache !== 'boolean') {
|
||||
throw new Error('Property `useCache` should be `true` or `false`');
|
||||
}
|
||||
|
||||
if (methods[0] !== 'get' || methods.length !== 1) {
|
||||
throw new Error('Functionality to use cache on anything except "get" is not yet supported');
|
||||
}
|
||||
|
||||
// validate 'timeToLive', default 1 hour
|
||||
if (timeToLive === undefined) {
|
||||
timeToLive = DEFAULT_TIME_TO_LIVE;
|
||||
}
|
||||
if (Number.isNaN(parseInt(String(timeToLive), 10))) {
|
||||
throw new Error('Property `timeToLive` must be of type `number`');
|
||||
}
|
||||
// validate 'invalidateUrls', must be an `Array` or `falsy`
|
||||
if (invalidateUrls) {
|
||||
if (!Array.isArray(invalidateUrls)) {
|
||||
throw new Error('Property `invalidateUrls` must be of type `Array` or `falsy`');
|
||||
}
|
||||
}
|
||||
// validate 'invalidateUrlsRegex', must be an regex expression or `falsy`
|
||||
if (invalidateUrlsRegex) {
|
||||
if (!(invalidateUrlsRegex instanceof RegExp)) {
|
||||
throw new Error('Property `invalidateUrlsRegex` must be of type `RegExp` or `falsy`');
|
||||
}
|
||||
}
|
||||
// validate 'requestIdentificationFn', default is url + searchParams
|
||||
if (requestIdentificationFn) {
|
||||
if (typeof requestIdentificationFn !== 'function') {
|
||||
throw new Error('Property `requestIdentificationFn` must be of type `function`');
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-shadow
|
||||
requestIdentificationFn = /** @param {any} data */ ({ url, params }, stringifySearchParams) => {
|
||||
const serializedParams = stringifySearchParams(params);
|
||||
return serializedParams ? `${url}?${serializedParams}` : url;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
useCache,
|
||||
methods,
|
||||
timeToLive,
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
requestIdentificationFn,
|
||||
};
|
||||
};
|
||||
157
packages/ajax/src/cacheManager.js
Normal file
157
packages/ajax/src/cacheManager.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import './typedef.js';
|
||||
import Cache from './Cache.js';
|
||||
import PendingRequestStore from './PendingRequestStore.js';
|
||||
|
||||
/**
|
||||
* The id for the cache session
|
||||
* @type {string | undefined}
|
||||
*/
|
||||
let cacheSessionId;
|
||||
|
||||
/**
|
||||
* The ajax cache
|
||||
* @type {Cache}
|
||||
*/
|
||||
export const ajaxCache = new Cache();
|
||||
|
||||
/**
|
||||
* The pending request store
|
||||
* @type {PendingRequestStore}
|
||||
*/
|
||||
export const pendingRequestStore = new PendingRequestStore();
|
||||
|
||||
/**
|
||||
* Checks whether the given cacheSessionId matches the currently active id.
|
||||
*
|
||||
* @param {string|undefined} cacheId The cache id to check
|
||||
*/
|
||||
export const isCurrentSessionId = cacheId => cacheId === cacheSessionId;
|
||||
|
||||
/**
|
||||
* Resets the cache session when the cacheId changes.
|
||||
*
|
||||
* There can be only 1 active session at all times.
|
||||
* @param {string} cacheId The cache id that is tied to the current session
|
||||
*/
|
||||
export const resetCacheSession = cacheId => {
|
||||
if (!cacheId) {
|
||||
throw new Error('Invalid cache identifier');
|
||||
}
|
||||
if (!isCurrentSessionId(cacheId)) {
|
||||
cacheSessionId = cacheId;
|
||||
ajaxCache.reset();
|
||||
pendingRequestStore.reset();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stringify URL search params
|
||||
* @param {Params} params query string parameters object
|
||||
* @returns {string} of querystring parameters WITHOUT `?` or empty string ''
|
||||
*/
|
||||
const stringifySearchParams = (params = {}) =>
|
||||
typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : '';
|
||||
|
||||
/**
|
||||
* Returns request key string, which uniquely identifies a Request
|
||||
* @param {Partial<CacheRequest>} request Request object
|
||||
* @param {function} serializeSearchParams Function to serialize URL search params
|
||||
* @returns {string} requestId to uniquely identify a request
|
||||
*/
|
||||
const DEFAULT_GET_REQUEST_ID = (
|
||||
{ url = '', params },
|
||||
serializeSearchParams = stringifySearchParams,
|
||||
) => {
|
||||
const serializedParams = serializeSearchParams(params);
|
||||
return serializedParams ? `${url}?${serializedParams}` : url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defaults to 1 hour
|
||||
*/
|
||||
const DEFAULT_MAX_AGE = 1000 * 60 * 60;
|
||||
|
||||
/**
|
||||
* @param {CacheOptions} options Cache options
|
||||
* @returns {ValidatedCacheOptions}
|
||||
*/
|
||||
export const extendCacheOptions = ({
|
||||
useCache = false,
|
||||
methods = ['get'],
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
requestIdFunction = DEFAULT_GET_REQUEST_ID,
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
}) => ({
|
||||
useCache,
|
||||
methods,
|
||||
maxAge,
|
||||
requestIdFunction,
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {CacheOptions} options Cache options
|
||||
*/
|
||||
export const validateCacheOptions = ({
|
||||
useCache,
|
||||
methods,
|
||||
maxAge,
|
||||
requestIdFunction,
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
} = {}) => {
|
||||
if (useCache !== undefined && typeof useCache !== 'boolean') {
|
||||
throw new Error('Property `useCache` must be a `boolean`');
|
||||
}
|
||||
if (methods !== undefined && JSON.stringify(methods) !== JSON.stringify(['get'])) {
|
||||
throw new Error('Cache can only be utilized with `GET` method');
|
||||
}
|
||||
if (maxAge !== undefined && !Number.isFinite(maxAge)) {
|
||||
throw new Error('Property `maxAge` must be a finite `number`');
|
||||
}
|
||||
if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) {
|
||||
throw new Error('Property `invalidateUrls` must be an `Array` or `falsy`');
|
||||
}
|
||||
if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) {
|
||||
throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`');
|
||||
}
|
||||
if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') {
|
||||
throw new Error('Property `requestIdFunction` must be a `function`');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidates matching requestIds in the cache and pendingRequestStore
|
||||
*
|
||||
* There are two kinds of invalidate rules:
|
||||
* invalidateUrls (array of URL like strings)
|
||||
* invalidateUrlsRegex (RegExp)
|
||||
* If a non-GET method is fired, by default it only invalidates its own endpoint.
|
||||
* Invalidating /api/users cache by doing a PATCH, will not invalidate /api/accounts cache.
|
||||
* However, in the case of users and accounts, they may be very interconnected,
|
||||
* so perhaps you do want to invalidate /api/accounts when invalidating /api/users.
|
||||
* If it's NOT one of the config.methods, invalidate caches
|
||||
*
|
||||
* @param requestId { string }
|
||||
* @param cacheOptions { CacheOptions }
|
||||
*/
|
||||
export const invalidateMatchingCache = (requestId, { invalidateUrls, invalidateUrlsRegex }) => {
|
||||
// invalidate this request
|
||||
ajaxCache.delete(requestId);
|
||||
pendingRequestStore.resolve(requestId);
|
||||
|
||||
// also invalidate caches matching to invalidateUrls
|
||||
if (Array.isArray(invalidateUrls)) {
|
||||
invalidateUrls.forEach(url => {
|
||||
ajaxCache.delete(url);
|
||||
pendingRequestStore.resolve(url);
|
||||
});
|
||||
}
|
||||
// also invalidate caches matching to invalidateUrlsRegex
|
||||
if (invalidateUrlsRegex) {
|
||||
ajaxCache.deleteMatching(invalidateUrlsRegex);
|
||||
pendingRequestStore.resolveMatching(invalidateUrlsRegex);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,137 +1,112 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import '../typedef.js';
|
||||
import { validateCacheOptions, stringifySearchParams, getCache } from '../cache.js';
|
||||
import {
|
||||
ajaxCache,
|
||||
resetCacheSession,
|
||||
extendCacheOptions,
|
||||
validateCacheOptions,
|
||||
invalidateMatchingCache,
|
||||
pendingRequestStore,
|
||||
isCurrentSessionId,
|
||||
} from '../cacheManager.js';
|
||||
|
||||
/**
|
||||
* Request interceptor to return relevant cached requests
|
||||
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||
* @param {function(): string} getCacheId used to invalidate cache if identifier is changed
|
||||
* @param {CacheOptions} globalCacheOptions
|
||||
* @returns {RequestInterceptor}
|
||||
*/
|
||||
const createCacheRequestInterceptor = (getCacheIdentifier, globalCacheOptions) => {
|
||||
const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions);
|
||||
const createCacheRequestInterceptor =
|
||||
(getCacheId, globalCacheOptions) => /** @param {CacheRequest} request */ async request => {
|
||||
validateCacheOptions(request.cacheOptions);
|
||||
const cacheSessionId = getCacheId();
|
||||
resetCacheSession(cacheSessionId); // cacheSessionId is used to bind the cache to the current session
|
||||
|
||||
return /** @param {CacheRequest} cacheRequest */ async cacheRequest => {
|
||||
const cacheOptions = validateCacheOptions({
|
||||
...validatedInitialCacheOptions,
|
||||
...cacheRequest.cacheOptions,
|
||||
const cacheOptions = extendCacheOptions({
|
||||
...globalCacheOptions,
|
||||
...request.cacheOptions,
|
||||
});
|
||||
|
||||
cacheRequest.cacheOptions = cacheOptions;
|
||||
// store cacheOptions and cacheSessionId in the request, to use it in the response interceptor.
|
||||
request.cacheOptions = cacheOptions;
|
||||
request.cacheSessionId = cacheSessionId;
|
||||
|
||||
// don't use cache if 'useCache' === false
|
||||
if (!cacheOptions.useCache) {
|
||||
return cacheRequest;
|
||||
return request;
|
||||
}
|
||||
|
||||
const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, stringifySearchParams);
|
||||
// cacheIdentifier is used to bind the cache to the current session
|
||||
const currentCache = getCache(getCacheIdentifier());
|
||||
const { method } = cacheRequest;
|
||||
const requestId = cacheOptions.requestIdFunction(request);
|
||||
const isMethodSupported = cacheOptions.methods.includes(request.method.toLowerCase());
|
||||
|
||||
// don't use cache if the request method is not part of the configs methods
|
||||
if (!cacheOptions.methods.includes(method.toLowerCase())) {
|
||||
// If it's NOT one of the config.methods, invalidate caches
|
||||
currentCache.delete(cacheId);
|
||||
// also invalidate caches matching to cacheOptions
|
||||
if (cacheOptions.invalidateUrls) {
|
||||
cacheOptions.invalidateUrls.forEach(
|
||||
/** @type {string} */ invalidateUrl => {
|
||||
currentCache.delete(invalidateUrl);
|
||||
},
|
||||
);
|
||||
}
|
||||
// also invalidate caches matching to invalidateUrlsRegex
|
||||
if (cacheOptions.invalidateUrlsRegex) {
|
||||
currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex);
|
||||
}
|
||||
|
||||
return cacheRequest;
|
||||
if (!isMethodSupported) {
|
||||
invalidateMatchingCache(requestId, cacheOptions);
|
||||
return request;
|
||||
}
|
||||
|
||||
const pendingRequest = currentCache.getPendingRequest(cacheId);
|
||||
const pendingRequest = pendingRequestStore.get(requestId);
|
||||
if (pendingRequest) {
|
||||
// there is another concurrent request, wait for it to finish
|
||||
await pendingRequest;
|
||||
}
|
||||
|
||||
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
|
||||
if (cacheResponse) {
|
||||
cacheRequest.cacheOptions = cacheRequest.cacheOptions ?? { useCache: false };
|
||||
const response = /** @type {CacheResponse} */ cacheResponse.clone();
|
||||
response.request = cacheRequest;
|
||||
const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge);
|
||||
if (cachedResponse) {
|
||||
// Return the response from cache
|
||||
request.cacheOptions = request.cacheOptions ?? { useCache: false };
|
||||
/** @type {CacheResponse} */
|
||||
const response = cachedResponse.clone();
|
||||
response.request = request;
|
||||
response.fromCache = true;
|
||||
return response;
|
||||
}
|
||||
|
||||
// we do want to use caching for this requesting, but it's not already cached
|
||||
// mark this as a pending request, so that concurrent requests can reuse it from the cache
|
||||
currentCache.setPendingRequest(cacheId);
|
||||
|
||||
return cacheRequest;
|
||||
// Mark this as a pending request, so that concurrent requests can use the response from this request
|
||||
pendingRequestStore.set(requestId);
|
||||
return request;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Response interceptor to cache relevant requests
|
||||
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||
* @param {CacheOptions} globalCacheOptions
|
||||
* @returns {ResponseInterceptor}
|
||||
*/
|
||||
const createCacheResponseInterceptor = (getCacheIdentifier, globalCacheOptions) => {
|
||||
const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions);
|
||||
|
||||
/**
|
||||
* Axios response https://github.com/axios/axios#response-schema
|
||||
*/
|
||||
return /** @param {CacheResponse} cacheResponse */ async cacheResponse => {
|
||||
if (!getCacheIdentifier()) {
|
||||
throw new Error(`getCacheIdentifier returns falsy`);
|
||||
const createCacheResponseInterceptor =
|
||||
globalCacheOptions => /** @param {CacheResponse} response */ async response => {
|
||||
if (!response.request) {
|
||||
throw new Error('Missing request in response');
|
||||
}
|
||||
|
||||
if (!cacheResponse.request) {
|
||||
throw new Error('Missing request in response.');
|
||||
}
|
||||
|
||||
const cacheOptions = validateCacheOptions({
|
||||
...validatedInitialCacheOptions,
|
||||
...cacheResponse.request?.cacheOptions,
|
||||
const cacheOptions = extendCacheOptions({
|
||||
...globalCacheOptions,
|
||||
...response.request.cacheOptions,
|
||||
});
|
||||
|
||||
// string that identifies cache entry
|
||||
const cacheId = cacheOptions.requestIdentificationFn(
|
||||
cacheResponse.request,
|
||||
stringifySearchParams,
|
||||
);
|
||||
const currentCache = getCache(getCacheIdentifier());
|
||||
const isAlreadyFromCache = !!cacheResponse.fromCache;
|
||||
// caching all responses with not default `timeToLive`
|
||||
const isCacheActive = cacheOptions.timeToLive > 0;
|
||||
const isMethodSupported = cacheOptions.methods.includes(
|
||||
cacheResponse.request.method.toLowerCase(),
|
||||
);
|
||||
// if the request is one of the options.methods; store response in cache
|
||||
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
|
||||
// store the response data in the cache and mark request as resolved
|
||||
currentCache.set(cacheId, cacheResponse.clone());
|
||||
}
|
||||
const requestId = cacheOptions.requestIdFunction(response.request);
|
||||
const isAlreadyFromCache = !!response.fromCache;
|
||||
const isCacheActive = cacheOptions.useCache;
|
||||
const isMethodSupported = cacheOptions.methods.includes(response.request?.method.toLowerCase());
|
||||
|
||||
currentCache.resolvePendingRequest(cacheId);
|
||||
return cacheResponse;
|
||||
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
|
||||
if (isCurrentSessionId(response.request.cacheSessionId)) {
|
||||
// Cache the response
|
||||
ajaxCache.set(requestId, response.clone());
|
||||
}
|
||||
|
||||
// Mark the pending request as resolved
|
||||
pendingRequestStore.resolve(requestId);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Response interceptor to cache relevant requests
|
||||
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||
* @param {function(): string} getCacheId used to invalidate cache if identifier is changed
|
||||
* @param {CacheOptions} globalCacheOptions
|
||||
* @returns [{RequestInterceptor}, {ResponseInterceptor}]
|
||||
* @returns {{cacheRequestInterceptor: RequestInterceptor, cacheResponseInterceptor: ResponseInterceptor}}
|
||||
*/
|
||||
export const createCacheInterceptors = (getCacheIdentifier, globalCacheOptions) => {
|
||||
const requestInterceptor = createCacheRequestInterceptor(getCacheIdentifier, globalCacheOptions);
|
||||
const responseInterceptor = createCacheResponseInterceptor(
|
||||
getCacheIdentifier,
|
||||
globalCacheOptions,
|
||||
);
|
||||
return [requestInterceptor, responseInterceptor];
|
||||
export const createCacheInterceptors = (getCacheId, globalCacheOptions) => {
|
||||
validateCacheOptions(globalCacheOptions);
|
||||
const cacheRequestInterceptor = createCacheRequestInterceptor(getCacheId, globalCacheOptions);
|
||||
const cacheResponseInterceptor = createCacheResponseInterceptor(globalCacheOptions);
|
||||
return { cacheRequestInterceptor, cacheResponseInterceptor };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor
|
||||
* @typedef {import('../types/types').CacheConfig} CacheConfig
|
||||
* @typedef {import('../types/types').Params} Params
|
||||
* @typedef {import('../types/types').RequestIdentificationFn} RequestIdentificationFn
|
||||
* @typedef {import('../types/types').RequestIdFunction} RequestIdFunction
|
||||
* @typedef {import('../types/types').CacheOptions} CacheOptions
|
||||
* @typedef {import('../types/types').ValidatedCacheOptions} ValidatedCacheOptions
|
||||
* @typedef {import('../types/types').CacheRequestExtension} CacheRequestExtension
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ describe('Ajax', () => {
|
|||
jsonPrefix: ")]}',",
|
||||
cacheOptions: {
|
||||
useCache: true,
|
||||
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||
maxAge: 1000 * 60 * 5, // 5 minutes
|
||||
getCacheIdentifier,
|
||||
},
|
||||
};
|
||||
|
|
@ -37,7 +37,7 @@ describe('Ajax', () => {
|
|||
jsonPrefix: ")]}',",
|
||||
cacheOptions: {
|
||||
useCache: true,
|
||||
timeToLive: 300000,
|
||||
maxAge: 300000,
|
||||
getCacheIdentifier,
|
||||
},
|
||||
};
|
||||
|
|
@ -53,7 +53,7 @@ describe('Ajax', () => {
|
|||
const config = {
|
||||
cacheOptions: {
|
||||
useCache: true,
|
||||
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||
maxAge: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
};
|
||||
// When
|
||||
|
|
@ -288,7 +288,7 @@ describe('Ajax', () => {
|
|||
const customAjax = new Ajax({
|
||||
cacheOptions: {
|
||||
useCache: true,
|
||||
timeToLive: 100,
|
||||
maxAge: 100,
|
||||
getCacheIdentifier,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
196
packages/ajax/test/Cache.test.js
Normal file
196
packages/ajax/test/Cache.test.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// @ts-nocheck
|
||||
import { expect } from '@open-wc/testing';
|
||||
import Cache from '../src/Cache.js';
|
||||
|
||||
const A_MINUTE_IN_MS = 60 * 1000;
|
||||
const TWO_MINUTES_IN_MS = 2 * A_MINUTE_IN_MS;
|
||||
const TEN_MINUTES_IN_MS = 10 * A_MINUTE_IN_MS;
|
||||
|
||||
describe('Cache', () => {
|
||||
describe('public interface', () => {
|
||||
const cache = new Cache();
|
||||
|
||||
it('Cache has `set` method', () => {
|
||||
expect(cache.set).to.exist;
|
||||
});
|
||||
|
||||
it('Cache has `get` method', () => {
|
||||
expect(cache.get).to.exist;
|
||||
});
|
||||
|
||||
it('Cache has `delete` method', () => {
|
||||
expect(cache.delete).to.exist;
|
||||
});
|
||||
|
||||
it('Cache has `reset` method', () => {
|
||||
expect(cache.reset).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache.get', () => {
|
||||
// Mock cache data
|
||||
const cache = new Cache();
|
||||
|
||||
cache._cachedRequests = {
|
||||
requestId1: { createdAt: Date.now() - TWO_MINUTES_IN_MS, response: 'cached data 1' },
|
||||
requestId2: { createdAt: Date.now(), response: 'cached data 2' },
|
||||
};
|
||||
|
||||
it('returns undefined if no cached request found for requestId', () => {
|
||||
// Given
|
||||
const maxAge = TEN_MINUTES_IN_MS;
|
||||
const expected = undefined;
|
||||
// When
|
||||
const result = cache.get('nonCachedRequestId', maxAge);
|
||||
// Then
|
||||
expect(result).to.equal(expected);
|
||||
});
|
||||
|
||||
it('returns undefined if maxAge is not a number', () => {
|
||||
// Given
|
||||
const maxAge = 'some string';
|
||||
const expected = undefined;
|
||||
// When
|
||||
const result = cache.get('requestId1', maxAge);
|
||||
// Then
|
||||
expect(result).to.equal(expected);
|
||||
});
|
||||
|
||||
it('returns undefined if maxAge is not finite', () => {
|
||||
// Given
|
||||
const maxAge = 1 / 0;
|
||||
const expected = undefined;
|
||||
// When
|
||||
const result = cache.get('requestId1', maxAge);
|
||||
// Then
|
||||
expect(result).to.equal(expected);
|
||||
});
|
||||
|
||||
it('returns undefined if maxAge is negative', () => {
|
||||
// Given
|
||||
const maxAge = -10;
|
||||
const expected = undefined;
|
||||
// When
|
||||
const result = cache.get('requestId1', maxAge);
|
||||
// Then
|
||||
expect(result).to.equal(expected);
|
||||
});
|
||||
|
||||
it('returns undefined if cached request age is not less than maxAge', () => {
|
||||
// Given
|
||||
const maxAge = A_MINUTE_IN_MS;
|
||||
const expected = undefined;
|
||||
// When
|
||||
const result = cache.get('requestId1', maxAge);
|
||||
// Then
|
||||
expect(result).to.equal(expected);
|
||||
});
|
||||
|
||||
it('gets the cached request by requestId if cached request age is less than maxAge', () => {
|
||||
// Given
|
||||
const maxAge = TEN_MINUTES_IN_MS;
|
||||
const expected = cache._cachedRequests?.requestId1?.response;
|
||||
// When
|
||||
const result = cache.get('requestId1', maxAge);
|
||||
// Then
|
||||
expect(result).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache.set', () => {
|
||||
it('stores the `response` for the given `requestId`', () => {
|
||||
// Given
|
||||
const cache = new Cache();
|
||||
const maxAge = TEN_MINUTES_IN_MS;
|
||||
const response1 = 'response of request1';
|
||||
const response2 = 'response of request2';
|
||||
// When
|
||||
cache.set('requestId1', response1);
|
||||
cache.set('requestId2', response2);
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
||||
});
|
||||
|
||||
it('updates the `response` for the given `requestId`, if already cached', () => {
|
||||
// Given
|
||||
const cache = new Cache();
|
||||
const maxAge = TEN_MINUTES_IN_MS;
|
||||
const response = 'response of request1';
|
||||
const updatedResponse = 'updated response of request1';
|
||||
// When
|
||||
cache.set('requestId1', response);
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.equal(response);
|
||||
// When
|
||||
cache.set('requestId1', updatedResponse);
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.equal(updatedResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache.delete', () => {
|
||||
it('deletes cache by `requestId`', () => {
|
||||
// Given
|
||||
const cache = new Cache();
|
||||
const maxAge = TEN_MINUTES_IN_MS;
|
||||
const response1 = 'response of request1';
|
||||
const response2 = 'response of request2';
|
||||
// When
|
||||
cache.set('requestId1', response1);
|
||||
cache.set('requestId2', response2);
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
||||
// When
|
||||
cache.delete('requestId1');
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.be.undefined;
|
||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
||||
});
|
||||
|
||||
it('deletes cache by regex', () => {
|
||||
// Given
|
||||
const cache = new Cache();
|
||||
const maxAge = TEN_MINUTES_IN_MS;
|
||||
const response1 = 'response of request1';
|
||||
const response2 = 'response of request2';
|
||||
const response3 = 'response of request3';
|
||||
// When
|
||||
cache.set('requestId1', response1);
|
||||
cache.set('requestId2', response2);
|
||||
cache.set('anotherRequestId', response3);
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
||||
expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
|
||||
// When
|
||||
cache.deleteMatching(/^requestId/);
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.be.undefined;
|
||||
expect(cache.get('requestId2', maxAge)).to.be.undefined;
|
||||
expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache.reset', () => {
|
||||
it('resets the cache', () => {
|
||||
// Given
|
||||
const cache = new Cache();
|
||||
const maxAge = TEN_MINUTES_IN_MS;
|
||||
const response1 = 'response of request1';
|
||||
const response2 = 'response of request2';
|
||||
// When
|
||||
cache.set('requestId1', response1);
|
||||
cache.set('requestId2', response2);
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
||||
// When
|
||||
cache.reset();
|
||||
// Then
|
||||
expect(cache.get('requestId1', maxAge)).to.be.undefined;
|
||||
expect(cache.get('requestId2', maxAge)).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
159
packages/ajax/test/PendingRequestStore.test.js
Normal file
159
packages/ajax/test/PendingRequestStore.test.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// @ts-nocheck
|
||||
import { expect } from '@open-wc/testing';
|
||||
import PendingRequestStore from '../src/PendingRequestStore.js';
|
||||
|
||||
describe('PendingRequestStore', () => {
|
||||
let pendingRequestStore;
|
||||
|
||||
beforeEach(() => {
|
||||
pendingRequestStore = new PendingRequestStore();
|
||||
});
|
||||
|
||||
describe('public interface', () => {
|
||||
it('PendingRequestStore has `set` method', () => {
|
||||
expect(pendingRequestStore.set).to.exist;
|
||||
});
|
||||
|
||||
it('PendingRequestStore has `get` method', () => {
|
||||
expect(pendingRequestStore.get).to.exist;
|
||||
});
|
||||
|
||||
it('PendingRequestStore has `resolve` method', () => {
|
||||
expect(pendingRequestStore.resolve).to.exist;
|
||||
});
|
||||
|
||||
it('PendingRequestStore has `reset` method', () => {
|
||||
expect(pendingRequestStore.reset).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getting and setting', () => {
|
||||
it('will return undefined for an unknown key', () => {
|
||||
expect(pendingRequestStore.get('unknown-key')).to.be.undefined;
|
||||
});
|
||||
|
||||
it('will return a promise for a key that has been added earlier', () => {
|
||||
// Given
|
||||
pendingRequestStore.set('a-key');
|
||||
|
||||
// Then
|
||||
expect(pendingRequestStore.get('a-key')).to.be.a('Promise');
|
||||
});
|
||||
|
||||
it('will not replace an already known entry', () => {
|
||||
// Given
|
||||
pendingRequestStore.set('the-original-key');
|
||||
const theOriginalPromise = pendingRequestStore.get('the-original-key');
|
||||
|
||||
// When
|
||||
pendingRequestStore.set('the-original-key');
|
||||
|
||||
// Then
|
||||
expect(pendingRequestStore.get('the-original-key')).to.equal(theOriginalPromise);
|
||||
});
|
||||
|
||||
it('will return the same promise when retrieved twice', () => {
|
||||
// Given
|
||||
pendingRequestStore.set('a-key');
|
||||
|
||||
// When
|
||||
const a1 = pendingRequestStore.get('a-key');
|
||||
const a2 = pendingRequestStore.get('a-key');
|
||||
|
||||
// Then
|
||||
expect(a1).to.equal(a2);
|
||||
});
|
||||
|
||||
it('will return undefined when the store is reset', () => {
|
||||
// Given
|
||||
pendingRequestStore.set('a-key');
|
||||
|
||||
// When
|
||||
pendingRequestStore.reset();
|
||||
|
||||
// Then
|
||||
expect(pendingRequestStore.get('a-key')).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving', () => {
|
||||
it('will resolve a named promise and delete it', async () => {
|
||||
// Given
|
||||
pendingRequestStore.set('do-groceries');
|
||||
const backFromTheStore = pendingRequestStore
|
||||
.get('do-groceries')
|
||||
.catch(() => expect.fail('Promise was rejected before it could be resolved'));
|
||||
|
||||
// When
|
||||
pendingRequestStore.resolve('do-groceries');
|
||||
await backFromTheStore;
|
||||
|
||||
// Then
|
||||
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
|
||||
});
|
||||
|
||||
it('will use the same promise when retrieving or resolving the same key', async () => {
|
||||
// Given
|
||||
const fridge = [];
|
||||
|
||||
pendingRequestStore.set('do-groceries');
|
||||
pendingRequestStore.get('do-groceries').then(() => fridge.push('milk'));
|
||||
pendingRequestStore.get('do-groceries').then(() => fridge.push('eggs'));
|
||||
|
||||
const backFromTheStore = pendingRequestStore.get('do-groceries');
|
||||
|
||||
// When
|
||||
pendingRequestStore.resolve('do-groceries');
|
||||
await backFromTheStore;
|
||||
|
||||
// Then
|
||||
expect(fridge).to.contain('milk');
|
||||
expect(fridge).to.contain('eggs');
|
||||
|
||||
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving multiple requestIds by regular expression', () => {
|
||||
it('will resolve multiple promises matching a regular expression and delete them', async () => {
|
||||
// Given
|
||||
let canIPlayNow = false;
|
||||
|
||||
pendingRequestStore.set('do-dishes');
|
||||
pendingRequestStore.set('do-groceries');
|
||||
|
||||
const choresAllDone = Promise.all([
|
||||
pendingRequestStore.get('do-dishes'),
|
||||
pendingRequestStore.get('do-groceries'),
|
||||
]);
|
||||
|
||||
// When
|
||||
pendingRequestStore.resolveMatching(/^do-/);
|
||||
await choresAllDone.then(() => {
|
||||
canIPlayNow = true;
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(canIPlayNow).to.be.ok;
|
||||
|
||||
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
|
||||
expect(pendingRequestStore.get('do-dishes')).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
it('will leave unmatched requests alone when resolving', () => {
|
||||
// Given
|
||||
pendingRequestStore.set('do-dishes');
|
||||
pendingRequestStore.set('do-groceries');
|
||||
pendingRequestStore.set('ponder-meaning-of-life');
|
||||
|
||||
// When
|
||||
pendingRequestStore.resolveMatching(/^do-/);
|
||||
|
||||
// Then
|
||||
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
|
||||
expect(pendingRequestStore.get('do-dishes')).to.be.undefined;
|
||||
|
||||
expect(pendingRequestStore.get('ponder-meaning-of-life')).not.to.be.undefined;
|
||||
});
|
||||
});
|
||||
313
packages/ajax/test/cacheManager.test.js
Normal file
313
packages/ajax/test/cacheManager.test.js
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
// @ts-nocheck
|
||||
import { expect } from '@open-wc/testing';
|
||||
import * as sinon from 'sinon';
|
||||
import {
|
||||
ajaxCache,
|
||||
pendingRequestStore,
|
||||
resetCacheSession,
|
||||
extendCacheOptions,
|
||||
validateCacheOptions,
|
||||
invalidateMatchingCache,
|
||||
isCurrentSessionId,
|
||||
} from '../src/cacheManager.js';
|
||||
import Cache from '../src/Cache.js';
|
||||
import PendingRequestStore from '../src/PendingRequestStore.js';
|
||||
|
||||
describe('cacheManager', () => {
|
||||
describe('ajaxCache', () => {
|
||||
it('is an instance of the Cache class', () => {
|
||||
expect(ajaxCache).to.be.instanceOf(Cache);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pendingRequestStore', () => {
|
||||
it('is an instance of the PendingRequestStore class', () => {
|
||||
expect(pendingRequestStore).to.be.instanceOf(PendingRequestStore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCacheSession', () => {
|
||||
let ajaxCacheSpy;
|
||||
let pendingRequestStoreSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
ajaxCacheSpy = sinon.spy(ajaxCache, 'reset');
|
||||
pendingRequestStoreSpy = sinon.spy(pendingRequestStore, 'reset');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ajaxCacheSpy.restore();
|
||||
pendingRequestStoreSpy.restore();
|
||||
});
|
||||
|
||||
it('throws an Error when no cacheId is passed', () => {
|
||||
try {
|
||||
resetCacheSession();
|
||||
} catch (e) {
|
||||
expect(e).to.be.an.instanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it('assigns the passed cacheId to the cacheSessionId', () => {
|
||||
// Arrange
|
||||
const cacheId = 'a-new-cache-id';
|
||||
// Act
|
||||
resetCacheSession(cacheId);
|
||||
// Assert
|
||||
expect(ajaxCacheSpy.calledOnce).to.be.true;
|
||||
expect(pendingRequestStoreSpy.calledOnce).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('extendCacheOptions', () => {
|
||||
// Arrange
|
||||
const DEFAULT_MAX_AGE = 1000 * 60 * 60;
|
||||
const invalidateUrls = ['https://f00.bar/', 'https://share.ware/'];
|
||||
const invalidateUrlsRegex = /f00/;
|
||||
|
||||
it('returns object with default values', () => {
|
||||
// Act
|
||||
const {
|
||||
useCache,
|
||||
methods,
|
||||
maxAge,
|
||||
requestIdFunction,
|
||||
invalidateUrls: invalidateUrlsResult,
|
||||
invalidateUrlsRegex: invalidateUrlsRegexResult,
|
||||
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
|
||||
// Assert
|
||||
expect(useCache).to.be.false;
|
||||
expect(methods).to.eql(['get']);
|
||||
expect(maxAge).to.equal(DEFAULT_MAX_AGE);
|
||||
expect(typeof requestIdFunction).to.eql('function');
|
||||
expect(invalidateUrlsResult).to.equal(invalidateUrls);
|
||||
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
|
||||
});
|
||||
|
||||
it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
|
||||
// Arrange
|
||||
const { requestIdFunction } = extendCacheOptions({
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
});
|
||||
// Act
|
||||
expect(requestIdFunction).to.throw(TypeError);
|
||||
});
|
||||
|
||||
it('the DEFAULT_GET_REQUEST_ID function returns a url when URLSearchParams cannot be serialized', () => {
|
||||
// Arrange
|
||||
const { requestIdFunction } = extendCacheOptions({
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
});
|
||||
// Act
|
||||
const formattedUrl = requestIdFunction({
|
||||
url: 'http://f00.bar/',
|
||||
params: {},
|
||||
});
|
||||
// Assert
|
||||
expect(formattedUrl).to.equal('http://f00.bar/');
|
||||
});
|
||||
|
||||
it('the DEFAULT_GET_REQUEST_ID function returns a correctly formatted url with URLSearchParams', () => {
|
||||
// Arrange
|
||||
const { requestIdFunction } = extendCacheOptions({
|
||||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
});
|
||||
// Act
|
||||
const formattedUrl = requestIdFunction({
|
||||
url: 'http://f00.bar/',
|
||||
params: { f00: 'bar', bar: 'f00' },
|
||||
});
|
||||
// Assert
|
||||
expect(formattedUrl).to.equal('http://f00.bar/?f00=bar&bar=f00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCacheOptions', () => {
|
||||
it('does not accept null as argument', () => {
|
||||
expect(() => validateCacheOptions(null)).to.throw(TypeError);
|
||||
});
|
||||
it('accepts an empty object', () => {
|
||||
expect(() => validateCacheOptions({})).not.to.throw(
|
||||
'Property `useCache` must be a `boolean`',
|
||||
);
|
||||
});
|
||||
describe('the useCache property', () => {
|
||||
it('accepts a boolean', () => {
|
||||
expect(() => validateCacheOptions({ useCache: false })).not.to.throw;
|
||||
});
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw;
|
||||
});
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ useCache: '' })).to.throw(
|
||||
'Property `useCache` must be a `boolean`',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('the methods property', () => {
|
||||
it('accepts an array with the value `get`', () => {
|
||||
expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw;
|
||||
});
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ methods: undefined })).not.to.throw;
|
||||
});
|
||||
it('does not accept anything else', () => {
|
||||
expect(() => validateCacheOptions({ methods: [] })).to.throw(
|
||||
'Cache can only be utilized with `GET` method',
|
||||
);
|
||||
expect(() => validateCacheOptions({ methods: ['post'] })).to.throw(
|
||||
'Cache can only be utilized with `GET` method',
|
||||
);
|
||||
expect(() => validateCacheOptions({ methods: ['get', 'post'] })).to.throw(
|
||||
'Cache can only be utilized with `GET` method',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('the maxAge property', () => {
|
||||
it('accepts a finite number', () => {
|
||||
expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw;
|
||||
});
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw;
|
||||
});
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw(
|
||||
'Property `maxAge` must be a finite `number`',
|
||||
);
|
||||
expect(() => validateCacheOptions({ maxAge: Infinity })).to.throw(
|
||||
'Property `maxAge` must be a finite `number`',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('the invalidateUrls 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({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }),
|
||||
).not.to.throw;
|
||||
});
|
||||
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`',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('the invalidateUrlsRegex property', () => {
|
||||
it('accepts a regular expression', () => {
|
||||
expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ }))
|
||||
.not.to.throw;
|
||||
});
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw;
|
||||
});
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() =>
|
||||
validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }),
|
||||
).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`');
|
||||
});
|
||||
});
|
||||
describe('the requestIdFunction property', () => {
|
||||
it('accepts a function', () => {
|
||||
// @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions
|
||||
expect(() =>
|
||||
validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }),
|
||||
).not.to.throw;
|
||||
});
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw;
|
||||
});
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw(
|
||||
'Property `requestIdFunction` must be a `function`',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateMatchingCache', () => {
|
||||
beforeEach(() => {
|
||||
sinon.spy(ajaxCache, 'delete');
|
||||
sinon.spy(ajaxCache, 'deleteMatching');
|
||||
sinon.spy(pendingRequestStore, 'resolve');
|
||||
sinon.spy(pendingRequestStore, 'resolveMatching');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('calls delete on the ajaxCache and calls resolve on the pendingRequestStore', () => {
|
||||
// Arrange
|
||||
const requestId = 'request-id';
|
||||
// Act
|
||||
invalidateMatchingCache(requestId, {});
|
||||
// Assert
|
||||
expect(ajaxCache.delete).to.have.been.calledOnce;
|
||||
expect(pendingRequestStore.resolve.calledOnce).to.be.true;
|
||||
|
||||
expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
|
||||
expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
|
||||
});
|
||||
|
||||
it('calls invalidateMatching for all URL items in the invalidateUrls argument', () => {
|
||||
// Arrange
|
||||
const requestId = 'request-id';
|
||||
const invalidateUrls = ['https://f00.bar/'];
|
||||
// Act
|
||||
invalidateMatchingCache(requestId, { invalidateUrls });
|
||||
// Assert
|
||||
expect(ajaxCache.delete.calledTwice).to.be.true;
|
||||
expect(pendingRequestStore.resolve.calledTwice).to.be.true;
|
||||
|
||||
expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
|
||||
expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
|
||||
|
||||
expect(ajaxCache.delete.calledWith('https://f00.bar/')).to.be.true;
|
||||
expect(pendingRequestStore.resolve.calledWith('https://f00.bar/')).to.be.true;
|
||||
});
|
||||
|
||||
it('calls invalidateMatching when the invalidateUrlsRegex argument is passed', () => {
|
||||
// Arrange
|
||||
const requestId = 'request-id';
|
||||
const invalidateUrlsRegex = 'f00';
|
||||
// Act
|
||||
invalidateMatchingCache(requestId, { invalidateUrlsRegex });
|
||||
// Assert
|
||||
expect(ajaxCache.delete.calledOnce).to.be.true;
|
||||
expect(ajaxCache.deleteMatching.calledOnce).to.be.true;
|
||||
expect(pendingRequestStore.resolve.calledOnce).to.be.true;
|
||||
expect(pendingRequestStore.resolveMatching.calledOnce).to.be.true;
|
||||
|
||||
expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
|
||||
expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
|
||||
|
||||
expect(ajaxCache.deleteMatching.calledWith('f00')).to.be.true;
|
||||
expect(pendingRequestStore.resolveMatching.calledWith('f00')).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCurrentSessionId', () => {
|
||||
it('returns true for the current session id', () => {
|
||||
resetCacheSession('the-id');
|
||||
|
||||
expect(isCurrentSessionId('the-id')).to.equal(true);
|
||||
});
|
||||
|
||||
it('returns true for the current session id', () => {
|
||||
resetCacheSession('the-id');
|
||||
|
||||
expect(isCurrentSessionId('a-different-id')).to.equal(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,586 +0,0 @@
|
|||
import { aTimeout, expect } from '@open-wc/testing';
|
||||
import { spy, stub, useFakeTimers } from 'sinon';
|
||||
import '../src/typedef.js';
|
||||
import { acceptLanguageRequestInterceptor } from '../src/interceptors/acceptLanguageHeader.js';
|
||||
import { createXsrfRequestInterceptor, getCookie } from '../src/interceptors/xsrfHeader.js';
|
||||
import { createCacheInterceptors } from '../src/interceptors/cacheInterceptors.js';
|
||||
import { Ajax } from '../index.js';
|
||||
|
||||
const ajax = new Ajax();
|
||||
|
||||
describe('interceptors', () => {
|
||||
describe('getCookie()', () => {
|
||||
it('returns the cookie value', () => {
|
||||
expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
|
||||
});
|
||||
|
||||
it('returns the cookie value when there are multiple cookies', () => {
|
||||
expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar');
|
||||
});
|
||||
|
||||
it('returns null when the cookie cannot be found', () => {
|
||||
expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null);
|
||||
});
|
||||
|
||||
it('decodes the cookie vaue', () => {
|
||||
expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal(
|
||||
'/foo/ bar "',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptLanguageRequestInterceptor()', () => {
|
||||
it('adds the locale as accept-language header', () => {
|
||||
const request = new Request('/foo/');
|
||||
acceptLanguageRequestInterceptor(request);
|
||||
expect(request.headers.get('accept-language')).to.equal('en');
|
||||
});
|
||||
|
||||
it('does not change an existing accept-language header', () => {
|
||||
const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
|
||||
acceptLanguageRequestInterceptor(request);
|
||||
expect(request.headers.get('accept-language')).to.equal('my-accept');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createXsrfRequestInterceptor()', () => {
|
||||
it('adds the xsrf token header to the request', () => {
|
||||
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||
cookie: 'XSRF-TOKEN=foo',
|
||||
});
|
||||
const request = new Request('/foo/');
|
||||
interceptor(request);
|
||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
|
||||
});
|
||||
|
||||
it('does not set anything if the cookie is not there', () => {
|
||||
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||
cookie: 'XXSRF-TOKEN=foo',
|
||||
});
|
||||
const request = new Request('/foo/');
|
||||
interceptor(request);
|
||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache interceptors', () => {
|
||||
/** @type {number | undefined} */
|
||||
let cacheId;
|
||||
/** @type {import('sinon').SinonStub} */
|
||||
let fetchStub;
|
||||
/** @type {() => string} */
|
||||
let getCacheIdentifier;
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const requestInterceptorIndex =
|
||||
ajaxInstance._requestInterceptors.push(
|
||||
/** @type {RequestInterceptor} */ (cacheRequestInterceptor),
|
||||
) - 1;
|
||||
|
||||
const responseInterceptorIndex =
|
||||
ajaxInstance._responseInterceptors.push(
|
||||
/** @type {ResponseInterceptor} */ (cacheResponseInterceptor),
|
||||
) - 1;
|
||||
|
||||
return {
|
||||
requestInterceptorIndex,
|
||||
responseInterceptorIndex,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {ajax} ajaxInstance
|
||||
* @param {{requestInterceptorIndex: number, responseInterceptorIndex: number}} indexes
|
||||
*/
|
||||
const removeCacheInterceptors = (
|
||||
ajaxInstance,
|
||||
{ requestInterceptorIndex, responseInterceptorIndex },
|
||||
) => {
|
||||
ajaxInstance._requestInterceptors.splice(requestInterceptorIndex, 1);
|
||||
ajaxInstance._responseInterceptors.splice(responseInterceptorIndex, 1);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getCacheIdentifier = () => String(cacheId);
|
||||
fetchStub = stub(window, 'fetch');
|
||||
fetchStub.returns(Promise.resolve(new Response('mock response')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchStub.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 = () => {
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
// @ts-ignore needed for test
|
||||
useCache: 'fakeUseCacheType',
|
||||
});
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
};
|
||||
expect(test).to.throw();
|
||||
});
|
||||
|
||||
it('validates property `timeToLive` throws if not type `number`', () => {
|
||||
newCacheId();
|
||||
expect(() => {
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
// @ts-ignore needed for test
|
||||
timeToLive: '',
|
||||
});
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
}).to.throw();
|
||||
});
|
||||
|
||||
it('validates cache identifier function', () => {
|
||||
// @ts-ignore needed for test
|
||||
cacheId = '';
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, { useCache: true });
|
||||
|
||||
return ajax.fetch('/test').catch(
|
||||
/** @param {Error} err */ err => {
|
||||
expect(err.message).to.equal('getCacheIdentifier returns falsy');
|
||||
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when using methods other than `['get']`", () => {
|
||||
newCacheId();
|
||||
|
||||
expect(() => {
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
methods: ['get', 'post'],
|
||||
});
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
}).to.throw(/not yet supported/);
|
||||
});
|
||||
|
||||
it('throws error when requestIdentificationFn is not a function', () => {
|
||||
newCacheId();
|
||||
|
||||
expect(() => {
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
// @ts-ignore needed for test
|
||||
requestIdentificationFn: 'not a function',
|
||||
});
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
}).to.throw(/Property `requestIdentificationFn` must be of type `function`/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cached responses', () => {
|
||||
it('returns the cached object on second call with `useCache: true`', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 100,
|
||||
});
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
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);
|
||||
|
||||
ajaxRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('all calls with non-default `timeToLive` are cached proactively', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: false,
|
||||
timeToLive: 100,
|
||||
});
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
await ajax.fetch('/test');
|
||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
await ajax.fetch('/test');
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
await ajax.fetch('/test', {
|
||||
cacheOptions: {
|
||||
useCache: true,
|
||||
},
|
||||
});
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
ajaxRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 100,
|
||||
});
|
||||
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
await ajax.fetch('/test', {
|
||||
params: {
|
||||
q: 'test',
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||
await ajax.fetch('/test', {
|
||||
params: {
|
||||
q: 'test',
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
// a request with different param should not be cached
|
||||
await ajax.fetch('/test', {
|
||||
params: {
|
||||
q: 'test',
|
||||
page: 2,
|
||||
},
|
||||
});
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
ajaxRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('uses cache when inside `timeToLive: 5000` window', async () => {
|
||||
newCacheId();
|
||||
const clock = useFakeTimers({
|
||||
shouldAdvanceTime: true,
|
||||
});
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 5000,
|
||||
});
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
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);
|
||||
ajaxRequestSpy.restore();
|
||||
clock.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('uses custom requestIdentificationFn when passed', async () => {
|
||||
newCacheId();
|
||||
|
||||
const customRequestIdFn = /** @type {RequestIdentificationFn} */ (request, serializer) => {
|
||||
let serializedRequestParams = '';
|
||||
if (request.params) {
|
||||
serializedRequestParams = `?${serializer(request.params)}`;
|
||||
}
|
||||
return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get(
|
||||
'x-id',
|
||||
)}${serializedRequestParams}`;
|
||||
};
|
||||
const reqIdSpy = spy(customRequestIdFn);
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
requestIdentificationFn: reqIdSpy,
|
||||
});
|
||||
|
||||
await ajax.fetch('/test', { headers: { 'x-id': '1' } });
|
||||
expect(reqIdSpy.calledOnce);
|
||||
expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`);
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache invalidation', () => {
|
||||
it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 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);
|
||||
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 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);
|
||||
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('deletes cache after one hour', async () => {
|
||||
newCacheId();
|
||||
const clock = useFakeTimers({
|
||||
shouldAdvanceTime: true,
|
||||
});
|
||||
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 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);
|
||||
ajaxRequestSpy.restore();
|
||||
clock.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('invalidates invalidateUrls endpoints', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 500,
|
||||
});
|
||||
|
||||
const actionConfig = {
|
||||
cacheOptions: {
|
||||
invalidateUrls: ['/test-invalid-url'],
|
||||
},
|
||||
};
|
||||
|
||||
await ajax.fetch('/test-valid-url', { ...actionConfig });
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
await ajax.fetch('/test-invalid-url');
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
// 'post' will invalidate 'own' cache and the one mentioned in config
|
||||
await ajax.fetch('/test-valid-url', { ...actionConfig, method: 'POST' });
|
||||
expect(fetchStub.callCount).to.equal(3);
|
||||
await ajax.fetch('/test-invalid-url');
|
||||
// indicates that 'test-invalid-url' cache was removed
|
||||
// because the server registered new request
|
||||
expect(fetchStub.callCount).to.equal(4);
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('invalidates cache on a post', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 100,
|
||||
});
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
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);
|
||||
ajaxRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('caches response but does not return it when expiration time is 0', async () => {
|
||||
newCacheId();
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 0,
|
||||
});
|
||||
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
await ajax.fetch('/test');
|
||||
const clock = useFakeTimers();
|
||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||
clock.tick(1);
|
||||
clock.restore();
|
||||
await ajax.fetch('/test');
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
ajaxRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('does not use cache when `useCache: false` in the action', async () => {
|
||||
newCacheId();
|
||||
getCacheIdentifier = () => 'cacheIdentifier2';
|
||||
|
||||
const ajaxAlwaysRequestSpy = spy(ajax, 'fetch');
|
||||
const indexes = addCacheInterceptors(ajax, { useCache: true });
|
||||
|
||||
await ajax.fetch('/test');
|
||||
expect(ajaxAlwaysRequestSpy.calledOnce, 'calledOnce').to.be.true;
|
||||
expect(ajaxAlwaysRequestSpy.calledWith('/test'));
|
||||
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
ajaxAlwaysRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
it('caches concurrent requests', async () => {
|
||||
newCacheId();
|
||||
|
||||
let i = 0;
|
||||
fetchStub.returns(
|
||||
new Promise(resolve => {
|
||||
i += 1;
|
||||
setTimeout(() => {
|
||||
resolve(new Response(`mock response ${i}`));
|
||||
}, 5);
|
||||
}),
|
||||
);
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 100,
|
||||
});
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
const request1 = ajax.fetch('/test');
|
||||
const request2 = ajax.fetch('/test');
|
||||
await aTimeout(1);
|
||||
const request3 = ajax.fetch('/test');
|
||||
await aTimeout(3);
|
||||
const request4 = ajax.fetch('/test');
|
||||
const responses = await Promise.all([request1, request2, request3, request4]);
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
const responseTexts = await Promise.all(responses.map(r => r.text()));
|
||||
expect(responseTexts).to.eql([
|
||||
'mock response 1',
|
||||
'mock response 1',
|
||||
'mock response 1',
|
||||
'mock response 1',
|
||||
]);
|
||||
|
||||
ajaxRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
|
||||
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' } }),
|
||||
),
|
||||
);
|
||||
|
||||
const indexes = addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
timeToLive: 100,
|
||||
});
|
||||
const ajaxRequestSpy = spy(ajax, 'fetch');
|
||||
|
||||
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');
|
||||
|
||||
ajaxRequestSpy.restore();
|
||||
removeCacheInterceptors(ajax, indexes);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
16
packages/ajax/test/interceptors/acceptLanguageHeader.test.js
Normal file
16
packages/ajax/test/interceptors/acceptLanguageHeader.test.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { expect } from '@open-wc/testing';
|
||||
import { acceptLanguageRequestInterceptor } from '../../src/interceptors/acceptLanguageHeader.js';
|
||||
|
||||
describe('acceptLanguageRequestInterceptor()', () => {
|
||||
it('adds the locale as accept-language header', () => {
|
||||
const request = new Request('/foo/');
|
||||
acceptLanguageRequestInterceptor(request);
|
||||
expect(request.headers.get('accept-language')).to.equal('en');
|
||||
});
|
||||
|
||||
it('does not change an existing accept-language header', () => {
|
||||
const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
|
||||
acceptLanguageRequestInterceptor(request);
|
||||
expect(request.headers.get('accept-language')).to.equal('my-accept');
|
||||
});
|
||||
});
|
||||
595
packages/ajax/test/interceptors/cacheInterceptors.test.js
Normal file
595
packages/ajax/test/interceptors/cacheInterceptors.test.js
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
16
packages/ajax/test/interceptors/interceptors.test.js
Normal file
16
packages/ajax/test/interceptors/interceptors.test.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { expect } from '@open-wc/testing';
|
||||
import * as interceptors from '../../src/interceptors/index.js';
|
||||
|
||||
describe('interceptors interface', () => {
|
||||
it('exposes the acceptLanguageRequestInterceptor function', () => {
|
||||
expect(interceptors.acceptLanguageRequestInterceptor).to.be.a('Function');
|
||||
});
|
||||
|
||||
it('exposes the createXsrfRequestInterceptor function', () => {
|
||||
expect(interceptors.createXsrfRequestInterceptor).to.be.a('Function');
|
||||
});
|
||||
|
||||
it('exposes the createCacheInterceptors function', () => {
|
||||
expect(interceptors.createCacheInterceptors).to.be.a('Function');
|
||||
});
|
||||
});
|
||||
42
packages/ajax/test/interceptors/xsrfHeader.test.js
Normal file
42
packages/ajax/test/interceptors/xsrfHeader.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { expect } from '@open-wc/testing';
|
||||
import { createXsrfRequestInterceptor, getCookie } from '../../src/interceptors/xsrfHeader.js';
|
||||
|
||||
describe('getCookie()', () => {
|
||||
it('returns the cookie value', () => {
|
||||
expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
|
||||
});
|
||||
|
||||
it('returns the cookie value when there are multiple cookies', () => {
|
||||
expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar');
|
||||
});
|
||||
|
||||
it('returns null when the cookie cannot be found', () => {
|
||||
expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null);
|
||||
});
|
||||
|
||||
it('decodes the cookie vaue', () => {
|
||||
expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal(
|
||||
'/foo/ bar "',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createXsrfRequestInterceptor()', () => {
|
||||
it('adds the xsrf token header to the request', () => {
|
||||
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||
cookie: 'XSRF-TOKEN=foo',
|
||||
});
|
||||
const request = new Request('/foo/');
|
||||
interceptor(request);
|
||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
|
||||
});
|
||||
|
||||
it('does not set anything if the cookie is not there', () => {
|
||||
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||
cookie: 'XXSRF-TOKEN=foo',
|
||||
});
|
||||
const request = new Request('/foo/');
|
||||
interceptor(request);
|
||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
|
||||
});
|
||||
});
|
||||
14
packages/ajax/types/types.d.ts
vendored
14
packages/ajax/types/types.d.ts
vendored
|
|
@ -27,18 +27,18 @@ export interface CacheConfig {
|
|||
|
||||
export type Params = { [key: string]: any };
|
||||
|
||||
export type RequestIdentificationFn = (
|
||||
export type RequestIdFunction = (
|
||||
request: Partial<CacheRequest>,
|
||||
stringifySearchParams: (params: Params) => string,
|
||||
serializeSearchParams?: (params: Params) => string,
|
||||
) => string;
|
||||
|
||||
export interface CacheOptions {
|
||||
useCache?: boolean;
|
||||
methods?: string[];
|
||||
timeToLive?: number;
|
||||
maxAge?: number;
|
||||
invalidateUrls?: string[];
|
||||
invalidateUrlsRegex?: RegExp;
|
||||
requestIdentificationFn?: RequestIdentificationFn;
|
||||
requestIdFunction?: RequestIdFunction;
|
||||
}
|
||||
|
||||
export interface CacheOptionsWithIdentifier extends CacheOptions {
|
||||
|
|
@ -48,11 +48,12 @@ export interface CacheOptionsWithIdentifier extends CacheOptions {
|
|||
export interface ValidatedCacheOptions extends CacheOptions {
|
||||
useCache: boolean;
|
||||
methods: string[];
|
||||
timeToLive: number;
|
||||
requestIdentificationFn: RequestIdentificationFn;
|
||||
maxAge: number;
|
||||
requestIdFunction: RequestIdFunction;
|
||||
}
|
||||
|
||||
export interface CacheRequestExtension {
|
||||
cacheSessionId?: string;
|
||||
cacheOptions?: CacheOptions;
|
||||
adapter: any;
|
||||
status: number;
|
||||
|
|
@ -61,6 +62,7 @@ export interface CacheRequestExtension {
|
|||
}
|
||||
|
||||
export interface CacheResponseRequest {
|
||||
cacheSessionId?: string;
|
||||
cacheOptions?: CacheOptions;
|
||||
method: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,8 @@ export default {
|
|||
playwrightLauncher({ product: 'chromium' }),
|
||||
playwrightLauncher({ product: 'webkit' }),
|
||||
],
|
||||
groups: packages.map(pkg => {
|
||||
return {
|
||||
name: pkg,
|
||||
files: `packages/${pkg}/test/**/*.test.js`,
|
||||
};
|
||||
}),
|
||||
groups: packages.map(pkg => ({
|
||||
name: pkg,
|
||||
files: `packages/${pkg}/test/**/*.test.js`,
|
||||
})),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue