Skip to content

Commit 2f19dbc

Browse files
authored
Merge pull request #88 from okekefrancis112/escrow
feat(api): add TTL cache for escrow read endpoint
2 parents ad81f88 + 6f38ab4 commit 2f19dbc

8 files changed

Lines changed: 455 additions & 0 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ STELLAR_NETWORK=testnet
99
HORIZON_URL=https://horizon-testnet.stellar.org
1010
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
1111

12+
# Cache
13+
ESCROW_CACHE_TTL_SECONDS=30
14+
1215
# DB (when added)
1316
# DATABASE_URL=postgresql://user:pass@localhost:5432/liquifact
1417
# REDIS_URL=redis://localhost:6379

src/__tests__/escrowCache.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const request = require('supertest');
2+
const { app, resetStore } = require('../index');
3+
4+
describe('Escrow Cache Integration', () => {
5+
beforeEach(() => {
6+
resetStore();
7+
});
8+
9+
it('serves cached response on second request for same invoiceId', async () => {
10+
const res1 = await request(app).get('/api/escrow/inv_100');
11+
expect(res1.status).toBe(200);
12+
expect(res1.headers['x-cache']).toBe('MISS');
13+
expect(res1.body.data).toHaveProperty('invoiceId', 'inv_100');
14+
15+
const res2 = await request(app).get('/api/escrow/inv_100');
16+
expect(res2.status).toBe(200);
17+
expect(res2.headers['x-cache']).toBe('HIT');
18+
expect(res2.body.data).toEqual(res1.body.data);
19+
});
20+
21+
it('caches different invoiceIds independently', async () => {
22+
const res1 = await request(app).get('/api/escrow/inv_200');
23+
expect(res1.headers['x-cache']).toBe('MISS');
24+
25+
const res2 = await request(app).get('/api/escrow/inv_300');
26+
expect(res2.headers['x-cache']).toBe('MISS');
27+
28+
const res3 = await request(app).get('/api/escrow/inv_200');
29+
expect(res3.headers['x-cache']).toBe('HIT');
30+
});
31+
32+
it('returns MISS after TTL expires', async () => {
33+
// Override cache TTL to 1ms for this test by directly accessing the store
34+
// We test TTL expiry via the cacheStore unit tests (Task 1).
35+
// Here we verify the header is MISS on first call as a smoke test.
36+
const res1 = await request(app).get('/api/escrow/inv_400');
37+
expect(res1.status).toBe(200);
38+
expect(res1.headers['x-cache']).toBe('MISS');
39+
});
40+
41+
it('returns correct response structure', async () => {
42+
const res = await request(app).get('/api/escrow/inv_500');
43+
expect(res.status).toBe(200);
44+
expect(res.body).toHaveProperty('data');
45+
expect(res.body).toHaveProperty('message');
46+
expect(res.body.data).toHaveProperty('invoiceId', 'inv_500');
47+
expect(res.body.data).toHaveProperty('status');
48+
expect(res.body.data).toHaveProperty('fundedAmount');
49+
});
50+
});

src/config/cache.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const DEFAULT_ESCROW_TTL_SECONDS = 30;
2+
3+
/**
4+
* Parses the escrow cache TTL from environment variables.
5+
* Falls back to the default if the value is missing or not a valid number.
6+
*
7+
* @param {NodeJS.ProcessEnv} env - Environment variables to read from.
8+
* @returns {{ escrowTtl: number }} Cache configuration with TTL in milliseconds.
9+
*/
10+
function parseCacheConfig(env = process.env) {
11+
const raw = env.ESCROW_CACHE_TTL_SECONDS;
12+
const parsed = parseInt(raw, 10);
13+
const seconds = Number.isFinite(parsed) && parsed > 0
14+
? parsed
15+
: DEFAULT_ESCROW_TTL_SECONDS;
16+
17+
return {
18+
escrowTtl: seconds * 1000,
19+
};
20+
}
21+
22+
const cacheConfig = parseCacheConfig();
23+
24+
module.exports = {
25+
cacheConfig,
26+
parseCacheConfig,
27+
};

src/config/cache.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
describe('cacheConfig', () => {
2+
const originalEnv = process.env;
3+
4+
beforeEach(() => {
5+
jest.resetModules();
6+
process.env = { ...originalEnv };
7+
});
8+
9+
afterEach(() => {
10+
process.env = originalEnv;
11+
});
12+
13+
it('uses default TTL of 30000ms when env var is not set', () => {
14+
delete process.env.ESCROW_CACHE_TTL_SECONDS;
15+
const { cacheConfig } = require('./cache');
16+
expect(cacheConfig.escrowTtl).toBe(30000);
17+
});
18+
19+
it('parses ESCROW_CACHE_TTL_SECONDS from env and converts to ms', () => {
20+
process.env.ESCROW_CACHE_TTL_SECONDS = '60';
21+
const { cacheConfig } = require('./cache');
22+
expect(cacheConfig.escrowTtl).toBe(60000);
23+
});
24+
25+
it('falls back to default when env var is not a valid number', () => {
26+
process.env.ESCROW_CACHE_TTL_SECONDS = 'abc';
27+
const { cacheConfig } = require('./cache');
28+
expect(cacheConfig.escrowTtl).toBe(30000);
29+
});
30+
});

src/middleware/cache.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Creates an Express middleware that caches JSON responses with a TTL.
3+
*
4+
* On cache hit, returns the cached JSON and sets X-Cache: HIT header.
5+
* On cache miss, intercepts res.json() to capture and cache 2xx responses,
6+
* then sets X-Cache: MISS header.
7+
*
8+
* Cache store errors are caught and logged — the request falls through
9+
* to the route handler so the cache never blocks a request.
10+
*
11+
* @param {object} options - Middleware configuration.
12+
* @param {number} options.ttl - Cache TTL in milliseconds.
13+
* @param {object} options.store - Cache store instance with get/set methods.
14+
* @param {Function} [options.keyFn] - Function to derive cache key from request. Defaults to req.originalUrl.
15+
* @returns {Function} Express middleware function.
16+
*/
17+
function cacheResponse({ ttl, store, keyFn }) {
18+
/**
19+
* Resolves the cache key for a given request.
20+
*
21+
* @param {import('express').Request} req - The Express request.
22+
* @returns {string} The cache key.
23+
*/
24+
const resolveKey = keyFn || ((req) => req.originalUrl);
25+
26+
return (req, res, next) => {
27+
let cached;
28+
const key = resolveKey(req);
29+
30+
try {
31+
cached = store.get(key);
32+
} catch (err) {
33+
console.warn('Cache store get error, falling through:', err.message);
34+
return next();
35+
}
36+
37+
if (cached !== undefined) {
38+
res.set('X-Cache', 'HIT');
39+
return res.json(cached);
40+
}
41+
42+
res.set('X-Cache', 'MISS');
43+
44+
const originalJson = res.json.bind(res);
45+
46+
/**
47+
* Patched res.json that caches 2xx responses before sending.
48+
*
49+
* @param {*} body - The response body to send.
50+
* @returns {object} The Express response.
51+
*/
52+
res.json = (body) => {
53+
if (res.statusCode >= 200 && res.statusCode < 300) {
54+
try {
55+
store.set(key, body, ttl);
56+
} catch (err) {
57+
console.warn('Cache store set error:', err.message);
58+
}
59+
}
60+
return originalJson(body);
61+
};
62+
63+
return next();
64+
};
65+
}
66+
67+
module.exports = {
68+
cacheResponse,
69+
};

src/middleware/cache.test.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const { cacheResponse } = require('./cache');
2+
const { MemoryCacheStore } = require('../services/cacheStore');
3+
4+
/**
5+
* Creates a minimal mock response object for testing.
6+
*
7+
* @returns {object} Mock Express response.
8+
*/
9+
function createMockRes() {
10+
const res = {
11+
statusCode: 200,
12+
headers: {},
13+
body: null,
14+
status(code) {
15+
res.statusCode = code;
16+
return res;
17+
},
18+
json(data) {
19+
res.body = data;
20+
return res;
21+
},
22+
set(name, value) {
23+
res.headers[name] = value;
24+
return res;
25+
},
26+
};
27+
return res;
28+
}
29+
30+
describe('cacheResponse', () => {
31+
let store;
32+
33+
beforeEach(() => {
34+
store = new MemoryCacheStore();
35+
});
36+
37+
it('calls next on cache miss and caches the 2xx response', (done) => {
38+
const middleware = cacheResponse({ ttl: 5000, store });
39+
const req = { originalUrl: '/api/escrow/123' };
40+
const res = createMockRes();
41+
42+
middleware(req, res, () => {
43+
// Simulate handler sending response
44+
res.json({ data: 'from handler' });
45+
46+
expect(res.body).toEqual({ data: 'from handler' });
47+
expect(res.headers['X-Cache']).toBe('MISS');
48+
expect(store.get('/api/escrow/123')).toEqual({ data: 'from handler' });
49+
done();
50+
});
51+
});
52+
53+
it('returns cached response on cache hit without calling next', () => {
54+
const middleware = cacheResponse({ ttl: 5000, store });
55+
const req = { originalUrl: '/api/escrow/123' };
56+
const res = createMockRes();
57+
58+
store.set('/api/escrow/123', { data: 'cached' }, 5000);
59+
60+
let nextCalled = false;
61+
middleware(req, res, () => { nextCalled = true; });
62+
63+
expect(nextCalled).toBe(false);
64+
expect(res.body).toEqual({ data: 'cached' });
65+
expect(res.headers['X-Cache']).toBe('HIT');
66+
});
67+
68+
it('does not cache non-2xx responses', (done) => {
69+
const middleware = cacheResponse({ ttl: 5000, store });
70+
const req = { originalUrl: '/api/escrow/bad' };
71+
const res = createMockRes();
72+
73+
middleware(req, res, () => {
74+
res.status(500).json({ error: 'fail' });
75+
76+
expect(res.body).toEqual({ error: 'fail' });
77+
expect(store.get('/api/escrow/bad')).toBeUndefined();
78+
done();
79+
});
80+
});
81+
82+
it('uses custom keyFn to generate cache key', (done) => {
83+
const keyFn = (r) => `custom:${r.params.id}`;
84+
const middleware = cacheResponse({ ttl: 5000, store, keyFn });
85+
const req = { originalUrl: '/api/escrow/456', params: { id: '456' } };
86+
const res = createMockRes();
87+
88+
middleware(req, res, () => {
89+
res.json({ data: 'keyed' });
90+
expect(store.get('custom:456')).toEqual({ data: 'keyed' });
91+
done();
92+
});
93+
});
94+
95+
it('falls through to handler when cache store throws', (done) => {
96+
const brokenStore = {
97+
get() { throw new Error('store broken'); },
98+
set() { throw new Error('store broken'); },
99+
};
100+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
101+
const middleware = cacheResponse({ ttl: 5000, store: brokenStore });
102+
const req = { originalUrl: '/api/escrow/123' };
103+
const res = createMockRes();
104+
105+
middleware(req, res, () => {
106+
res.json({ data: 'fallthrough' });
107+
expect(res.body).toEqual({ data: 'fallthrough' });
108+
expect(warnSpy).toHaveBeenCalled();
109+
warnSpy.mockRestore();
110+
done();
111+
});
112+
});
113+
114+
it('logs warning but still sends response when cache store set throws', (done) => {
115+
const setErrorStore = {
116+
get() { return undefined; },
117+
set() { throw new Error('set broken'); },
118+
};
119+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
120+
const middleware = cacheResponse({ ttl: 5000, store: setErrorStore });
121+
const req = { originalUrl: '/api/escrow/789' };
122+
const res = createMockRes();
123+
124+
middleware(req, res, () => {
125+
res.json({ data: 'still works' });
126+
expect(res.body).toEqual({ data: 'still works' });
127+
expect(warnSpy).toHaveBeenCalledWith('Cache store set error:', 'set broken');
128+
warnSpy.mockRestore();
129+
done();
130+
});
131+
});
132+
});

0 commit comments

Comments
 (0)