-
Notifications
You must be signed in to change notification settings - Fork 0
02 Invalidate Cache v5 Upgrade #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 02-invalidate-example
Are you sure you want to change the base?
Changes from all commits
ba04742
a766314
686668a
a97a06f
4d6d4c2
fa00ce7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| name: Integration Tests | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| workflow_dispatch: | ||
| inputs: | ||
| node-version: | ||
| description: 'Node.js version' | ||
| required: true | ||
| type: choice | ||
| default: 'all' | ||
| options: | ||
| - 'all' | ||
| - '22' | ||
| - '24' | ||
| - '26' | ||
|
|
||
| jobs: | ||
| generate-node-version-matrix: | ||
| name: Generate Node Version Matrix | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| node-versions: ${{ steps.set-node-versions.outputs.node-versions }} | ||
|
|
||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
|
|
||
| - name: Set Node versions | ||
| id: set-node-versions | ||
| env: | ||
| NODE_VER: ${{ github.event.inputs.node-version }} | ||
| run: | | ||
| if [ "$NODE_VER" == "all" ] || [ -z "$NODE_VER" ]; then | ||
| echo "node-versions=[22, 24, 26]" >> $GITHUB_OUTPUT | ||
| else | ||
| echo "node-versions=[$NODE_VER]" >> $GITHUB_OUTPUT | ||
| fi | ||
|
|
||
| integration-tests: | ||
| name: Integration Tests (Node ${{ matrix.node-version }}) | ||
| needs: [generate-node-version-matrix] | ||
| runs-on: ubuntu-latest | ||
|
|
||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| node-version: ${{ fromJSON(needs.generate-node-version-matrix.outputs.node-versions) }} | ||
|
|
||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
|
|
||
| - name: Set up Node.js | ||
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version: ${{ matrix.node-version }} | ||
|
|
||
| - name: Install dependencies | ||
| run: npm ci | ||
|
|
||
| - name: Run integration tests | ||
| run: npm run test:integration | ||
| env: | ||
| HARPER_INTEGRATION_TEST_LOG_DIR: /tmp/harper-test-logs | ||
| FORCE_COLOR: '1' | ||
|
|
||
| - name: Upload Harper logs on failure | ||
| if: failure() | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: harper-logs-node-${{ matrix.node-version }} | ||
| path: /tmp/harper-test-logs/ | ||
| retention-days: 7 |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,162 @@ | ||||||||
| /** | ||||||||
| * Verifies the JokeCache resource which caches jokes from an external API. | ||||||||
| * - GET /JokeCache/:id triggers a read-through fetch from the joke API and caches the result. | ||||||||
| * - A second GET is served from the cache and a conditional request returns a real 304. | ||||||||
| * - POST /JokeCache/:id with {action: "invalidate"} evicts the cached entry (uses | ||||||||
| * `invalidate()`, not `delete()` — the v5 cache eviction contract) and forces a re-fetch. | ||||||||
| */ | ||||||||
| import { suite, test, before, after } from 'node:test'; | ||||||||
| import { strictEqual, ok } from 'node:assert/strict'; | ||||||||
| import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; | ||||||||
| import { createRequire } from 'node:module'; | ||||||||
| import { resolve, dirname } from 'node:path'; | ||||||||
| import { fileURLToPath } from 'node:url'; | ||||||||
|
|
||||||||
| const require = createRequire(import.meta.url); | ||||||||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||||||||
| const fixtureDir = resolve(__dirname, '..'); | ||||||||
|
|
||||||||
| // harper's `exports` map only exposes ".", so 'harper/dist/bin/harper.js' is not resolvable | ||||||||
| // via require.resolve. Resolve the CLI from the exported main entry and pass it explicitly. | ||||||||
| const harperBinPath = resolve(dirname(require.resolve('harper')), 'bin/harper.js'); | ||||||||
|
|
||||||||
| function basicAuth(username: string, password: string): string { | ||||||||
| return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); | ||||||||
| } | ||||||||
|
|
||||||||
| suite('JokeCache REST', (ctx: ContextWithHarper) => { | ||||||||
| before(async () => { | ||||||||
| await setupHarperWithFixture(ctx, fixtureDir, { harperBinPath }); | ||||||||
| }); | ||||||||
|
|
||||||||
| after(async () => { | ||||||||
| await teardownHarper(ctx); | ||||||||
| }); | ||||||||
|
|
||||||||
| test('GET /JokeCache/ returns an accessible endpoint', async () => { | ||||||||
| const { admin, httpURL } = ctx.harper; | ||||||||
| const auth = basicAuth(admin.username, admin.password); | ||||||||
|
|
||||||||
| const res = await fetch(`${httpURL}/JokeCache/`, { | ||||||||
| headers: { Authorization: auth }, | ||||||||
| }); | ||||||||
| await res.arrayBuffer(); | ||||||||
|
|
||||||||
| ok(res.status < 500, `endpoint should not return a server error, got ${res.status}`); | ||||||||
| }); | ||||||||
|
|
||||||||
| test('GET /JokeCache/:id fetches and caches a joke from the external API', async () => { | ||||||||
| const { admin, httpURL } = ctx.harper; | ||||||||
| const auth = basicAuth(admin.username, admin.password); | ||||||||
|
|
||||||||
| const res = await fetch(`${httpURL}/JokeCache/1`, { | ||||||||
| headers: { Authorization: auth }, | ||||||||
| }); | ||||||||
|
|
||||||||
| strictEqual(res.status, 200); | ||||||||
| const body = await res.json() as Record<string, unknown>; | ||||||||
| ok(body.setup, `response should have a setup field, got: ${JSON.stringify(body)}`); | ||||||||
| ok(body.punchline, `response should have a punchline field, got: ${JSON.stringify(body)}`); | ||||||||
| }); | ||||||||
|
|
||||||||
| test('GET /JokeCache/:id returns the same cached result on a second fetch', async () => { | ||||||||
| const { admin, httpURL } = ctx.harper; | ||||||||
| const auth = basicAuth(admin.username, admin.password); | ||||||||
|
|
||||||||
| // First call populates the cache from the source. | ||||||||
| const res1 = await fetch(`${httpURL}/JokeCache/2`, { | ||||||||
| headers: { Authorization: auth }, | ||||||||
| }); | ||||||||
| strictEqual(res1.status, 200, `first fetch for id=2 should return 200, got ${res1.status}`); | ||||||||
| const body1 = await res1.json() as Record<string, unknown>; | ||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test gap:
Add a status check before consuming the body:
Suggested change
|
||||||||
|
|
||||||||
| // Second call should return the cached copy. | ||||||||
| const res2 = await fetch(`${httpURL}/JokeCache/2`, { | ||||||||
| headers: { Authorization: auth }, | ||||||||
| }); | ||||||||
| const body2 = await res2.json() as Record<string, unknown>; | ||||||||
|
|
||||||||
| strictEqual(res2.status, 200); | ||||||||
| strictEqual(body1.setup, body2.setup, 'cached response setup should match first response'); | ||||||||
| }); | ||||||||
|
|
||||||||
| // Reads `${httpURL}/JokeCache/${id}` until Harper serves it from cache with a validator. | ||||||||
| // The first read for an id is a cache MISS: Harper loads it through the source and the | ||||||||
| // record is committed to the cache table asynchronously, so the miss response carries no | ||||||||
| // stored record version and therefore no ETag/Last-Modified. Once the entry is committed, | ||||||||
| // subsequent reads are cache HITS whose stored version Harper turns into an ETag. We poll | ||||||||
| // a few times to ride out the background commit before asserting the validator. | ||||||||
| async function fetchUntilCached(httpURL: string, auth: string, id: string | number) { | ||||||||
| for (let attempt = 0; attempt < 60; attempt++) { | ||||||||
| const res = await fetch(`${httpURL}/JokeCache/${id}`, { headers: { Authorization: auth } }); | ||||||||
| await res.arrayBuffer(); | ||||||||
| const validator = res.headers.get('etag') ?? res.headers.get('last-modified'); | ||||||||
| if (res.status === 200 && validator) { | ||||||||
| return { res, etag: res.headers.get('etag'), lastModified: res.headers.get('last-modified') }; | ||||||||
| } | ||||||||
| await new Promise((r) => setTimeout(r, 50)); | ||||||||
| } | ||||||||
| throw new Error(`fetchUntilCached timeout: no validator received for id=${id} after 60 attempts`); | ||||||||
| } | ||||||||
|
|
||||||||
| test('GET /JokeCache/:id honors a conditional request with a real 304 cache hit', async () => { | ||||||||
| const { admin, httpURL } = ctx.harper; | ||||||||
| const auth = basicAuth(admin.username, admin.password); | ||||||||
|
|
||||||||
| // Prime the cache, then read again until the entry is committed and Harper emits a | ||||||||
| // validator for the cache hit. Harper derives the ETag from the cached record's stored | ||||||||
| // version, so a validator only appears once the read is served from the cache table. | ||||||||
| const { res, etag, lastModified } = await fetchUntilCached(httpURL, auth, 4); | ||||||||
| strictEqual(res.status, 200); | ||||||||
| ok(etag || lastModified, 'a cached response should expose an ETag or Last-Modified validator'); | ||||||||
|
|
||||||||
| const conditionalHeaders: Record<string, string> = { Authorization: auth }; | ||||||||
| if (etag) conditionalHeaders['If-None-Match'] = etag; | ||||||||
| else if (lastModified) conditionalHeaders['If-Modified-Since'] = lastModified; | ||||||||
|
|
||||||||
| const conditional = await fetch(`${httpURL}/JokeCache/4`, { headers: conditionalHeaders }); | ||||||||
| await conditional.arrayBuffer(); | ||||||||
|
|
||||||||
| strictEqual(conditional.status, 304, 'a matching conditional request should be a 304 cache hit'); | ||||||||
| }); | ||||||||
|
|
||||||||
| test('POST /JokeCache/:id with action invalidate evicts the cache and forces a re-fetch', async () => { | ||||||||
| const { admin, httpURL } = ctx.harper; | ||||||||
| const auth = basicAuth(admin.username, admin.password); | ||||||||
|
|
||||||||
| // Populate the cache and obtain the validator Harper emits for the committed entry. | ||||||||
| const { res: first, etag } = await fetchUntilCached(httpURL, auth, 3); | ||||||||
| strictEqual(first.status, 200); | ||||||||
|
|
||||||||
| // Invalidate the cached entry. This exercises `this.invalidate(target)` in the resource | ||||||||
| // (the v5 eviction contract); `delete()` would throw because the source has no delete(). | ||||||||
| const inv = await fetch(`${httpURL}/JokeCache/3`, { | ||||||||
| method: 'POST', | ||||||||
| headers: { 'Content-Type': 'application/json', Authorization: auth }, | ||||||||
| body: JSON.stringify({ action: 'invalidate' }), | ||||||||
| }); | ||||||||
| await inv.arrayBuffer(); | ||||||||
| ok(inv.status < 400, `invalidate should succeed, got ${inv.status}`); | ||||||||
|
|
||||||||
| // After invalidation the prior validator must no longer produce a 304 — the entry was | ||||||||
| // evicted and is re-fetched from the source on the next read. | ||||||||
| if (etag) { | ||||||||
| const afterInvalidate = await fetch(`${httpURL}/JokeCache/3`, { | ||||||||
| headers: { Authorization: auth, 'If-None-Match': etag }, | ||||||||
| }); | ||||||||
| await afterInvalidate.arrayBuffer(); | ||||||||
| strictEqual( | ||||||||
| afterInvalidate.status, | ||||||||
| 200, | ||||||||
| 'after invalidation the cache must be re-fetched, not served as a 304', | ||||||||
| ); | ||||||||
| } else { | ||||||||
| const afterInvalidate = await fetch(`${httpURL}/JokeCache/3`, { | ||||||||
| headers: { Authorization: auth }, | ||||||||
| }); | ||||||||
| strictEqual(afterInvalidate.status, 200); | ||||||||
| const body = await afterInvalidate.json() as Record<string, unknown>; | ||||||||
| ok(body.setup, 'a re-fetched joke should still have a setup field'); | ||||||||
| } | ||||||||
| }); | ||||||||
| }); | ||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test gap:
res1.statusnever checked — false green on first-fetch failurebody1is populated fromres1.json()without first assertingres1.status === 200. If the joke API is unreachable or returns an error on the first fetch,body1.setupisundefined. The second fetch may also return an error, makingbody2.setupalsoundefined.strictEqual(undefined, undefined)passes — the test greens out silently with no joke actually cached.Add a status guard before consuming the first body: