diff --git a/.env.example b/.env.example index 085456a6..e457947e 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ STELLAR_NETWORK=testnet HORIZON_URL=https://horizon-testnet.stellar.org SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +# Cache +ESCROW_CACHE_TTL_SECONDS=30 + # DB (when added) # DATABASE_URL=postgresql://user:pass@localhost:5432/liquifact # REDIS_URL=redis://localhost:6379 diff --git a/package-lock.json b/package-lock.json index 187d27a2..ed7f39e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,13 +73,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/core": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1823,7 +1832,6 @@ "version": "8.16.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2159,7 +2167,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2184,6 +2191,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2638,6 +2651,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -2753,7 +2775,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4408,6 +4429,49 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -4459,6 +4523,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4466,6 +4566,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "dev": true, @@ -4979,39 +5085,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-plugin-organize-imports": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz", - "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "prettier": ">=2.0", - "typescript": ">=2.9", - "vue-tsc": "^2.1.0 || 3" - }, - "peerDependenciesMeta": { - "vue-tsc": { - "optional": true - } - } - }, "node_modules/pretty-format": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", @@ -5173,6 +5246,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex": { "version": "2.1.1", "dev": true, @@ -5187,7 +5280,6 @@ }, "node_modules/semver": { "version": "7.7.4", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5405,6 +5497,16 @@ "node": ">=10" } }, + "node_modules/stack-utils/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stack-utils/node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -5419,6 +5521,19 @@ "node": ">=10" } }, + "node_modules/stack-utils/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/statuses": { "version": "2.0.2", "license": "MIT", @@ -5455,6 +5570,35 @@ "node": ">=8" } }, + "node_modules/string-length/node_modules/ansi-regex/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-length/node_modules/ansi-regex/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string-length/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5721,21 +5865,6 @@ "node": ">= 0.6" } }, - "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/src/__tests__/escrowCache.test.js b/src/__tests__/escrowCache.test.js new file mode 100644 index 00000000..f45beec2 --- /dev/null +++ b/src/__tests__/escrowCache.test.js @@ -0,0 +1,50 @@ +const request = require('supertest'); +const { app, resetStore } = require('../index'); + +describe('Escrow Cache Integration', () => { + beforeEach(() => { + resetStore(); + }); + + it('serves cached response on second request for same invoiceId', async () => { + const res1 = await request(app).get('/api/escrow/inv_100'); + expect(res1.status).toBe(200); + expect(res1.headers['x-cache']).toBe('MISS'); + expect(res1.body.data).toHaveProperty('invoiceId', 'inv_100'); + + const res2 = await request(app).get('/api/escrow/inv_100'); + expect(res2.status).toBe(200); + expect(res2.headers['x-cache']).toBe('HIT'); + expect(res2.body.data).toEqual(res1.body.data); + }); + + it('caches different invoiceIds independently', async () => { + const res1 = await request(app).get('/api/escrow/inv_200'); + expect(res1.headers['x-cache']).toBe('MISS'); + + const res2 = await request(app).get('/api/escrow/inv_300'); + expect(res2.headers['x-cache']).toBe('MISS'); + + const res3 = await request(app).get('/api/escrow/inv_200'); + expect(res3.headers['x-cache']).toBe('HIT'); + }); + + it('returns MISS after TTL expires', async () => { + // Override cache TTL to 1ms for this test by directly accessing the store + // We test TTL expiry via the cacheStore unit tests (Task 1). + // Here we verify the header is MISS on first call as a smoke test. + const res1 = await request(app).get('/api/escrow/inv_400'); + expect(res1.status).toBe(200); + expect(res1.headers['x-cache']).toBe('MISS'); + }); + + it('returns correct response structure', async () => { + const res = await request(app).get('/api/escrow/inv_500'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('data'); + expect(res.body).toHaveProperty('message'); + expect(res.body.data).toHaveProperty('invoiceId', 'inv_500'); + expect(res.body.data).toHaveProperty('status'); + expect(res.body.data).toHaveProperty('fundedAmount'); + }); +}); diff --git a/src/config/cache.js b/src/config/cache.js new file mode 100644 index 00000000..a10642a4 --- /dev/null +++ b/src/config/cache.js @@ -0,0 +1,27 @@ +const DEFAULT_ESCROW_TTL_SECONDS = 30; + +/** + * Parses the escrow cache TTL from environment variables. + * Falls back to the default if the value is missing or not a valid number. + * + * @param {NodeJS.ProcessEnv} env - Environment variables to read from. + * @returns {{ escrowTtl: number }} Cache configuration with TTL in milliseconds. + */ +function parseCacheConfig(env = process.env) { + const raw = env.ESCROW_CACHE_TTL_SECONDS; + const parsed = parseInt(raw, 10); + const seconds = Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_ESCROW_TTL_SECONDS; + + return { + escrowTtl: seconds * 1000, + }; +} + +const cacheConfig = parseCacheConfig(); + +module.exports = { + cacheConfig, + parseCacheConfig, +}; diff --git a/src/config/cache.test.js b/src/config/cache.test.js new file mode 100644 index 00000000..dee11ef6 --- /dev/null +++ b/src/config/cache.test.js @@ -0,0 +1,30 @@ +describe('cacheConfig', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('uses default TTL of 30000ms when env var is not set', () => { + delete process.env.ESCROW_CACHE_TTL_SECONDS; + const { cacheConfig } = require('./cache'); + expect(cacheConfig.escrowTtl).toBe(30000); + }); + + it('parses ESCROW_CACHE_TTL_SECONDS from env and converts to ms', () => { + process.env.ESCROW_CACHE_TTL_SECONDS = '60'; + const { cacheConfig } = require('./cache'); + expect(cacheConfig.escrowTtl).toBe(60000); + }); + + it('falls back to default when env var is not a valid number', () => { + process.env.ESCROW_CACHE_TTL_SECONDS = 'abc'; + const { cacheConfig } = require('./cache'); + expect(cacheConfig.escrowTtl).toBe(30000); + }); +}); diff --git a/src/index.js b/src/index.js index 386adffe..0fb8b4e4 100644 --- a/src/index.js +++ b/src/index.js @@ -4,12 +4,20 @@ */ require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); const { globalLimiter, sensitiveLimiter } = require('./middleware/rateLimit'); const { authenticateToken } = require('./middleware/auth'); const asyncHandler = require('./utils/asyncHandler'); const errorHandler = require('./middleware/errorHandler'); const { callSorobanContract } = require('./services/soroban'); +const { cacheResponse } = require('./middleware/cache'); +const { createCacheStore } = require('./services/cacheStore'); +const { cacheConfig } = require('./config/cache'); + +const app = express(); +const escrowCache = createCacheStore(); const PORT = process.env.PORT || 3001; @@ -181,7 +189,19 @@ app.patch('/api/invoices/:id/restore', (req, res) => { * @param {import('express').Response} res - The Express response object. * @returns {Promise} */ -app.get('/api/escrow/:invoiceId', async (req, res) => { +app.get('/api/escrow/:invoiceId', + cacheResponse({ + ttl: cacheConfig.escrowTtl, + store: escrowCache, + /** + * Derives a cache key from the invoice ID in the request params. + * + * @param {import('express').Request} req - The Express request object. + * @returns {string} The cache key for this escrow request. + */ + keyFn: (req) => `escrow:${req.params.invoiceId}`, + }), + async (req, res) => { const { invoiceId } = req.params; try { @@ -254,6 +274,7 @@ const startServer = () => { */ const resetStore = () => { invoices = []; + escrowCache.clear(); }; // Start server if not in test mode diff --git a/src/middleware/cache.js b/src/middleware/cache.js new file mode 100644 index 00000000..f8252224 --- /dev/null +++ b/src/middleware/cache.js @@ -0,0 +1,69 @@ +/** + * Creates an Express middleware that caches JSON responses with a TTL. + * + * On cache hit, returns the cached JSON and sets X-Cache: HIT header. + * On cache miss, intercepts res.json() to capture and cache 2xx responses, + * then sets X-Cache: MISS header. + * + * Cache store errors are caught and logged — the request falls through + * to the route handler so the cache never blocks a request. + * + * @param {object} options - Middleware configuration. + * @param {number} options.ttl - Cache TTL in milliseconds. + * @param {object} options.store - Cache store instance with get/set methods. + * @param {Function} [options.keyFn] - Function to derive cache key from request. Defaults to req.originalUrl. + * @returns {Function} Express middleware function. + */ +function cacheResponse({ ttl, store, keyFn }) { + /** + * Resolves the cache key for a given request. + * + * @param {import('express').Request} req - The Express request. + * @returns {string} The cache key. + */ + const resolveKey = keyFn || ((req) => req.originalUrl); + + return (req, res, next) => { + let cached; + const key = resolveKey(req); + + try { + cached = store.get(key); + } catch (err) { + console.warn('Cache store get error, falling through:', err.message); + return next(); + } + + if (cached !== undefined) { + res.set('X-Cache', 'HIT'); + return res.json(cached); + } + + res.set('X-Cache', 'MISS'); + + const originalJson = res.json.bind(res); + + /** + * Patched res.json that caches 2xx responses before sending. + * + * @param {*} body - The response body to send. + * @returns {object} The Express response. + */ + res.json = (body) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + store.set(key, body, ttl); + } catch (err) { + console.warn('Cache store set error:', err.message); + } + } + return originalJson(body); + }; + + return next(); + }; +} + +module.exports = { + cacheResponse, +}; diff --git a/src/middleware/cache.test.js b/src/middleware/cache.test.js new file mode 100644 index 00000000..13ef3745 --- /dev/null +++ b/src/middleware/cache.test.js @@ -0,0 +1,132 @@ +const { cacheResponse } = require('./cache'); +const { MemoryCacheStore } = require('../services/cacheStore'); + +/** + * Creates a minimal mock response object for testing. + * + * @returns {object} Mock Express response. + */ +function createMockRes() { + const res = { + statusCode: 200, + headers: {}, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + json(data) { + res.body = data; + return res; + }, + set(name, value) { + res.headers[name] = value; + return res; + }, + }; + return res; +} + +describe('cacheResponse', () => { + let store; + + beforeEach(() => { + store = new MemoryCacheStore(); + }); + + it('calls next on cache miss and caches the 2xx response', (done) => { + const middleware = cacheResponse({ ttl: 5000, store }); + const req = { originalUrl: '/api/escrow/123' }; + const res = createMockRes(); + + middleware(req, res, () => { + // Simulate handler sending response + res.json({ data: 'from handler' }); + + expect(res.body).toEqual({ data: 'from handler' }); + expect(res.headers['X-Cache']).toBe('MISS'); + expect(store.get('/api/escrow/123')).toEqual({ data: 'from handler' }); + done(); + }); + }); + + it('returns cached response on cache hit without calling next', () => { + const middleware = cacheResponse({ ttl: 5000, store }); + const req = { originalUrl: '/api/escrow/123' }; + const res = createMockRes(); + + store.set('/api/escrow/123', { data: 'cached' }, 5000); + + let nextCalled = false; + middleware(req, res, () => { nextCalled = true; }); + + expect(nextCalled).toBe(false); + expect(res.body).toEqual({ data: 'cached' }); + expect(res.headers['X-Cache']).toBe('HIT'); + }); + + it('does not cache non-2xx responses', (done) => { + const middleware = cacheResponse({ ttl: 5000, store }); + const req = { originalUrl: '/api/escrow/bad' }; + const res = createMockRes(); + + middleware(req, res, () => { + res.status(500).json({ error: 'fail' }); + + expect(res.body).toEqual({ error: 'fail' }); + expect(store.get('/api/escrow/bad')).toBeUndefined(); + done(); + }); + }); + + it('uses custom keyFn to generate cache key', (done) => { + const keyFn = (r) => `custom:${r.params.id}`; + const middleware = cacheResponse({ ttl: 5000, store, keyFn }); + const req = { originalUrl: '/api/escrow/456', params: { id: '456' } }; + const res = createMockRes(); + + middleware(req, res, () => { + res.json({ data: 'keyed' }); + expect(store.get('custom:456')).toEqual({ data: 'keyed' }); + done(); + }); + }); + + it('falls through to handler when cache store throws', (done) => { + const brokenStore = { + get() { throw new Error('store broken'); }, + set() { throw new Error('store broken'); }, + }; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const middleware = cacheResponse({ ttl: 5000, store: brokenStore }); + const req = { originalUrl: '/api/escrow/123' }; + const res = createMockRes(); + + middleware(req, res, () => { + res.json({ data: 'fallthrough' }); + expect(res.body).toEqual({ data: 'fallthrough' }); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + done(); + }); + }); + + it('logs warning but still sends response when cache store set throws', (done) => { + const setErrorStore = { + get() { return undefined; }, + set() { throw new Error('set broken'); }, + }; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const middleware = cacheResponse({ ttl: 5000, store: setErrorStore }); + const req = { originalUrl: '/api/escrow/789' }; + const res = createMockRes(); + + middleware(req, res, () => { + res.json({ data: 'still works' }); + expect(res.body).toEqual({ data: 'still works' }); + expect(warnSpy).toHaveBeenCalledWith('Cache store set error:', 'set broken'); + warnSpy.mockRestore(); + done(); + }); + }); +}); diff --git a/src/services/cacheStore.js b/src/services/cacheStore.js new file mode 100644 index 00000000..3df9ff4f --- /dev/null +++ b/src/services/cacheStore.js @@ -0,0 +1,85 @@ +/** + * In-memory cache store backed by a native Map. + * Each entry is stored with an expiry timestamp for TTL-based eviction. + * + * @class + */ +class MemoryCacheStore { + /** + * Creates a new MemoryCacheStore instance. + * + * @returns {MemoryCacheStore} A new cache store. + */ + constructor() { + this._cache = new Map(); + } + + /** + * Retrieves a cached value by key. Returns undefined if the key is missing + * or expired. Expired entries are lazily evicted. + * + * @param {string} key - The cache key to look up. + * @returns {*} The cached value, or undefined if missing/expired. + */ + get(key) { + const entry = this._cache.get(key); + if (!entry) { + return undefined; + } + if (Date.now() > entry.expiresAt) { + this._cache.delete(key); + return undefined; + } + return entry.value; + } + + /** + * Stores a value in the cache with a TTL in milliseconds. + * + * @param {string} key - The cache key. + * @param {*} value - The value to cache. + * @param {number} ttlMs - Time-to-live in milliseconds. + * @returns {void} + */ + set(key, value, ttlMs) { + this._cache.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + } + + /** + * Removes a specific entry from the cache. + * + * @param {string} key - The cache key to remove. + * @returns {void} + */ + del(key) { + this._cache.delete(key); + } + + /** + * Removes all entries from the cache. + * + * @returns {void} + */ + clear() { + this._cache.clear(); + } +} + +/** + * Factory function that creates a cache store instance. + * Currently returns a MemoryCacheStore. Future implementations can check + * for REDIS_URL and return a Redis-backed store. + * + * @returns {MemoryCacheStore} A cache store instance. + */ +function createCacheStore() { + return new MemoryCacheStore(); +} + +module.exports = { + MemoryCacheStore, + createCacheStore, +}; diff --git a/src/services/cacheStore.test.js b/src/services/cacheStore.test.js new file mode 100644 index 00000000..c672098a --- /dev/null +++ b/src/services/cacheStore.test.js @@ -0,0 +1,59 @@ +const { MemoryCacheStore, createCacheStore } = require('./cacheStore'); + +describe('MemoryCacheStore', () => { + let store; + + beforeEach(() => { + store = new MemoryCacheStore(); + }); + + it('returns undefined for missing keys', () => { + expect(store.get('nonexistent')).toBeUndefined(); + }); + + it('round-trips a value with set and get', () => { + store.set('key1', { data: 'hello' }, 5000); + expect(store.get('key1')).toEqual({ data: 'hello' }); + }); + + it('returns undefined and evicts expired entries', () => { + const now = Date.now(); + jest.spyOn(Date, 'now') + .mockReturnValueOnce(now) // set call + .mockReturnValueOnce(now + 6000); // get call (after TTL) + + store.set('key1', 'value', 5000); + expect(store.get('key1')).toBeUndefined(); + + Date.now.mockRestore(); + }); + + it('del removes a specific entry', () => { + store.set('key1', 'value1', 5000); + store.set('key2', 'value2', 5000); + store.del('key1'); + expect(store.get('key1')).toBeUndefined(); + expect(store.get('key2')).toBe('value2'); + }); + + it('clear removes all entries', () => { + store.set('key1', 'value1', 5000); + store.set('key2', 'value2', 5000); + store.clear(); + expect(store.get('key1')).toBeUndefined(); + expect(store.get('key2')).toBeUndefined(); + }); + + it('set overwrites existing entries', () => { + store.set('key1', 'old', 5000); + store.set('key1', 'new', 5000); + expect(store.get('key1')).toBe('new'); + }); +}); + +describe('createCacheStore', () => { + it('returns a MemoryCacheStore instance', () => { + const store = createCacheStore(); + expect(store).toBeInstanceOf(MemoryCacheStore); + }); +});