Skip to content

Commit fb85fd4

Browse files
kriszypclaude
andcommitted
test(integration): add blob-advanced tests for multi-path blobPaths and per-device-type DB sharding (#1195)
Adds integrationTests/database/blob-advanced.test.ts with three cases: 1. Per-device-type DB sharding — DesktopPage and MobilePage in separate LMDB databases (cache_desktop / cache_mobile) hold the same primary key without cross-DB bleed; a key present only in MobilePage returns 404 on DesktopPage. 2. Multi-path blobPaths — Harper starts with two storage.blobPaths dirs configured; 4 × 10KB records are stored and retrieved byte-exact across the round-robin distribution. 3. Large blob (200KB) round-trips through the multi-path setup without truncation or corruption. Also adds integrationTests/fixtures/blob-advanced/ (schema.graphql + config.yaml). blobPaths config path: storage.blobPaths (array of strings in config-root.schema.json). Bytes REST round-trip: REST accepts JSON string → stored as Buffer.from(value, 'utf8'); returns {"type":"Buffer","data":[...]} (Node.js Buffer.toJSON format). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 7845a09 commit fb85fd4

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/**
2+
* Advanced blob integration tests — issue #1195
3+
*
4+
* Covers patterns observed in production that are NOT tested by blob.test.mjs:
5+
*
6+
* 1. Per-device-type database sharding — two tables in separate LMDB databases
7+
* (cache_desktop / cache_mobile) hold records with the same primary key;
8+
* neither table sees the other's data.
9+
*
10+
* 2. Multi-path blobPaths — Harper starts with two blobPaths configured; binary
11+
* data is stored and retrieved byte-exact. Harper distributes blob files
12+
* round-robin across the paths; all records are retrievable regardless of
13+
* which path a given file landed on.
14+
*
15+
* 3. Large blob (200KB) — a 200KB payload round-trips through the multi-path
16+
* setup without truncation or corruption.
17+
*
18+
* The fixture pre-defines DesktopPage and MobilePage tables in separate databases.
19+
* blobPaths are supplied at start time via `storage.blobPaths` in the config option.
20+
*
21+
* Note on Bytes round-trip: the REST layer accepts a Bytes field as a JSON string
22+
* (stored as Buffer.from(value, 'utf8')) and returns it as
23+
* `{"type":"Buffer","data":[...utf8 byte values...]}` (Node.js Buffer.toJSON format).
24+
* Tests verify the decoded Buffer matches the original string bytes.
25+
*
26+
* Skipped on Windows: depends on `restart_service http_workers` (HarperFast/harper#549).
27+
* Skipped on Bun: component install is not reliable under Harper-on-Bun in CI.
28+
*/
29+
import { suite, test, before, after } from 'node:test';
30+
import { ok, strictEqual, notStrictEqual } from 'node:assert/strict';
31+
import { mkdtempSync, readFileSync } from 'node:fs';
32+
import { tmpdir } from 'node:os';
33+
import { join } from 'node:path';
34+
import request from 'supertest';
35+
36+
import { startHarper, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
37+
// @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine
38+
import { createApiClient } from '../apiTests/utils/client.mjs';
39+
// @ts-expect-error utils/components.mjs has no type declarations; runtime resolves fine
40+
import { installAppComponent } from '../apiTests/utils/components.mjs';
41+
42+
const skipSuite = process.platform === 'win32' || process.env.HARPER_RUNTIME === 'bun';
43+
44+
const FIXTURE_PATH = join(import.meta.dirname, '../fixtures/blob-advanced');
45+
const SCHEMA_GRAPHQL = readFileSync(join(FIXTURE_PATH, 'schema.graphql'), 'utf8');
46+
const CONFIG_YAML = readFileSync(join(FIXTURE_PATH, 'config.yaml'), 'utf8');
47+
48+
/**
49+
* Decode a Harper Bytes field from a REST JSON response body back to a Buffer.
50+
*
51+
* Harper serializes Bytes via Node.js Buffer.toJSON(), which produces
52+
* `{"type":"Buffer","data":[...byte values...]}`. This helper reconstructs
53+
* the original Buffer so tests can do byte-exact comparisons.
54+
*/
55+
function decodeHarperBytes(value: unknown): Buffer {
56+
ok(value !== null && typeof value === 'object', `expected Buffer JSON object, got ${JSON.stringify(value)}`);
57+
const obj = value as { type?: string; data?: number[] };
58+
strictEqual(obj.type, 'Buffer', `expected type 'Buffer', got '${obj.type}'`);
59+
ok(Array.isArray(obj.data), `expected data array, got ${JSON.stringify(obj.data)}`);
60+
return Buffer.from(obj.data);
61+
}
62+
63+
suite('blob-advanced', { skip: skipSuite }, (ctx: ContextWithHarper) => {
64+
let client: ReturnType<typeof createApiClient>;
65+
66+
/** Two temporary directories used as blob storage paths. */
67+
const blobDir1 = mkdtempSync(join(tmpdir(), 'harper-blob-test-1-'));
68+
const blobDir2 = mkdtempSync(join(tmpdir(), 'harper-blob-test-2-'));
69+
70+
before(async () => {
71+
await startHarper(ctx, {
72+
config: {
73+
storage: {
74+
blobPaths: [blobDir1, blobDir2],
75+
},
76+
},
77+
env: {},
78+
});
79+
client = createApiClient(ctx.harper);
80+
81+
await installAppComponent(client, {
82+
project: 'blob-advanced',
83+
files: {
84+
'schema.graphql': SCHEMA_GRAPHQL,
85+
'config.yaml': CONFIG_YAML,
86+
},
87+
probePath: '/DesktopPage/',
88+
restartTimeoutMs: 120_000,
89+
});
90+
});
91+
92+
after(async () => {
93+
await teardownHarper(ctx);
94+
});
95+
96+
/**
97+
* Test 1: Per-device-type database sharding — no cross-DB bleed
98+
*
99+
* DesktopPage and MobilePage live in separate LMDB databases (cache_desktop
100+
* and cache_mobile). Writing the same primary key to each table must produce
101+
* two isolated records: each table returns only its own content, and a key
102+
* that exists only in MobilePage must 404 on DesktopPage.
103+
*
104+
* Harper's Bytes field accepts JSON strings and stores them as UTF-8 Buffers.
105+
*/
106+
test('per-device-type DB sharding: no cross-DB bleed', { timeout: 30_000 }, async () => {
107+
const desktopStr = '<html>desktop</html>';
108+
const mobileStr = '<html>mobile</html>';
109+
const desktopExpected = Buffer.from(desktopStr);
110+
const mobileExpected = Buffer.from(mobileStr);
111+
112+
// Write page1 to DesktopPage
113+
await request(client.restURL)
114+
.put('/DesktopPage/page1')
115+
.set(client.headers)
116+
.send({ id: 'page1', content: desktopStr, contentType: 'text/html' })
117+
.expect(204);
118+
119+
// Write page1 to MobilePage — same primary key, different database
120+
await request(client.restURL)
121+
.put('/MobilePage/page1')
122+
.set(client.headers)
123+
.send({ id: 'page1', content: mobileStr, contentType: 'text/html' })
124+
.expect(204);
125+
126+
// Read back DesktopPage/page1 — must return desktop content
127+
const desktopResp = await request(client.restURL).get('/DesktopPage/page1').set(client.headers).expect(200);
128+
129+
ok(desktopResp.body, 'DesktopPage/page1 should return a body');
130+
strictEqual(desktopResp.body.contentType, 'text/html', 'DesktopPage record should have contentType text/html');
131+
const desktopBytes = decodeHarperBytes(desktopResp.body.content);
132+
strictEqual(desktopBytes.compare(desktopExpected), 0, 'DesktopPage content must match what was stored');
133+
134+
// Read back MobilePage/page1 — must return mobile content
135+
const mobileResp = await request(client.restURL).get('/MobilePage/page1').set(client.headers).expect(200);
136+
137+
ok(mobileResp.body, 'MobilePage/page1 should return a body');
138+
strictEqual(mobileResp.body.contentType, 'text/html', 'MobilePage record should have contentType text/html');
139+
const mobileBytes = decodeHarperBytes(mobileResp.body.content);
140+
strictEqual(mobileBytes.compare(mobileExpected), 0, 'MobilePage content must match what was stored');
141+
142+
// The two records must not be identical (cross-DB bleed would make them equal)
143+
notStrictEqual(
144+
desktopBytes.compare(mobileBytes),
145+
0,
146+
'DesktopPage and MobilePage records for the same key must hold different content (no cross-DB bleed)'
147+
);
148+
149+
// Write page2 only to MobilePage — DesktopPage must return 404
150+
await request(client.restURL)
151+
.put('/MobilePage/page2')
152+
.set(client.headers)
153+
.send({ id: 'page2', content: mobileStr, contentType: 'text/html' })
154+
.expect(204);
155+
156+
await request(client.restURL).get('/DesktopPage/page2').set(client.headers).expect(404);
157+
});
158+
159+
/**
160+
* Test 2: Multi-path blobPaths — binary data stored and retrievable
161+
*
162+
* Store 4 records with ~10KB Bytes payloads. Harper distributes blob files
163+
* round-robin across the two blobPaths; we verify all 4 are retrievable
164+
* byte-exact regardless of which path they landed on.
165+
*
166+
* Content strings are built from repeating ASCII patterns so the UTF-8
167+
* Buffer.from() encoding is lossless and the byte-exact comparison is valid.
168+
*/
169+
test('multi-path blobPaths: all records stored and retrievable byte-exact', { timeout: 30_000 }, async () => {
170+
const BLOB_SIZE = 10 * 1024; // 10KB
171+
172+
// Build 4 distinct payloads using ASCII characters (safe for UTF-8 round-trip)
173+
const payloads: Buffer[] = Array.from(
174+
{ length: 4 },
175+
(_, i) => Buffer.from(String.fromCharCode(65 + i).repeat(BLOB_SIZE)) // 'A', 'B', 'C', 'D' repeated
176+
);
177+
178+
// Store all 4 records
179+
for (let i = 0; i < payloads.length; i++) {
180+
await request(client.restURL)
181+
.put(`/DesktopPage/multi-${i}`)
182+
.set(client.headers)
183+
.send({
184+
id: `multi-${i}`,
185+
content: payloads[i].toString(), // ASCII string → stored as UTF-8 Buffer
186+
contentType: 'application/octet-stream',
187+
})
188+
.expect(204);
189+
}
190+
191+
// Retrieve each record via REST and verify byte-exact content
192+
for (let i = 0; i < payloads.length; i++) {
193+
const resp = await request(client.restURL).get(`/DesktopPage/multi-${i}`).set(client.headers).expect(200);
194+
195+
ok(resp.body.content, `record multi-${i} should have content`);
196+
const returned = decodeHarperBytes(resp.body.content);
197+
198+
strictEqual(
199+
returned.length,
200+
payloads[i].length,
201+
`record multi-${i}: returned length ${returned.length} !== expected ${payloads[i].length}`
202+
);
203+
strictEqual(
204+
returned.compare(payloads[i]),
205+
0,
206+
`record multi-${i}: content mismatch (byte-exact comparison failed)`
207+
);
208+
}
209+
});
210+
211+
/**
212+
* Test 3: Large blob (200KB) through multi-path setup
213+
*
214+
* A 200KB ASCII payload must survive the round-trip without truncation or
215+
* corruption. Content uses only printable ASCII so the UTF-8 Buffer round-trip
216+
* is lossless.
217+
*/
218+
test('large blob (200KB) round-trip through multi-path blobPaths', { timeout: 30_000 }, async () => {
219+
const LARGE_SIZE = 200 * 1024; // 200KB
220+
221+
// Repeating printable ASCII pattern (32–126) — safe for UTF-8 Buffer.from()
222+
const chars = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('');
223+
let str = '';
224+
while (str.length < LARGE_SIZE) str += chars;
225+
const content = str.slice(0, LARGE_SIZE);
226+
const expected = Buffer.from(content);
227+
228+
await request(client.restURL)
229+
.put('/DesktopPage/large-blob')
230+
.set(client.headers)
231+
.send({
232+
id: 'large-blob',
233+
content,
234+
contentType: 'application/octet-stream',
235+
})
236+
.expect(204);
237+
238+
const resp = await request(client.restURL).get('/DesktopPage/large-blob').set(client.headers).expect(200);
239+
240+
ok(resp.body.content, 'large-blob record should have content');
241+
const returned = decodeHarperBytes(resp.body.content);
242+
243+
strictEqual(returned.length, LARGE_SIZE, `large-blob: returned length ${returned.length} !== ${LARGE_SIZE}`);
244+
strictEqual(returned.compare(expected), 0, 'large-blob: content mismatch (byte-exact comparison failed)');
245+
});
246+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
graphqlSchema:
2+
files: '*.graphql'
3+
rest: true
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
type DesktopPage @table(database: "cache_desktop") @export {
2+
id: ID @primaryKey
3+
content: Bytes
4+
contentType: String
5+
}
6+
7+
type MobilePage @table(database: "cache_mobile") @export {
8+
id: ID @primaryKey
9+
content: Bytes
10+
contentType: String
11+
}

0 commit comments

Comments
 (0)