|
1 | 1 | /** |
2 | 2 | * Caching integration tests. |
3 | 3 | * |
4 | | - * Exercises `sourcedFrom()` and `allowStaleWhileRevalidate()` end-to-end through a |
5 | | - * live Harper instance. A lightweight HTTP origin server is started in-process so |
6 | | - * every fetch can be counted without mocking internals. |
7 | | - * |
8 | | - * Scenarios: |
9 | | - * |
10 | | - * 1. **Cache miss → origin fetch → cached** — first GET triggers one origin |
11 | | - * request; second GET is served from cache with no additional origin hit. |
12 | | - * |
13 | | - * 2. **404 on origin → 404 from Harper** — when the origin returns 404 the |
14 | | - * resource does the same. |
15 | | - * |
16 | | - * 3. **DELETE invalidates cache → re-fetch** — after an explicit DELETE the next |
17 | | - * GET must go back to the origin. |
18 | | - * |
19 | | - * 4. **allowStaleWhileRevalidate** — a stale record is returned immediately while |
20 | | - * a background revalidation fires; the subsequent GET reflects the refreshed |
21 | | - * value without an additional origin hit. |
| 4 | + * sourcedFrom cache miss/hit, invalidation, stale-while-revalidate, and stampede |
| 5 | + * are comprehensively covered by unitTests/resources/caching.test.js. |
| 6 | + * This integration test focuses on scenarios requiring a live Harper instance |
| 7 | + * that the unit suite cannot cover. |
| 8 | + * |
| 9 | + * TODO: replicationSource: true (sourcedFrom fetches on replica node, not origin) |
| 10 | + * requires a 2-node cluster setup — tracked in |
| 11 | + * https://github.com/HarperFast/harper/issues/1189. |
| 12 | + * See integrationTests/ for existing infrastructure patterns once a cluster harness |
| 13 | + * is available. |
22 | 14 | */ |
23 | | -import { suite, test, before, after } from 'node:test'; |
24 | | -import { strictEqual, ok } from 'node:assert/strict'; |
25 | | -import { createServer, type Server } from 'node:http'; |
26 | | -import type { AddressInfo } from 'node:net'; |
27 | | -import { setTimeout as delay } from 'node:timers/promises'; |
28 | | - |
29 | | -import { startHarper, teardownHarper } from '@harperfast/integration-testing'; |
30 | | -// @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine |
31 | | -import { createApiClient } from '../apiTests/utils/client.mjs'; |
32 | | -// @ts-expect-error utils/lifecycle.mjs has no type declarations; runtime resolves fine |
33 | | -import { restartHttpWorkers } from '../apiTests/utils/lifecycle.mjs'; |
34 | | - |
35 | | -// --------------------------------------------------------------------------- |
36 | | -// Mock origin server helpers |
37 | | -// --------------------------------------------------------------------------- |
38 | | - |
39 | | -interface MockOrigin { |
40 | | - url: string; |
41 | | - close(): Promise<void>; |
42 | | - fetchCount(key: string): number; |
43 | | - resetCounts(): void; |
44 | | - setData(key: string, value: unknown): void; |
45 | | - deleteData(key: string): void; |
46 | | -} |
47 | | - |
48 | | -async function startMockOrigin(): Promise<MockOrigin> { |
49 | | - const fetchCounts = new Map<string, number>(); |
50 | | - const data = new Map<string, unknown>(); |
51 | | - |
52 | | - const server: Server = createServer((req, res) => { |
53 | | - const key = req.url?.slice(1) ?? ''; |
54 | | - fetchCounts.set(key, (fetchCounts.get(key) ?? 0) + 1); |
55 | | - const value = data.get(key); |
56 | | - if (value !== undefined) { |
57 | | - res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=10' }); |
58 | | - res.end(JSON.stringify(value)); |
59 | | - } else { |
60 | | - res.writeHead(404); |
61 | | - res.end(); |
62 | | - } |
63 | | - }); |
64 | | - |
65 | | - await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); |
66 | | - const addr = server.address() as AddressInfo; |
67 | | - const url = `http://127.0.0.1:${addr.port}`; |
68 | | - |
69 | | - return { |
70 | | - url, |
71 | | - close: () => { |
72 | | - server.closeAllConnections(); |
73 | | - return new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); |
74 | | - }, |
75 | | - fetchCount: (key) => fetchCounts.get(key) ?? 0, |
76 | | - resetCounts: () => fetchCounts.clear(), |
77 | | - setData: (key, value) => data.set(key, value), |
78 | | - deleteData: (key) => data.delete(key), |
79 | | - }; |
80 | | -} |
81 | | - |
82 | | -// --------------------------------------------------------------------------- |
83 | | -// Component definition |
84 | | -// --------------------------------------------------------------------------- |
85 | | - |
86 | | -const SCHEMA_GRAPHQL = ` |
87 | | -type CachedProduct @table(database: "cachingtest") @sealed @export { |
88 | | - id: ID! @primaryKey |
89 | | - name: String |
90 | | - price: Float |
91 | | -} |
92 | | -
|
93 | | -type StaleProduct @table(database: "cachingtest") @sealed @export { |
94 | | - id: ID! @primaryKey |
95 | | - name: String |
96 | | - revision: Int |
97 | | -} |
98 | | -`.trim(); |
99 | | - |
100 | | -// resources.js is evaluated by Harper's component loader at boot. The loader |
101 | | -// provides `Resource` and `databases` as globals — no imports needed. |
102 | | -// |
103 | | -// CachedProduct: plain sourcedFrom with a short TTL so we can exercise |
104 | | -// cache-miss and invalidation in fast wall-clock time. |
105 | | -// |
106 | | -// StaleProduct: same source wiring but the class overrides |
107 | | -// allowStaleWhileRevalidate() to always return true, enabling SWR behaviour. |
108 | | -function buildResourcesJs(originUrl: string): string { |
109 | | - return [ |
110 | | - `const { CachedProduct, StaleProduct } = databases.cachingtest;`, |
111 | | - ``, |
112 | | - `// Source for CachedProduct — forwards reads to the mock origin.`, |
113 | | - `// Implements delete() so REST DELETE on the table can propagate`, |
114 | | - `// (clears the local cache entry without touching the origin).`, |
115 | | - `export class ProductSource extends Resource {`, |
116 | | - `\tasync get() {`, |
117 | | - `\t\tconst id = this.getId();`, |
118 | | - `\t\tconst response = await fetch(${JSON.stringify(originUrl)} + '/' + id);`, |
119 | | - `\t\tif (!response.ok) {`, |
120 | | - `\t\t\tconst err = new Error('Origin returned ' + response.status + ' for ' + id);`, |
121 | | - `\t\t\terr.statusCode = response.status;`, |
122 | | - `\t\t\tthrow err;`, |
123 | | - `\t\t}`, |
124 | | - `\t\treturn response.json();`, |
125 | | - `\t}`, |
126 | | - `\tdelete() {`, |
127 | | - `\t\t// allow DELETE to clear the cached entry`, |
128 | | - `\t}`, |
129 | | - `}`, |
130 | | - ``, |
131 | | - // 30 s expiration: long enough that the non-expiry tests never race the TTL, |
132 | | - // even on slow CI runners. |
133 | | - `CachedProduct.sourcedFrom(ProductSource, { expiration: 30 });`, |
134 | | - ``, |
135 | | - `// Source for StaleProduct — same fetch logic.`, |
136 | | - `export class StaleProdSource extends Resource {`, |
137 | | - `\tasync get() {`, |
138 | | - `\t\tconst id = this.getId();`, |
139 | | - `\t\tconst response = await fetch(${JSON.stringify(originUrl)} + '/' + id);`, |
140 | | - `\t\tif (!response.ok) {`, |
141 | | - `\t\t\tconst err = new Error('Origin returned ' + response.status + ' for ' + id);`, |
142 | | - `\t\t\terr.statusCode = response.status;`, |
143 | | - `\t\t\tthrow err;`, |
144 | | - `\t\t}`, |
145 | | - `\t\treturn response.json();`, |
146 | | - `\t}`, |
147 | | - `}`, |
148 | | - ``, |
149 | | - `// Wire stale-while-revalidate directly on StaleProduct by overriding the method`, |
150 | | - `// on the table class after calling sourcedFrom, so /StaleProduct/* routes use SWR.`, |
151 | | - `// expiration (100 ms) < eviction (10 s): stale entries linger in the DB long`, |
152 | | - `// enough for SWR to serve them while background revalidation runs.`, |
153 | | - `StaleProduct.sourcedFrom(StaleProdSource, { expiration: 0.1, eviction: 10 });`, |
154 | | - `StaleProduct.prototype.allowStaleWhileRevalidate = function(_entry, _id) { return true; };`, |
155 | | - ``, |
156 | | - ].join('\n'); |
157 | | -} |
158 | | - |
159 | | -// --------------------------------------------------------------------------- |
160 | | -// Suite |
161 | | -// --------------------------------------------------------------------------- |
162 | | - |
163 | | -suite('Caching: sourcedFrom and allowStaleWhileRevalidate', (ctx: any) => { |
164 | | - let origin: MockOrigin; |
165 | | - let client: any; |
166 | | - |
167 | | - before(async () => { |
168 | | - origin = await startMockOrigin(); |
169 | | - |
170 | | - await startHarper(ctx, { config: {}, env: {} }); |
171 | | - client = createApiClient(ctx.harper); |
172 | | - |
173 | | - await client |
174 | | - .req() |
175 | | - .send({ operation: 'add_component', project: 'cachingtest' }) |
176 | | - .expect((r: any) => { |
177 | | - const text = JSON.stringify(r.body); |
178 | | - ok( |
179 | | - text.includes('Successfully added project') || text.includes('Project already exists'), |
180 | | - `add_component failed: ${r.text}` |
181 | | - ); |
182 | | - }); |
183 | | - |
184 | | - await client |
185 | | - .req() |
186 | | - .send({ |
187 | | - operation: 'set_component_file', |
188 | | - project: 'cachingtest', |
189 | | - file: 'schema.graphql', |
190 | | - payload: SCHEMA_GRAPHQL, |
191 | | - }) |
192 | | - .expect((r: any) => ok(r.body?.message?.includes?.('Successfully set component: schema.graphql'), r.text)) |
193 | | - .expect(200); |
194 | | - |
195 | | - await client |
196 | | - .req() |
197 | | - .send({ |
198 | | - operation: 'set_component_file', |
199 | | - project: 'cachingtest', |
200 | | - file: 'resources.js', |
201 | | - payload: buildResourcesJs(origin.url), |
202 | | - }) |
203 | | - .expect((r: any) => ok(r.body?.message?.includes?.('Successfully set component: resources.js'), r.text)) |
204 | | - .expect(200); |
205 | | - |
206 | | - await restartHttpWorkers(client, '/openapi'); |
207 | | - }); |
208 | | - |
209 | | - after(async () => { |
210 | | - try { |
211 | | - await teardownHarper(ctx); |
212 | | - } finally { |
213 | | - await origin.close(); |
214 | | - } |
215 | | - }); |
216 | | - |
217 | | - // ------------------------------------------------------------------------- |
218 | | - // Test 1: cache miss → origin fetch → cache populated → second GET no re-fetch |
219 | | - // ------------------------------------------------------------------------- |
220 | | - test('cache miss fetches from origin; second GET is served from cache', { timeout: 15000 }, async () => { |
221 | | - const id = 'prod-1'; |
222 | | - origin.setData(id, { id, name: 'Widget', price: 9.99 }); |
223 | | - origin.resetCounts(); |
224 | | - |
225 | | - const baseUrl = ctx.harper.httpURL; |
226 | | - const authHeader = `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; |
227 | | - |
228 | | - // First GET: cache miss → origin should be hit once |
229 | | - const r1 = await fetch(`${baseUrl}/CachedProduct/${id}`, { headers: { Authorization: authHeader } }); |
230 | | - strictEqual(r1.status, 200, 'First GET should succeed'); |
231 | | - const body1 = await r1.json(); |
232 | | - strictEqual(body1.name, 'Widget'); |
233 | | - strictEqual(body1.price, 9.99); |
234 | | - strictEqual(origin.fetchCount(id), 1, 'Origin should have been called once after cache miss'); |
235 | | - |
236 | | - // Second GET: should be served from cache — no additional origin hit |
237 | | - const r2 = await fetch(`${baseUrl}/CachedProduct/${id}`, { headers: { Authorization: authHeader } }); |
238 | | - strictEqual(r2.status, 200, 'Second GET should succeed'); |
239 | | - const body2 = await r2.json(); |
240 | | - strictEqual(body2.name, 'Widget'); |
241 | | - strictEqual(origin.fetchCount(id), 1, 'Origin should not be called again on cache hit'); |
242 | | - }); |
243 | | - |
244 | | - // ------------------------------------------------------------------------- |
245 | | - // Test 2: cache miss returns 404 when origin 404s |
246 | | - // ------------------------------------------------------------------------- |
247 | | - test('cache miss propagates origin 404 to the client', { timeout: 10000 }, async () => { |
248 | | - const id = 'nonexistent-product'; |
249 | | - // Do not seed origin data — it will return 404 |
250 | | - origin.resetCounts(); |
251 | | - |
252 | | - const baseUrl = ctx.harper.httpURL; |
253 | | - const authHeader = `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; |
254 | | - |
255 | | - const r = await fetch(`${baseUrl}/CachedProduct/${id}`, { headers: { Authorization: authHeader } }); |
256 | | - await r.text(); |
257 | | - strictEqual(r.status, 404, 'Should return 404 when origin returns 404'); |
258 | | - strictEqual(origin.fetchCount(id), 1, 'Origin should have been called once'); |
259 | | - }); |
260 | | - |
261 | | - // ------------------------------------------------------------------------- |
262 | | - // Test 3: explicit DELETE removes cached record → next GET re-fetches |
263 | | - // ------------------------------------------------------------------------- |
264 | | - test('DELETE invalidates cache; subsequent GET re-fetches from origin', { timeout: 15000 }, async () => { |
265 | | - const id = 'prod-delete'; |
266 | | - origin.setData(id, { id, name: 'Gadget', price: 19.99 }); |
267 | | - origin.resetCounts(); |
268 | | - |
269 | | - const baseUrl = ctx.harper.httpURL; |
270 | | - const authHeader = `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; |
271 | | - |
272 | | - // Populate cache |
273 | | - const r1 = await fetch(`${baseUrl}/CachedProduct/${id}`, { headers: { Authorization: authHeader } }); |
274 | | - await r1.text(); |
275 | | - strictEqual(r1.status, 200); |
276 | | - strictEqual(origin.fetchCount(id), 1, 'Should fetch from origin on first GET'); |
277 | | - |
278 | | - // Invalidate via DELETE |
279 | | - const rDel = await fetch(`${baseUrl}/CachedProduct/${id}`, { |
280 | | - method: 'DELETE', |
281 | | - headers: { Authorization: authHeader }, |
282 | | - }); |
283 | | - await rDel.text(); |
284 | | - ok(rDel.status === 200 || rDel.status === 204, `DELETE should succeed, got ${rDel.status}`); |
285 | | - |
286 | | - // Next GET must go back to the origin |
287 | | - const r2 = await fetch(`${baseUrl}/CachedProduct/${id}`, { headers: { Authorization: authHeader } }); |
288 | | - strictEqual(r2.status, 200); |
289 | | - const body2 = await r2.json(); |
290 | | - strictEqual(body2.name, 'Gadget'); |
291 | | - strictEqual(origin.fetchCount(id), 2, 'Origin should be called again after cache invalidation'); |
292 | | - }); |
293 | | - |
294 | | - // ------------------------------------------------------------------------- |
295 | | - // Test 4: allowStaleWhileRevalidate — stale returned immediately, revalidated in bg |
296 | | - // ------------------------------------------------------------------------- |
297 | | - test( |
298 | | - 'allowStaleWhileRevalidate serves stale immediately and revalidates in background', |
299 | | - { timeout: 20000 }, |
300 | | - async () => { |
301 | | - const id = 'stale-1'; |
302 | | - |
303 | | - // Revision 1 in origin |
304 | | - origin.setData(id, { id, name: 'Stale Widget', revision: 1 }); |
305 | | - origin.resetCounts(); |
306 | | - |
307 | | - const baseUrl = ctx.harper.httpURL; |
308 | | - const authHeader = `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; |
309 | | - |
310 | | - // Prime the cache with revision 1 |
311 | | - const r1 = await fetch(`${baseUrl}/StaleProduct/${id}`, { headers: { Authorization: authHeader } }); |
312 | | - strictEqual(r1.status, 200); |
313 | | - const body1 = await r1.json(); |
314 | | - strictEqual(body1.revision, 1, 'Should get revision 1 on first fetch'); |
315 | | - strictEqual(origin.fetchCount(id), 1, 'Origin called once to prime cache'); |
316 | | - |
317 | | - // Update origin to revision 2 and wait for TTL to expire (expiration: 0.1 s = 100 ms) |
318 | | - origin.setData(id, { id, name: 'Fresh Widget', revision: 2 }); |
319 | | - await delay(250); // wait longer than the 100 ms TTL so the entry is stale but not evicted |
320 | | - origin.resetCounts(); |
321 | | - |
322 | | - // GET while stale: allowStaleWhileRevalidate should return the stale value immediately |
323 | | - // and kick off a background revalidation |
324 | | - const r2 = await fetch(`${baseUrl}/StaleProduct/${id}`, { headers: { Authorization: authHeader } }); |
325 | | - strictEqual(r2.status, 200, 'Stale GET should succeed'); |
326 | | - const body2 = await r2.json(); |
327 | | - // The stale value (revision 1) should be served immediately |
328 | | - strictEqual(body2.revision, 1, 'Stale value should be returned immediately'); |
329 | | - |
330 | | - // Background revalidation must start: poll until the mock origin has received |
331 | | - // the fetch. Asserting synchronously here is racy because the HTTP round-trip |
332 | | - // can complete before the background task executes. |
333 | | - const bgDeadline = Date.now() + 5000; |
334 | | - while (origin.fetchCount(id) === 0 && Date.now() < bgDeadline) { |
335 | | - await delay(25); |
336 | | - } |
337 | | - ok(origin.fetchCount(id) >= 1, 'Background revalidation should have started'); |
338 | | - |
339 | | - // Wait for the background revalidation to complete, then re-fetch. |
340 | | - // Poll at 25 ms — shorter than the 100 ms TTL so the freshly-written entry |
341 | | - // does not expire again before we read it. |
342 | | - let freshBody: any; |
343 | | - const deadline = Date.now() + 5000; |
344 | | - while (Date.now() < deadline) { |
345 | | - await delay(25); |
346 | | - const r3 = await fetch(`${baseUrl}/StaleProduct/${id}`, { headers: { Authorization: authHeader } }); |
347 | | - freshBody = await r3.json(); |
348 | | - if (freshBody.revision === 2) break; |
349 | | - } |
350 | | - strictEqual(freshBody?.revision, 2, 'Cache should reflect the fresh value after background revalidation'); |
351 | | - // Only one origin fetch should have occurred for the full revalidation cycle |
352 | | - strictEqual(origin.fetchCount(id), 1, 'Only one origin fetch should have occurred for revalidation'); |
353 | | - } |
354 | | - ); |
355 | | -}); |
0 commit comments