From 5c9d5b67e2b0c845e90b18c7097e15366206a21d Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 18 Jun 2026 16:01:37 -0400 Subject: [PATCH 1/5] feat: add api client caching functionality --- resources/js/apiClientCache.js | 246 +++++++++++++++++++++++ resources/js/bootstrap.js | 3 + resources/js/next/config/processmaker.js | 2 + 3 files changed, 251 insertions(+) create mode 100644 resources/js/apiClientCache.js diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js new file mode 100644 index 0000000000..942c3201fc --- /dev/null +++ b/resources/js/apiClientCache.js @@ -0,0 +1,246 @@ +const DEFAULT_CACHE_TTL = 5000; + +const CACHEABLE_METHOD = "get"; + +const isObject = (value) => value && typeof value === "object" && !Array.isArray(value); + +const isAbsoluteURL = (url) => /^[a-z][a-z\d+\-.]*:\/\//i.test(url); + +const combineURLs = (baseURL, requestedURL = "") => { + const normalizedBaseURL = baseURL || ""; + + if (!normalizedBaseURL || isAbsoluteURL(requestedURL)) { + return requestedURL; + } + + return `${normalizedBaseURL.replace(/\/+$/, "")}/${requestedURL.replace(/^\/+/, "")}`; +}; + +const normalizeHeaderName = (headers, headerName) => { + const normalizedHeaders = headers || {}; + const match = Object.keys(normalizedHeaders).find((key) => key.toLowerCase() === headerName.toLowerCase()); + return match ? normalizedHeaders[match] : undefined; +}; + +const encodeValue = (value) => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (isObject(value)) { + return JSON.stringify(value); + } + + return value; +}; + +const serializeParams = (params, paramsSerializer) => { + if (!params) { + return ""; + } + + if (typeof paramsSerializer === "function") { + return paramsSerializer(params); + } + + if (typeof URLSearchParams !== "undefined" && params instanceof URLSearchParams) { + return params.toString(); + } + + return Object.keys(params) + .sort() + .reduce((parts, key) => { + const value = params[key]; + + if (value === null || typeof value === "undefined") { + return parts; + } + + const values = Array.isArray(value) ? value : [value]; + + values.forEach((entry) => { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(encodeValue(entry))}`); + }); + + return parts; + }, []) + .join("&"); +}; + +const appendParams = (url, params, paramsSerializer) => { + const serializedParams = serializeParams(params, paramsSerializer); + + if (!serializedParams) { + return url; + } + + return `${url}${url.includes("?") ? "&" : "?"}${serializedParams}`; +}; + +const cloneData = (data) => { + if (!data || typeof data !== "object") { + return data; + } + + if (typeof structuredClone === "function") { + try { + return structuredClone(data); + } catch (error) { + // Fall through to JSON cloning for plain response payloads. + } + } + + try { + return JSON.parse(JSON.stringify(data)); + } catch (error) { + return data; + } +}; + +const cloneResponse = (response) => ({ + ...response, + data: cloneData(response.data), + headers: response.headers ? { ...response.headers } : response.headers, +}); + +export const buildApiClientCacheKey = (config = {}) => { + const url = appendParams( + combineURLs(config.baseURL, config.url || ""), + config.params, + config.paramsSerializer, + ); + const headers = config.headers || {}; + const relevantConfig = { + responseType: config.responseType || "", + accept: normalizeHeaderName(headers, "Accept") || "", + }; + + return { + key: `${url}|${JSON.stringify(relevantConfig)}`, + url, + }; +}; + +export const installApiClientCache = (apiClient) => { + if (!apiClient || apiClient.cache) { + return apiClient; + } + + const client = apiClient; + const responseCache = new Map(); + const pendingRequests = new Map(); + const originalAdapter = client.defaults.adapter; + + let globallyEnabled = false; + let disabled = false; + + const cleanup = () => { + const now = Date.now(); + + responseCache.forEach((entry, key) => { + if (entry.expiresAt <= now) { + responseCache.delete(key); + } + }); + }; + + const invalidateByMatcher = (matcher) => { + responseCache.forEach((entry, key) => { + if (matcher(key, entry)) { + responseCache.delete(key); + } + }); + }; + + const cache = { + DEFAULT_CACHE_TTL, + get enabled() { + return globallyEnabled && !disabled; + }, + get disabled() { + return disabled; + }, + enable() { + globallyEnabled = true; + disabled = false; + }, + disable() { + disabled = true; + }, + clear() { + responseCache.clear(); + pendingRequests.clear(); + }, + cleanup, + invalidate(urlOrKey) { + invalidateByMatcher((key, entry) => key === urlOrKey || entry.url === urlOrKey); + }, + invalidateByPattern(pattern) { + if (pattern instanceof RegExp) { + invalidateByMatcher((key, entry) => pattern.test(key) || pattern.test(entry.url)); + return; + } + + invalidateByMatcher((key, entry) => key.includes(pattern) || entry.url.includes(pattern)); + }, + }; + + const isCacheEnabledForRequest = (config) => { + if (disabled || (config.cache && config.cache.enabled === false)) { + return false; + } + + return Boolean(config.cache && config.cache.enabled === true) || globallyEnabled; + }; + + const getTTL = (config) => { + const ttl = Number(config.cache && config.cache.ttl); + return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_CACHE_TTL; + }; + + client.DEFAULT_CACHE_TTL = DEFAULT_CACHE_TTL; + client.cache = cache; + client.defaults.adapter = (config) => { + const method = (config.method || CACHEABLE_METHOD).toLowerCase(); + + if (method !== CACHEABLE_METHOD || !isCacheEnabledForRequest(config)) { + return originalAdapter(config); + } + + cleanup(); + + const { key, url } = buildApiClientCacheKey(config); + const cachedResponse = responseCache.get(key); + + if (cachedResponse && cachedResponse.expiresAt > Date.now()) { + return Promise.resolve(cloneResponse(cachedResponse.response)); + } + + if (pendingRequests.has(key)) { + return pendingRequests.get(key).then(cloneResponse); + } + + const request = originalAdapter(config) + .then((response) => { + responseCache.set(key, { + key, + url, + response: cloneResponse(response), + expiresAt: Date.now() + getTTL(config), + }); + + return cloneResponse(response); + }) + .finally(() => { + pendingRequests.delete(key); + }); + + pendingRequests.set(key, request); + + return request.then(cloneResponse); + }; + + return client; +}; + +export default installApiClientCache; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 8df87873b1..acce95c058 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -34,6 +34,7 @@ import TreeView from "./components/TreeView.vue"; import FilterTable from "./components/shared/FilterTable.vue"; import PaginationTable from "./components/shared/PaginationTable.vue"; import PMDropdownSuggest from "./components/PMDropdownSuggest"; +import { installApiClientCache } from "./apiClientCache"; import "@processmaker/screen-builder/dist/vue-form-builder.css"; window.__ = translator; @@ -242,6 +243,8 @@ window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); */ window.ProcessMaker.apiClient = require("axios"); +installApiClientCache(window.ProcessMaker.apiClient); + window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; /** diff --git a/resources/js/next/config/processmaker.js b/resources/js/next/config/processmaker.js index 348227533a..e30588a07b 100644 --- a/resources/js/next/config/processmaker.js +++ b/resources/js/next/config/processmaker.js @@ -1,5 +1,6 @@ import axios from "axios"; import { setGlobalPMVariables, getGlobalPMVariable } from "../globalVariables"; +import { installApiClientCache } from "../../apiClientCache"; export default () => { const token = document.head.querySelector("meta[name=\"csrf-token\"]"); @@ -20,6 +21,7 @@ export default () => { */ const apiClient = axios; + installApiClientCache(apiClient); apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; From 913769f54e560b1c0e7aecae5c10cfe4c8b79c48 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 18 Jun 2026 16:56:21 -0400 Subject: [PATCH 2/5] feat: add debug logging to api client cache for improved diagnostics --- resources/js/apiClientCache.js | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js index 942c3201fc..215f72e459 100644 --- a/resources/js/apiClientCache.js +++ b/resources/js/apiClientCache.js @@ -133,6 +133,16 @@ export const installApiClientCache = (apiClient) => { let globallyEnabled = false; let disabled = false; + let debugEnabled = false; + + const debug = (config, message, details = {}) => { + if (!debugEnabled && !(config.cache && config.cache.debug)) { + return; + } + + // eslint-disable-next-line no-console + console.log(`[ProcessMaker.apiClient.cache] ${message}`, details); + }; const cleanup = () => { const now = Date.now(); @@ -160,28 +170,45 @@ export const installApiClientCache = (apiClient) => { get disabled() { return disabled; }, + get debug() { + return debugEnabled; + }, enable() { globallyEnabled = true; disabled = false; + debug({}, "cache enabled globally"); }, disable() { disabled = true; + debug({}, "cache disabled for current window"); + }, + enableDebug() { + debugEnabled = true; + debug({}, "debug logging enabled"); + }, + disableDebug() { + debug({}, "debug logging disabled"); + debugEnabled = false; }, clear() { responseCache.clear(); pendingRequests.clear(); + debug({}, "cache cleared"); }, cleanup, invalidate(urlOrKey) { invalidateByMatcher((key, entry) => key === urlOrKey || entry.url === urlOrKey); + debug({}, "cache invalidated", { urlOrKey }); }, invalidateByPattern(pattern) { if (pattern instanceof RegExp) { invalidateByMatcher((key, entry) => pattern.test(key) || pattern.test(entry.url)); + debug({}, "cache invalidated by RegExp pattern", { pattern }); return; } invalidateByMatcher((key, entry) => key.includes(pattern) || entry.url.includes(pattern)); + debug({}, "cache invalidated by pattern", { pattern }); }, }; @@ -204,6 +231,11 @@ export const installApiClientCache = (apiClient) => { const method = (config.method || CACHEABLE_METHOD).toLowerCase(); if (method !== CACHEABLE_METHOD || !isCacheEnabledForRequest(config)) { + debug(config, "bypassing cache", { + method, + url: config.url, + reason: method !== CACHEABLE_METHOD ? "non-get request" : "cache disabled", + }); return originalAdapter(config); } @@ -213,13 +245,21 @@ export const installApiClientCache = (apiClient) => { const cachedResponse = responseCache.get(key); if (cachedResponse && cachedResponse.expiresAt > Date.now()) { + debug(config, "cache hit", { + key, + url, + expiresAt: cachedResponse.expiresAt, + }); return Promise.resolve(cloneResponse(cachedResponse.response)); } if (pendingRequests.has(key)) { + debug(config, "deduplicated in-flight request", { key, url }); return pendingRequests.get(key).then(cloneResponse); } + debug(config, "cache miss; sending network request", { key, url }); + const request = originalAdapter(config) .then((response) => { responseCache.set(key, { @@ -229,10 +269,28 @@ export const installApiClientCache = (apiClient) => { expiresAt: Date.now() + getTTL(config), }); + debug(config, "response cached", { + key, + url, + ttl: getTTL(config), + status: response.status, + }); + return cloneResponse(response); }) + .catch((error) => { + debug(config, "request failed; response not cached", { + key, + url, + message: error.message, + status: error.response && error.response.status, + }); + + return Promise.reject(error); + }) .finally(() => { pendingRequests.delete(key); + debug(config, "in-flight request removed", { key, url }); }); pendingRequests.set(key, request); From 359fcc9ca2ed9ea0539b2238f987227d050ce91c Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Fri, 19 Jun 2026 09:59:01 -0400 Subject: [PATCH 3/5] feat: enhance api client cache with additional utility functions and detailed documentation --- resources/js/apiClientCache.js | 173 +++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js index 215f72e459..50be624870 100644 --- a/resources/js/apiClientCache.js +++ b/resources/js/apiClientCache.js @@ -1,11 +1,36 @@ +/** + * Default number of milliseconds a successful GET response remains reusable. + */ const DEFAULT_CACHE_TTL = 5000; +/** + * HTTP method eligible for response caching and request deduplication. + */ const CACHEABLE_METHOD = "get"; +/** + * Determines whether a value is a plain object-like value for parameter encoding. + * + * @param {*} value + * @returns {boolean} + */ const isObject = (value) => value && typeof value === "object" && !Array.isArray(value); +/** + * Checks whether a URL already includes a protocol and host. + * + * @param {string} url + * @returns {boolean} + */ const isAbsoluteURL = (url) => /^[a-z][a-z\d+\-.]*:\/\//i.test(url); +/** + * Combines Axios baseURL and request URL while preserving absolute request URLs. + * + * @param {string} baseURL + * @param {string} requestedURL + * @returns {string} + */ const combineURLs = (baseURL, requestedURL = "") => { const normalizedBaseURL = baseURL || ""; @@ -16,12 +41,25 @@ const combineURLs = (baseURL, requestedURL = "") => { return `${normalizedBaseURL.replace(/\/+$/, "")}/${requestedURL.replace(/^\/+/, "")}`; }; +/** + * Reads a header value case-insensitively from an Axios headers object. + * + * @param {object} headers + * @param {string} headerName + * @returns {*} + */ const normalizeHeaderName = (headers, headerName) => { const normalizedHeaders = headers || {}; const match = Object.keys(normalizedHeaders).find((key) => key.toLowerCase() === headerName.toLowerCase()); return match ? normalizedHeaders[match] : undefined; }; +/** + * Converts a query parameter value into a stable string representation. + * + * @param {*} value + * @returns {*} + */ const encodeValue = (value) => { if (value instanceof Date) { return value.toISOString(); @@ -34,6 +72,13 @@ const encodeValue = (value) => { return value; }; +/** + * Serializes query parameters in a stable order unless Axios provides a custom serializer. + * + * @param {object|URLSearchParams} params + * @param {Function} paramsSerializer + * @returns {string} + */ const serializeParams = (params, paramsSerializer) => { if (!params) { return ""; @@ -67,6 +112,14 @@ const serializeParams = (params, paramsSerializer) => { .join("&"); }; +/** + * Appends serialized query parameters to a URL. + * + * @param {string} url + * @param {object|URLSearchParams} params + * @param {Function} paramsSerializer + * @returns {string} + */ const appendParams = (url, params, paramsSerializer) => { const serializedParams = serializeParams(params, paramsSerializer); @@ -77,6 +130,12 @@ const appendParams = (url, params, paramsSerializer) => { return `${url}${url.includes("?") ? "&" : "?"}${serializedParams}`; }; +/** + * Clones response data so cached responses are not mutated by consumers. + * + * @param {*} data + * @returns {*} + */ const cloneData = (data) => { if (!data || typeof data !== "object") { return data; @@ -97,12 +156,27 @@ const cloneData = (data) => { } }; +/** + * Clones the Axios response fields that consumers commonly mutate. + * + * @param {object} response + * @returns {object} + */ const cloneResponse = (response) => ({ ...response, data: cloneData(response.data), headers: response.headers ? { ...response.headers } : response.headers, }); +/** + * Builds the cache lookup key and human-readable URL for an Axios request. + * + * The key includes URL, query parameters, response type, and Accept header so + * requests that can produce different payload shapes do not share cache entries. + * + * @param {object} config Axios request configuration + * @returns {{key: string, url: string}} + */ export const buildApiClientCacheKey = (config = {}) => { const url = appendParams( combineURLs(config.baseURL, config.url || ""), @@ -121,6 +195,15 @@ export const buildApiClientCacheKey = (config = {}) => { }; }; +/** + * Installs response caching and in-flight request deduplication on an Axios client. + * + * The wrapper is implemented at the Axios adapter layer so normal Axios call + * forms, interceptors, defaults, and helpers continue to work unchanged. + * + * @param {Function|object} apiClient Axios client instance + * @returns {Function|object} The same Axios client instance + */ export const installApiClientCache = (apiClient) => { if (!apiClient || apiClient.cache) { return apiClient; @@ -135,6 +218,14 @@ export const installApiClientCache = (apiClient) => { let disabled = false; let debugEnabled = false; + /** + * Writes cache diagnostics only when global or per-request debugging is enabled. + * + * @param {object} config Axios request configuration + * @param {string} message Debug message + * @param {object} details Structured details for browser console inspection + * @returns {void} + */ const debug = (config, message, details = {}) => { if (!debugEnabled && !(config.cache && config.cache.debug)) { return; @@ -144,6 +235,11 @@ export const installApiClientCache = (apiClient) => { console.log(`[ProcessMaker.apiClient.cache] ${message}`, details); }; + /** + * Removes expired response entries from the in-memory cache. + * + * @returns {void} + */ const cleanup = () => { const now = Date.now(); @@ -154,6 +250,12 @@ export const installApiClientCache = (apiClient) => { }); }; + /** + * Invalidates response entries that satisfy a caller-provided predicate. + * + * @param {Function} matcher Predicate receiving the cache key and entry + * @returns {void} + */ const invalidateByMatcher = (matcher) => { responseCache.forEach((entry, key) => { if (matcher(key, entry)) { @@ -164,42 +266,94 @@ export const installApiClientCache = (apiClient) => { const cache = { DEFAULT_CACHE_TTL, + /** + * Indicates whether cache is enabled globally for requests that do not opt in. + * + * @returns {boolean} + */ get enabled() { return globallyEnabled && !disabled; }, + /** + * Indicates whether cache has been disabled for the current window. + * + * @returns {boolean} + */ get disabled() { return disabled; }, + /** + * Indicates whether global cache debug logging is active. + * + * @returns {boolean} + */ get debug() { return debugEnabled; }, + /** + * Enables cache for all eligible GET requests in the current window. + * + * @returns {void} + */ enable() { globallyEnabled = true; disabled = false; debug({}, "cache enabled globally"); }, + /** + * Disables cache and deduplication for the current window. + * + * @returns {void} + */ disable() { disabled = true; debug({}, "cache disabled for current window"); }, + /** + * Enables cache diagnostic logging for the current window. + * + * @returns {void} + */ enableDebug() { debugEnabled = true; debug({}, "debug logging enabled"); }, + /** + * Disables cache diagnostic logging for the current window. + * + * @returns {void} + */ disableDebug() { debug({}, "debug logging disabled"); debugEnabled = false; }, + /** + * Clears cached responses and tracked in-flight requests. + * + * @returns {void} + */ clear() { responseCache.clear(); pendingRequests.clear(); debug({}, "cache cleared"); }, cleanup, + /** + * Invalidates an exact cache key or URL. + * + * @param {string} urlOrKey + * @returns {void} + */ invalidate(urlOrKey) { invalidateByMatcher((key, entry) => key === urlOrKey || entry.url === urlOrKey); debug({}, "cache invalidated", { urlOrKey }); }, + /** + * Invalidates cache entries whose key or URL matches a string or RegExp pattern. + * + * @param {string|RegExp} pattern + * @returns {void} + */ invalidateByPattern(pattern) { if (pattern instanceof RegExp) { invalidateByMatcher((key, entry) => pattern.test(key) || pattern.test(entry.url)); @@ -212,6 +366,12 @@ export const installApiClientCache = (apiClient) => { }, }; + /** + * Determines whether a request should use cache behavior. + * + * @param {object} config Axios request configuration + * @returns {boolean} + */ const isCacheEnabledForRequest = (config) => { if (disabled || (config.cache && config.cache.enabled === false)) { return false; @@ -220,6 +380,12 @@ export const installApiClientCache = (apiClient) => { return Boolean(config.cache && config.cache.enabled === true) || globallyEnabled; }; + /** + * Resolves the per-request TTL, falling back to the default duration. + * + * @param {object} config Axios request configuration + * @returns {number} + */ const getTTL = (config) => { const ttl = Number(config.cache && config.cache.ttl); return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_CACHE_TTL; @@ -227,6 +393,13 @@ export const installApiClientCache = (apiClient) => { client.DEFAULT_CACHE_TTL = DEFAULT_CACHE_TTL; client.cache = cache; + /** + * Axios adapter wrapper that serves cached GETs, joins duplicate in-flight GETs, + * or delegates to the original adapter for all other requests. + * + * @param {object} config Axios request configuration + * @returns {Promise} + */ client.defaults.adapter = (config) => { const method = (config.method || CACHEABLE_METHOD).toLowerCase(); From 48d1b217730eda5d6a55b8c1ba7ed3837fafec0e Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Fri, 19 Jun 2026 15:38:43 -0400 Subject: [PATCH 4/5] feat: return original request in api client cache to maintain promise chain --- resources/js/apiClientCache.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js index 50be624870..8b3dc7762c 100644 --- a/resources/js/apiClientCache.js +++ b/resources/js/apiClientCache.js @@ -428,7 +428,7 @@ export const installApiClientCache = (apiClient) => { if (pendingRequests.has(key)) { debug(config, "deduplicated in-flight request", { key, url }); - return pendingRequests.get(key).then(cloneResponse); + return pendingRequests.get(key); } debug(config, "cache miss; sending network request", { key, url }); @@ -468,7 +468,7 @@ export const installApiClientCache = (apiClient) => { pendingRequests.set(key, request); - return request.then(cloneResponse); + return request; }; return client; From 5c222b6ae6a967abe5c6b61fa19010ef33070900 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Fri, 19 Jun 2026 15:38:58 -0400 Subject: [PATCH 5/5] test: add comprehensive tests for api client cache functionality --- tests/js/apiClientCache.test.js | 286 ++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests/js/apiClientCache.test.js diff --git a/tests/js/apiClientCache.test.js b/tests/js/apiClientCache.test.js new file mode 100644 index 0000000000..563c6e234d --- /dev/null +++ b/tests/js/apiClientCache.test.js @@ -0,0 +1,286 @@ +import { buildApiClientCacheKey, installApiClientCache } from "../../resources/js/apiClientCache"; + +const createApiClient = (adapter) => ({ + defaults: { + adapter, + }, +}); + +const createResponse = (data, config = {}) => ({ + data, + status: 200, + statusText: "OK", + headers: {}, + config, +}); + +describe("apiClient cache", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("does not cache GET requests unless caching is enabled", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { method: "get", baseURL: "/api/1.0/", url: "tasks" }; + + const first = await apiClient.defaults.adapter(config); + const second = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 2 }); + }); + + test("deduplicates concurrent cache-enabled GET requests", async () => { + let resolveRequest; + const adapter = jest.fn(() => new Promise((resolve) => { + resolveRequest = resolve; + })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "task_schema/584421", + cache: { enabled: true }, + }; + + const first = apiClient.defaults.adapter(config); + const second = apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + + resolveRequest(createResponse({ id: 584421 }, config)); + + await expect(first).resolves.toMatchObject({ data: { id: 584421 } }); + await expect(second).resolves.toMatchObject({ data: { id: 584421 } }); + }); + + test("uses the default TTL and refetches after it expires", async () => { + let now = 1000; + jest.spyOn(Date, "now").mockImplementation(() => now); + + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + const first = await apiClient.defaults.adapter(config); + now = 5999; + const second = await apiClient.defaults.adapter(config); + now = 6000; + const third = await apiClient.defaults.adapter(config); + + expect(apiClient.DEFAULT_CACHE_TTL).toBe(5000); + expect(adapter).toHaveBeenCalledTimes(2); + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 1 }); + expect(third.data).toEqual({ count: 2 }); + }); + + test("uses a per-request TTL override", async () => { + let now = 1000; + jest.spyOn(Date, "now").mockImplementation(() => now); + + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true, ttl: 30_000 }, + }; + + await apiClient.defaults.adapter(config); + now = 30_000; + const cached = await apiClient.defaults.adapter(config); + now = 31_000; + const refetched = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(cached.data).toEqual({ count: 1 }); + expect(refetched.data).toEqual({ count: 2 }); + }); + + test("does not cache failed GET requests", async () => { + const error = new Error("Network failed"); + const adapter = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(createResponse({ count: 1 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + await expect(apiClient.defaults.adapter(config)).rejects.toThrow("Network failed"); + await expect(apiClient.defaults.adapter(config)).resolves.toMatchObject({ data: { count: 1 } }); + + expect(adapter).toHaveBeenCalledTimes(2); + }); + + test("bypasses cache and deduplication for non-GET requests", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "post", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + await apiClient.defaults.adapter(config); + await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + }); + + test("globally enables caching while allowing a request to opt out", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })) + .mockResolvedValueOnce(createResponse({ count: 3 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { method: "get", baseURL: "/api/1.0/", url: "tasks" }; + + apiClient.cache.enable(); + await apiClient.defaults.adapter(config); + await apiClient.defaults.adapter(config); + await apiClient.defaults.adapter({ ...config, cache: { enabled: false } }); + await apiClient.defaults.adapter({ ...config, cache: { enabled: false } }); + + expect(adapter).toHaveBeenCalledTimes(3); + }); + + test("invalidates cached responses by exact URL and pattern", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })) + .mockResolvedValueOnce(createResponse({ count: 3 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + params: { include: "data" }, + cache: { enabled: true }, + }; + + await apiClient.defaults.adapter(config); + apiClient.cache.invalidate("/api/1.0/tasks?include=data"); + const second = await apiClient.defaults.adapter(config); + apiClient.cache.invalidateByPattern("/tasks"); + const third = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(3); + expect(second.data).toEqual({ count: 2 }); + expect(third.data).toEqual({ count: 3 }); + }); + + test("disable bypasses even explicitly cache-enabled requests in the current window", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + apiClient.cache.disable(); + + const first = await apiClient.defaults.adapter(config); + const second = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 2 }); + }); + + test("builds stable keys from URL, params, and relevant config", () => { + const first = buildApiClientCacheKey({ + baseURL: "/api/1.0/", + url: "tasks", + params: { b: 2, a: 1 }, + headers: { Accept: "application/json" }, + }); + const second = buildApiClientCacheKey({ + baseURL: "/api/1.0/", + url: "tasks", + params: { a: 1, b: 2 }, + headers: { accept: "application/json" }, + }); + + expect(first).toEqual(second); + expect(first.url).toBe("/api/1.0/tasks?a=1&b=2"); + }); + + test("keeps responses with distinct relevant configuration separate", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ format: "json" })) + .mockResolvedValueOnce(createResponse({ format: "blob" })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const baseConfig = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + const json = await apiClient.defaults.adapter({ + ...baseConfig, + headers: { Accept: "application/json" }, + }); + const blob = await apiClient.defaults.adapter({ + ...baseConfig, + headers: { Accept: "application/octet-stream" }, + responseType: "blob", + }); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(json.data).toEqual({ format: "json" }); + expect(blob.data).toEqual({ format: "blob" }); + }); + + test("logs cache decisions when debug is enabled", async () => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + const adapter = jest.fn().mockResolvedValue(createResponse({ count: 1 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + apiClient.cache.enableDebug(); + + await apiClient.defaults.adapter(config); + + expect(log).toHaveBeenCalledWith( + "[ProcessMaker.apiClient.cache] cache miss; sending network request", + expect.objectContaining({ url: "/api/1.0/tasks" }), + ); + expect(log).toHaveBeenCalledWith( + "[ProcessMaker.apiClient.cache] response cached", + expect.objectContaining({ status: 200 }), + ); + }); +});