Skip to content

Commit b958f55

Browse files
authored
Merge pull request #174 from gidson5/feature/response-caching
feat(backend): response caching with ETag support and per-route TTLs
2 parents 6a72833 + d36e9fb commit b958f55

7 files changed

Lines changed: 827 additions & 5 deletions

File tree

backend/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,18 @@ app.use((req: Request, res: Response, next: NextFunction) => {
168168
// SLA Tracking middleware
169169
app.use(slaTrackingMiddleware);
170170

171+
// Cache defaults:
172+
// - GET/HEAD: individual routes apply cacheControl() with per-route TTLs.
173+
// - All other methods: always no-store (mutations must never be cached).
174+
app.use((req: Request, res: Response, next: NextFunction) => {
175+
if (req.method !== 'GET' && req.method !== 'HEAD') {
176+
res.setHeader('Cache-Control', 'no-store');
177+
}
178+
// Vary on Accept-Encoding so compressed/uncompressed responses are cached separately
179+
res.setHeader('Vary', 'Accept-Encoding');
180+
next();
181+
});
182+
171183
// Health & Readiness checks
172184
app.use(healthRouter);
173185

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* cache.test.ts
3+
*
4+
* Unit tests for the cacheControl() middleware and CacheTTL constants.
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import type { Request, Response } from 'express';
9+
import { cacheControl, CacheTTL } from '../cache.js';
10+
11+
// ─── Helpers ──────────────────────────────────────────────────────────────────
12+
13+
function makeReq(overrides: Partial<Request> = {}): Request {
14+
return {
15+
method: 'GET',
16+
headers: {},
17+
...overrides,
18+
} as unknown as Request;
19+
}
20+
21+
function makeRes(): {
22+
res: Response;
23+
headers: Record<string, string | number>;
24+
sentStatus: number | null;
25+
sentBody: unknown;
26+
jsonCalled: boolean;
27+
} {
28+
const headers: Record<string, string | number> = {};
29+
let sentStatus: number | null = null;
30+
let sentBody: unknown = undefined;
31+
let jsonCalled = false;
32+
33+
const res = {
34+
setHeader: vi.fn((name: string, value: string | number) => {
35+
headers[name] = value;
36+
}),
37+
status: vi.fn(function (code: number) {
38+
sentStatus = code;
39+
return res;
40+
}),
41+
end: vi.fn(),
42+
json: vi.fn(function (body: unknown) {
43+
jsonCalled = true;
44+
sentBody = body;
45+
return res;
46+
}),
47+
} as unknown as Response;
48+
49+
return { res, headers, get sentStatus() { return sentStatus; }, get sentBody() { return sentBody; }, get jsonCalled() { return jsonCalled; } };
50+
}
51+
52+
// ─── Tests ────────────────────────────────────────────────────────────────────
53+
54+
describe('CacheTTL constants', () => {
55+
it('STATIC is 300 seconds', () => expect(CacheTTL.STATIC).toBe(300));
56+
it('SHORT is 30 seconds', () => expect(CacheTTL.SHORT).toBe(30));
57+
it('IMMUTABLE is 600 seconds', () => expect(CacheTTL.IMMUTABLE).toBe(600));
58+
it('NONE is 0', () => expect(CacheTTL.NONE).toBe(0));
59+
});
60+
61+
describe('cacheControl() middleware', () => {
62+
let next: ReturnType<typeof vi.fn>;
63+
64+
beforeEach(() => {
65+
next = vi.fn();
66+
});
67+
68+
// ── Cache-Control header ────────────────────────────────────────────────────
69+
70+
it('sets public Cache-Control with max-age for a normal GET', () => {
71+
const req = makeReq();
72+
const { res, headers } = makeRes();
73+
const mw = cacheControl({ maxAge: 300 });
74+
75+
mw(req, res, next);
76+
(res.json as ReturnType<typeof vi.fn>)({ data: 1 });
77+
78+
expect(headers['Cache-Control']).toBe('public, max-age=300');
79+
expect(next).toHaveBeenCalledOnce();
80+
});
81+
82+
it('sets private Cache-Control when isPublic is false', () => {
83+
const req = makeReq();
84+
const { res, headers } = makeRes();
85+
const mw = cacheControl({ maxAge: 60, isPublic: false });
86+
87+
mw(req, res, next);
88+
(res.json as ReturnType<typeof vi.fn>)({ data: 1 });
89+
90+
expect(headers['Cache-Control']).toBe('private, max-age=60');
91+
});
92+
93+
it('appends stale-while-revalidate when provided', () => {
94+
const req = makeReq();
95+
const { res, headers } = makeRes();
96+
const mw = cacheControl({ maxAge: 300, staleWhileRevalidate: 60 });
97+
98+
mw(req, res, next);
99+
(res.json as ReturnType<typeof vi.fn>)({ ok: true });
100+
101+
expect(headers['Cache-Control']).toBe('public, max-age=300, stale-while-revalidate=60');
102+
});
103+
104+
it('sets no-store when maxAge is 0', () => {
105+
const req = makeReq();
106+
const { res, headers } = makeRes();
107+
const mw = cacheControl({ maxAge: CacheTTL.NONE });
108+
109+
mw(req, res, next);
110+
(res.json as ReturnType<typeof vi.fn>)({});
111+
112+
expect(headers['Cache-Control']).toBe('no-store');
113+
});
114+
115+
// ── ETag header ─────────────────────────────────────────────────────────────
116+
117+
it('sets an ETag header on the response', () => {
118+
const req = makeReq();
119+
const { res, headers } = makeRes();
120+
const mw = cacheControl({ maxAge: 30 });
121+
122+
mw(req, res, next);
123+
(res.json as ReturnType<typeof vi.fn>)({ value: 42 });
124+
125+
expect(headers['ETag']).toMatch(/^"[0-9a-f]{16}"$/);
126+
});
127+
128+
it('produces the same ETag for identical bodies', () => {
129+
const body = { name: 'agenticpay', version: 1 };
130+
131+
const makeCall = () => {
132+
const req = makeReq();
133+
const { res, headers } = makeRes();
134+
const mw = cacheControl({ maxAge: 60 });
135+
mw(req, res, next);
136+
(res.json as ReturnType<typeof vi.fn>)(body);
137+
return headers['ETag'];
138+
};
139+
140+
expect(makeCall()).toBe(makeCall());
141+
});
142+
143+
it('produces different ETags for different bodies', () => {
144+
const makeCall = (body: unknown) => {
145+
const req = makeReq();
146+
const { res, headers } = makeRes();
147+
const mw = cacheControl({ maxAge: 60 });
148+
mw(req, res, next);
149+
(res.json as ReturnType<typeof vi.fn>)(body);
150+
return headers['ETag'];
151+
};
152+
153+
expect(makeCall({ a: 1 })).not.toBe(makeCall({ a: 2 }));
154+
});
155+
156+
// ── Conditional GET / 304 ───────────────────────────────────────────────────
157+
158+
it('returns 304 and skips body when If-None-Match matches the ETag', () => {
159+
const body = { catalog: [] };
160+
161+
// First request — get the ETag
162+
const reqA = makeReq();
163+
const { res: resA, headers: headersA } = makeRes();
164+
cacheControl({ maxAge: 300 })(reqA, resA, vi.fn());
165+
(resA.json as ReturnType<typeof vi.fn>)(body);
166+
const etag = headersA['ETag'] as string;
167+
168+
// Second request — client sends the ETag back
169+
const reqB = makeReq({ headers: { 'if-none-match': etag } as any });
170+
const { res: resB, headers: headersB } = makeRes();
171+
cacheControl({ maxAge: 300 })(reqB, resB, vi.fn());
172+
(resB.json as ReturnType<typeof vi.fn>)(body);
173+
174+
expect((resB.status as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(304);
175+
expect((resB.end as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
176+
// ETag header should still be set even on 304
177+
expect(headersB['ETag']).toMatch(/^"[0-9a-f]{16}"$/);
178+
});
179+
180+
it('does NOT return 304 when If-None-Match does not match', () => {
181+
const req = makeReq({ headers: { 'if-none-match': '"outdatedETagValue"' } as any });
182+
const { res } = makeRes();
183+
184+
cacheControl({ maxAge: 60 })(req, res, next);
185+
(res.json as ReturnType<typeof vi.fn>)({ updated: true });
186+
187+
expect((res.status as ReturnType<typeof vi.fn>)).not.toHaveBeenCalledWith(304);
188+
});
189+
190+
// ── Non-GET passthrough ─────────────────────────────────────────────────────
191+
192+
it('calls next() without modifying res.json for POST requests', () => {
193+
const req = makeReq({ method: 'POST' });
194+
const { res } = makeRes();
195+
const originalJson = res.json;
196+
197+
cacheControl({ maxAge: 300 })(req, res, next);
198+
199+
expect(next).toHaveBeenCalledOnce();
200+
// res.json should NOT have been replaced (POST is not intercepted)
201+
expect(res.json).toBe(originalJson);
202+
});
203+
204+
it('calls next() without modifying res.json for DELETE requests', () => {
205+
const req = makeReq({ method: 'DELETE' });
206+
const { res } = makeRes();
207+
const originalJson = res.json;
208+
209+
cacheControl({ maxAge: 300 })(req, res, next);
210+
211+
expect(res.json).toBe(originalJson);
212+
});
213+
214+
it('intercepts HEAD requests the same as GET', () => {
215+
const req = makeReq({ method: 'HEAD' });
216+
const { res, headers } = makeRes();
217+
218+
cacheControl({ maxAge: 120 })(req, res, next);
219+
(res.json as ReturnType<typeof vi.fn>)({});
220+
221+
expect(headers['Cache-Control']).toBe('public, max-age=120');
222+
});
223+
});

0 commit comments

Comments
 (0)