Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/integration-tests.yml
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
162 changes: 162 additions & 0 deletions integrationTests/joke-cache.test.ts
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>;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: res1.status never checked — false green on first-fetch failure

body1 is populated from res1.json() without first asserting res1.status === 200. If the joke API is unreachable or returns an error on the first fetch, body1.setup is undefined. The second fetch may also return an error, making body2.setup also undefined. strictEqual(undefined, undefined) passes — the test greens out silently with no joke actually cached.

Add a status guard before consuming the first body:

Suggested change
const body1 = await res1.json() as Record<string, unknown>;
strictEqual(res1.status, 200, `first fetch should succeed, got ${res1.status}`);
const body1 = await res1.json() as Record<string, unknown>;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: res1.status never checked — false green when the first fetch fails

body1 is populated from res1.json() at line 70 without first asserting res1.status === 200. If the joke API is unreachable or returns an error body on the first fetch, body1.setup is undefined. If the second fetch also fails, body2.setup is also undefined, and strictEqual(undefined, undefined) passes — the cache-consistency assertion greens out without any joke ever being cached.

Add a status check before consuming the body:

Suggested change
const body1 = await res1.json() as Record<string, unknown>;
strictEqual(res1.status, 200, `first fetch for id=2 should succeed, got ${res1.status}`);
const body1 = await res1.json() as Record<string, unknown>;


// 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');
}
});
});
Loading