Skip to content

Commit 8ca4769

Browse files
kriszypclaude
andcommitted
test: scope down to replicationSource gap; redirect sourcedFrom/SWR to unit suite (#1208)
The four previous tests (cache miss/hit, 404 passthrough, DELETE invalidation, SWR) duplicate coverage already in unitTests/resources/caching.test.js. Replace them with an explanatory comment block and a TODO pointing to issue #1189 for the one genuinely new scenario (replicationSource: true) that requires a 2-node cluster harness not yet available in integrationTests/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2e3c90c commit 8ca4769

1 file changed

Lines changed: 10 additions & 351 deletions

File tree

Lines changed: 10 additions & 351 deletions
Original file line numberDiff line numberDiff line change
@@ -1,355 +1,14 @@
11
/**
22
* Caching integration tests.
33
*
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.
2214
*/
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

Comments
 (0)