|
| 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 | +}); |
0 commit comments