Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Harper React SSR Example

This repo is an example of how to implement React SSR using HarperDB Resources to efficiently generate a _Blog_ from a database of _Posts_.
This repo is an example of how to implement React SSR using Harper Resources to efficiently generate a _Blog_ from a database of _Posts_.

It includes complete client side hydration as well, resulting in a fully interactive React app experience.

Expand Down Expand Up @@ -35,4 +35,4 @@ curl -X PATCH http://localhost:9926/Post/0 \
-d '{ "comments": [] }'
```

- This repo includes a `caching-test.js` script for quickly demonstrating and validating the caching behavior. Give it a try with `node caching-test.js` (component must be running with HarperDB).
- This repo includes a `caching-test.js` script for quickly demonstrating and validating the caching behavior. Give it a try with `node caching-test.js` (component must be running with Harper).
208 changes: 208 additions & 0 deletions integrationTests/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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 { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { createRequire } from 'node:module';

const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = resolve(__dirname, '..');

// The `harper` package's `exports` map only exposes ".", so the harness's
// auto-resolution of 'harper/dist/bin/harper.js' fails with ERR_PACKAGE_PATH_NOT_EXPORTED.
// Resolve the CLI from the (exported) main entry and pass it explicitly.
const require = createRequire(import.meta.url);
const harperBinPath = resolve(dirname(require.resolve('harper')), 'bin/harper.js');

function authFetch(
ctx: ContextWithHarper,
path: string,
init: RequestInit & { headers?: Record<string, string> } = {}
) {
const { headers = {}, ...rest } = init;
const creds = Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64');
return fetch(`${ctx.harper.httpURL}${path}`, { ...rest, headers: { Authorization: `Basic ${creds}`, ...headers } });
}

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// Compare only the media type of a Content-Type header. HTTP servers commonly append
// parameters such as `; charset=utf-8`, and a strict equality against 'text/html' would
// turn that into a spurious failure (or, in the settlement loop below, a timeout that
// reports "never settled" instead of the real reason).
function mediaType(contentType: string | null): string | undefined {
return contentType?.split(';')[0].trim().toLowerCase();
}

// The BlogCache table is sourced from PageBuilder and populated asynchronously;
// after a source (Post) change the cache entry is rebuilt in the background, so
// the ETag/Last-Modified can change across the first few reads. Poll a full
// (200, body) response until its ETag is stable across two consecutive reads so
// conditional-request assertions are deterministic, mirroring real cache use.
async function fetchSettledCachedBlog(
ctx: ContextWithHarper,
path = '/CachedBlog/0',
attempts = 50
): Promise<{ etag: string; lastModified: string; html: string; contentType: string }> {
let prevEtag: string | null = null;
let last: { status: number; etag: string | null; html: string } | undefined;
for (let i = 0; i < attempts; i++) {
const res = await authFetch(ctx, path);
const etag = res.headers.get('ETag');
const lastModified = res.headers.get('Last-Modified');
const contentType = res.headers.get('Content-Type');
const html = await res.text();
last = { status: res.status, etag, html };
// The BlogCache entry is populated asynchronously: until `cached.content`
// exists, CachedBlog.get returns { contentType, data: undefined }, which
// serializes to JSON (no ETag) rather than the HTML body. Consider the
// cache settled only once it serves a full HTML document (text/html) with
// an ETag stable across two consecutive reads.
if (
res.status === 200 &&
etag &&
etag === prevEtag &&
mediaType(contentType) === 'text/html' &&
html.includes('<!doctype html>')
) {
return { etag, lastModified: lastModified!, html, contentType };
}
prevEtag = etag;
await delay(100);
}
throw new Error(
`CachedBlog never settled into a cached HTML document; last status=${last?.status} etag=${last?.etag} bodyHead=${JSON.stringify(last?.html.slice(0, 80))}`
);
}

// Issue a conditional request and poll until it returns 304. The cache can
// briefly re-revalidate after settling (so the freshly captured ETag may not
// match for a moment); retry until the conditional request is honored.
async function expectConditional304(
ctx: ContextWithHarper,
etag: string,
lastModified: string,
attempts = 30
): Promise<void> {
let lastStatus = 0;
for (let i = 0; i < attempts; i++) {
const res = await authFetch(ctx, '/CachedBlog/0', {
headers: { 'If-None-Match': etag, 'If-Modified-Since': lastModified },
});
await res.arrayBuffer();
lastStatus = res.status;
if (res.status === 304) return;
await delay(100);
}
throw new Error(`expected a 304 cache hit with the given headers; last status=${lastStatus}`);
}

void suite('React SSR + caching example', (ctx: ContextWithHarper) => {
before(async () => {
// The fixture (repo root) is built (vite) by the test script before this runs,
// so dist/client/index.html and dist/server/entry-server.js exist for resources.js.
await setupHarperWithFixture(ctx, FIXTURE_PATH, { harperBinPath });
});

after(async () => {
await teardownHarper(ctx);
});

// --- Core Harper REST on the Post table ---

void test('Harper starts and serves the seeded Post via REST', async () => {
const res = await authFetch(ctx, '/Post/0');
strictEqual(res.status, 200);
const body = (await res.json()) as { id: string; title: string; comments: string[] };
strictEqual(body.id, '0');
strictEqual(body.title, 'Hello, World!');
ok(Array.isArray(body.comments), 'expected comments array');
});

void test('GET /Post/ returns an array of posts', async () => {
const res = await authFetch(ctx, '/Post/');
strictEqual(res.status, 200);
const body = await res.json();
ok(Array.isArray(body), 'expected array response');
});

void test('PATCH /Post/0 updates the record (adds a comment)', async () => {
const current = (await (await authFetch(ctx, '/Post/0')).json()) as { comments: string[] };
const comment = `Test comment ${Math.random()}`;
const res = await authFetch(ctx, '/Post/0', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comments: current.comments.concat(comment) }),
});
ok(res.ok, `expected successful PATCH, got HTTP ${res.status}`);
const after = (await (await authFetch(ctx, '/Post/0')).json()) as { comments: string[] };
ok(after.comments.includes(comment), 'expected the new comment to be persisted');
});

// --- SSR render path ---

void test('GET /UncachedBlog/0 server-side renders HTML', async () => {
const res = await authFetch(ctx, '/UncachedBlog/0');
strictEqual(res.status, 200);
strictEqual(mediaType(res.headers.get('Content-Type')), 'text/html');
const html = await res.text();
ok(html.includes('<!doctype html>'), 'expected full HTML document');
// The app head/html placeholders should have been replaced by the SSR render.
ok(!html.includes('<!--app-html-->'), 'expected app-html placeholder to be rendered');
// SSR injects the initial post data and cached flag for client hydration.
ok(html.includes('window.__INITIAL_POST_DATA__'), 'expected hydration data in SSR output');
ok(html.includes('window.__CACHED__ = false'), 'expected uncached flag in SSR output');
// The seeded post title should appear in the rendered markup.
ok(html.includes('Hello, World!'), 'expected post title in rendered HTML');
});

void test('GET /CachedBlog/0 server-side renders HTML with cached flag', async () => {
const { html, contentType } = await fetchSettledCachedBlog(ctx);
strictEqual(mediaType(contentType), 'text/html', `expected text/html content-type, got ${contentType}`);
ok(html.includes('<!doctype html>'), 'expected full HTML document');
ok(html.includes('window.__CACHED__ = true'), 'expected cached flag in SSR output');
ok(html.includes('Hello, World!'), 'expected post title in rendered HTML');
});

// --- Harper multi-tier caching behavior (mirrors caching-test.js) ---

void test('CachedBlog emits caching headers and a 304 on conditional re-request', async () => {
const { etag, lastModified } = await fetchSettledCachedBlog(ctx);
ok(etag, 'expected an ETag header on the cached response');
ok(lastModified, 'expected a Last-Modified header (rest.lastModified) on the cached response');

await expectConditional304(ctx, etag, lastModified);
});

void test('Updating the Post invalidates the cache, then re-caches', async () => {
// Prime the cache and capture settled headers.
const before = await fetchSettledCachedBlog(ctx);

// A conditional request with the settled headers should hit the cache (304).
await expectConditional304(ctx, before.etag, before.lastModified);

// Update the source Post, which invalidates the BlogCache entry.
const post = (await (await authFetch(ctx, '/Post/0')).json()) as { comments: string[] };
const patch = await authFetch(ctx, '/Post/0', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comments: post.comments.concat(`Invalidate ${Math.random()}`) }),
});
ok(patch.ok, `expected successful PATCH, got HTTP ${patch.status}`);

// A conditional request with the stale (pre-update) headers must miss (200):
// the content changed because the Post's comments changed, so the ETag no
// longer matches.
const miss = await authFetch(ctx, '/CachedBlog/0', {
headers: { 'If-None-Match': before.etag, 'If-Modified-Since': before.lastModified },
});
await miss.arrayBuffer();
strictEqual(miss.status, 200, 'expected a cache miss with stale headers after invalidation');

// Once the cache re-settles on a new ETag, a conditional request with the
// refreshed headers should hit again (304).
const after = await fetchSettledCachedBlog(ctx);
ok(after.etag !== before.etag, 'expected a new ETag after the source Post changed');
await expectConditional304(ctx, after.etag, after.lastModified);
});
});
Loading