Skip to content

Commit d728b71

Browse files
test: add schema enforcement and schema-less table integration tests
Add four test cases per issue #1194: - @Sealed enforcement: PUT and operations-API insert with undeclared fields are rejected with HTTP 400 and "is not allowed" error. (schema-enforcement.test.mjs) - [String] @indexed array element search: REST queries on an indexed array field return only records whose array contains the queried element. (schema-enforcement.test.mjs) - Brotli Blob Content-Encoding pass-through: a sourced resource that stores Brotli-compressed bytes and sets Content-Encoding: br in the get() response delivers that header through Harper's REST layer. (blob.test.mjs — new Brotli Blob suite) - Schema-less table for MQTT retained messages: a table created via the operations API with no declared attributes (only hash_attribute) accepts arbitrary fields; upsert correctly overwrites the record for the same id, modelling retained-message semantics. (configuration.test.mjs) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 88c94e6 commit d728b71

3 files changed

Lines changed: 313 additions & 0 deletions

File tree

integrationTests/apiTests/blob.test.mjs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { setTimeout } from 'node:timers/promises';
2727
import { startHarper, teardownHarper } from '@harperfast/integration-testing';
2828
import { createApiClient } from './utils/client.mjs';
2929
import { restartHttpWorkers } from './utils/lifecycle.mjs';
30+
import { installAppComponent } from './utils/components.mjs';
3031

3132
const skipSuite = process.platform === 'win32' || process.env.HARPER_RUNTIME === 'bun';
3233

@@ -262,3 +263,92 @@ suite('Blob lifecycle', { skip: skipSuite }, (ctx) => {
262263
}
263264
});
264265
});
266+
267+
// ── Brotli Blob Content-Encoding pass-through ─────────────────────────────────
268+
//
269+
// Validates that a sourced resource can store Brotli-compressed bytes as a Blob
270+
// and serve them back with Content-Encoding: br. Harper must pass through the
271+
// header rather than re-compressing or stripping it.
272+
//
273+
// Skipped under the same conditions as the parent suite (Windows crash on
274+
// restart_service http_workers; unreliable Bun timing).
275+
276+
const BROTLI_SCHEMA_GRAPHQL =
277+
'type BrotliCache @table(database: "brotlidb") @export {\n' +
278+
'\tid: ID @primaryKey\n' +
279+
'\tbrotliContent: Blob!\n' +
280+
'\tcontentSize: Int\n' +
281+
'\thttpStatus: Int\n' +
282+
'}\n\n';
283+
284+
const BROTLI_RESOURCES_JS =
285+
"import { brotliCompressSync } from 'zlib';\n" +
286+
'\n' +
287+
'const { BrotliCache } = databases.brotlidb;\n' +
288+
"const rawContent = Buffer.from('Brotli Content-Encoding pass-through test. '.repeat(500));\n" +
289+
'const compressed = brotliCompressSync(rawContent);\n' +
290+
'\n' +
291+
'export class brotlicache extends BrotliCache {\n' +
292+
'\tasync get() {\n' +
293+
'\t\treturn {\n' +
294+
'\t\t\tstatus: this.httpStatus || 200,\n' +
295+
"\t\t\theaders: { 'Content-Encoding': 'br' },\n" +
296+
'\t\t\tbody: this.brotliContent,\n' +
297+
'\t\t};\n' +
298+
'\t}\n' +
299+
'}\n' +
300+
'\n' +
301+
'export class BrotliCacheSource extends Resource {\n' +
302+
'\tasync get() {\n' +
303+
'\t\tconst blob = await createBlob(compressed);\n' +
304+
'\t\treturn {\n' +
305+
'\t\t\tbrotliContent: blob,\n' +
306+
'\t\t\tcontentSize: compressed.length,\n' +
307+
'\t\t\thttpStatus: 200,\n' +
308+
'\t\t};\n' +
309+
'\t}\n' +
310+
'}\n' +
311+
'\n' +
312+
'brotlicache.sourcedFrom(BrotliCacheSource);\n\n';
313+
314+
suite('Brotli Blob Content-Encoding pass-through', { skip: skipSuite }, (ctx) => {
315+
let client;
316+
const brotliId = randomInt(1000000);
317+
318+
before(async () => {
319+
await startHarper(ctx, { config: {}, env: {} });
320+
client = createApiClient(ctx.harper);
321+
322+
// Probe the list endpoint (/brotlicache/) — it returns 404 until the component
323+
// registers its routes, and 200 (empty list) once ready. Avoids triggering
324+
// BrotliCacheSource on /brotlicache/{id}, which would create a spurious record.
325+
await installAppComponent(client, {
326+
project: 'brotliblobs',
327+
files: { 'schema.graphql': BROTLI_SCHEMA_GRAPHQL, 'resources.js': BROTLI_RESOURCES_JS },
328+
probePath: '/brotlicache/',
329+
restartTimeoutMs: 120000,
330+
});
331+
});
332+
333+
after(async () => {
334+
await teardownHarper(ctx);
335+
});
336+
337+
test('GET Brotli blob triggers source and returns Content-Encoding: br', async () => {
338+
const r = await client.reqRest(`/brotlicache/${brotliId}`).set('Accept', '*/*').expect(200);
339+
assert.equal(
340+
r.headers['content-encoding'],
341+
'br',
342+
`expected Content-Encoding: br in response headers, got: ${JSON.stringify(r.headers)}`
343+
);
344+
});
345+
346+
test('subsequent GET Brotli blob still returns Content-Encoding: br', async () => {
347+
const r = await client.reqRest(`/brotlicache/${brotliId}`).set('Accept', '*/*').expect(200);
348+
assert.equal(
349+
r.headers['content-encoding'],
350+
'br',
351+
`expected Content-Encoding: br on cached blob, got: ${JSON.stringify(r.headers)}`
352+
);
353+
});
354+
});

integrationTests/apiTests/configuration.test.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { createApiClient } from './utils/client.mjs';
2222
const SCHEMA = 'dev';
2323
const ATTR_TEST_TABLE = 'create_attr_test';
2424
const DROP_ATTR_TABLE = 'AttributeDropTest';
25+
const SCHEMALESS_TABLE = 'MqttRetained';
2526

2627
const TEST_ROLE = 'test_dev_role';
2728
const TEST_USER = 'test_user';
@@ -311,4 +312,77 @@ suite('Configuration', (ctx) => {
311312
.expect((r) => assert.equal(r.body.message, `${TEST_ROLE} successfully deleted`, r.text))
312313
.expect(200);
313314
});
315+
316+
// ── schema-less table (no declared attributes) ───────────────────────────
317+
318+
test('create schema-less table with no declared attributes', async () => {
319+
await client
320+
.req()
321+
.send({ operation: 'create_table', schema: SCHEMA, table: SCHEMALESS_TABLE, hash_attribute: 'id' })
322+
.expect((r) => assert.ok(r.body.message.includes('successfully created'), r.text))
323+
.expect(200);
324+
});
325+
326+
test('schema-less table accepts insert with arbitrary fields', async () => {
327+
await client
328+
.req()
329+
.send({
330+
operation: 'insert',
331+
schema: SCHEMA,
332+
table: SCHEMALESS_TABLE,
333+
records: [
334+
{
335+
id: '/sensors/temperature/room1',
336+
payload: '{"temperature":22.5,"unit":"C"}',
337+
qos: 1,
338+
retained: true,
339+
timestamp: 1699000000000,
340+
},
341+
],
342+
})
343+
.expect((r) => assert.equal(r.body.message, 'inserted 1 of 1 records', r.text))
344+
.expect(200);
345+
});
346+
347+
test('schema-less table stores and retrieves arbitrary fields', async () => {
348+
const r = await client
349+
.req()
350+
.send({ operation: 'sql', sql: `SELECT * FROM ${SCHEMA}.${SCHEMALESS_TABLE}` })
351+
.expect(200);
352+
assert.ok(Array.isArray(r.body), r.text);
353+
assert.equal(r.body.length, 1, r.text);
354+
const record = r.body[0];
355+
assert.equal(record.id, '/sensors/temperature/room1', r.text);
356+
assert.equal(record.payload, '{"temperature":22.5,"unit":"C"}', r.text);
357+
assert.equal(record.qos, 1, r.text);
358+
assert.equal(record.retained, true, r.text);
359+
});
360+
361+
test('schema-less table upsert overwrites retained message for same id', async () => {
362+
await client
363+
.req()
364+
.send({
365+
operation: 'upsert',
366+
schema: SCHEMA,
367+
table: SCHEMALESS_TABLE,
368+
records: [
369+
{
370+
id: '/sensors/temperature/room1',
371+
payload: '{"temperature":23.0,"unit":"C"}',
372+
qos: 1,
373+
retained: true,
374+
timestamp: 1699001000000,
375+
},
376+
],
377+
})
378+
.expect((r) => assert.ok(r.body.upserted_hashes?.length === 1, r.text))
379+
.expect(200);
380+
381+
const r = await client
382+
.req()
383+
.send({ operation: 'sql', sql: `SELECT * FROM ${SCHEMA}.${SCHEMALESS_TABLE}` })
384+
.expect(200);
385+
assert.equal(r.body.length, 1, `expected 1 retained record after upsert\n${r.text}`);
386+
assert.equal(r.body[0].payload, '{"temperature":23.0,"unit":"C"}', r.text);
387+
});
314388
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Schema enforcement integration tests.
3+
*
4+
* Validates:
5+
* - @sealed table rejects records that include undeclared fields (REST PUT and
6+
* operations API insert both enforce the constraint with HTTP 400)
7+
* - [String] @indexed field supports element-level REST search — a query on the
8+
* array attribute returns only records whose array contains that element
9+
*
10+
* Both require a component with a GraphQL schema, so each uses
11+
* installAppComponent + HTTP worker restart. Grouped in a single suite to share
12+
* the Harper instance and reduce startup cost.
13+
*
14+
* Skipped on Windows: restart_service http_workers crashes on the Windows
15+
* single-worker model (HarperFast/harper#549).
16+
*/
17+
import { suite, test, before, after } from 'node:test';
18+
import assert from 'node:assert/strict';
19+
import request from 'supertest';
20+
import { startHarper, teardownHarper } from '@harperfast/integration-testing';
21+
import { createApiClient } from './utils/client.mjs';
22+
import { installAppComponent } from './utils/components.mjs';
23+
24+
const skipSuite = process.platform === 'win32';
25+
26+
const CONFIG_YAML = "rest: true\ngraphqlSchema:\n files: '*.graphql'\ngraphql: true\n";
27+
28+
// Only id, name, and count are declared — any other property must be rejected.
29+
const SEALED_SCHEMA =
30+
'type SealedRecord @table @sealed @export {\n' +
31+
'\tid: ID @primaryKey\n' +
32+
'\tname: String\n' +
33+
'\tcount: Int\n' +
34+
'}\n';
35+
36+
// tags is a [String] @indexed array — element-level REST queries must work.
37+
const TAGGED_SCHEMA =
38+
'type TaggedItem @table @export {\n' +
39+
'\tid: ID @primaryKey\n' +
40+
'\tlabel: String\n' +
41+
'\ttags: [String] @indexed\n' +
42+
'}\n';
43+
44+
suite('Schema enforcement features', { skip: skipSuite }, (ctx) => {
45+
let client;
46+
47+
before(async () => {
48+
await startHarper(ctx, { config: {}, env: {} });
49+
client = createApiClient(ctx.harper);
50+
51+
await installAppComponent(client, {
52+
project: 'sealedTest',
53+
files: { 'schema.graphql': SEALED_SCHEMA, 'config.yaml': CONFIG_YAML },
54+
probePath: '/SealedRecord/',
55+
restartTimeoutMs: 120000,
56+
});
57+
58+
await installAppComponent(client, {
59+
project: 'taggedTest',
60+
files: { 'schema.graphql': TAGGED_SCHEMA, 'config.yaml': CONFIG_YAML },
61+
probePath: '/TaggedItem/',
62+
restartTimeoutMs: 120000,
63+
});
64+
65+
await client
66+
.req()
67+
.send({
68+
operation: 'insert',
69+
table: 'TaggedItem',
70+
records: [
71+
{ id: '1', label: 'alpha-beta', tags: ['alpha', 'beta'] },
72+
{ id: '2', label: 'beta-gamma', tags: ['beta', 'gamma'] },
73+
{ id: '3', label: 'gamma-delta', tags: ['gamma', 'delta'] },
74+
],
75+
})
76+
.expect((r) => assert.ok(r.body.message?.includes('inserted 3 of 3 records'), r.text))
77+
.expect(200);
78+
});
79+
80+
after(async () => {
81+
await teardownHarper(ctx);
82+
});
83+
84+
// ── @sealed enforcement ────────────────────────────────────────────────────
85+
86+
test('@sealed: PUT with declared fields only succeeds', async () => {
87+
await request(client.restURL)
88+
.put('/SealedRecord/1')
89+
.set(client.headers)
90+
.send({ id: '1', name: 'allowed', count: 5 })
91+
.expect(204);
92+
});
93+
94+
test('@sealed: PUT with undeclared field is rejected with 400', async () => {
95+
await request(client.restURL)
96+
.put('/SealedRecord/2')
97+
.set(client.headers)
98+
.send({ id: '2', name: 'test', count: 3, extraField: 'not allowed' })
99+
.expect((r) => {
100+
assert.ok(r.text.includes('is not allowed'), `expected "is not allowed" in error body, got: ${r.text}`);
101+
})
102+
.expect(400);
103+
});
104+
105+
test('@sealed: operations API insert with undeclared field is rejected with 400', async () => {
106+
await client
107+
.req()
108+
.send({
109+
operation: 'insert',
110+
table: 'SealedRecord',
111+
records: [{ id: '3', name: 'test', count: 1, hiddenField: 'forbidden' }],
112+
})
113+
.expect((r) => {
114+
assert.ok(r.text.includes('is not allowed'), `expected "is not allowed" in error body, got: ${r.text}`);
115+
})
116+
.expect(400);
117+
});
118+
119+
// ── [String] @indexed — element-level REST search ──────────────────────────
120+
121+
test('[String] @indexed: search by element shared by two records returns both', async () => {
122+
const r = await client.reqRest('/TaggedItem/?tags=beta').expect(200);
123+
assert.ok(Array.isArray(r.body), r.text);
124+
assert.equal(r.body.length, 2, `expected 2 records with tag "beta", got ${r.body.length}: ${r.text}`);
125+
const ids = r.body.map((item) => item.id).sort();
126+
assert.deepEqual(ids, ['1', '2'], r.text);
127+
});
128+
129+
test('[String] @indexed: search by unique element returns single record', async () => {
130+
const r = await client.reqRest('/TaggedItem/?tags=alpha').expect(200);
131+
assert.ok(Array.isArray(r.body), r.text);
132+
assert.equal(r.body.length, 1, `expected 1 record with tag "alpha", got ${r.body.length}: ${r.text}`);
133+
assert.equal(r.body[0].id, '1', r.text);
134+
});
135+
136+
test('[String] @indexed: search by element shared by second pair returns both', async () => {
137+
const r = await client.reqRest('/TaggedItem/?tags=gamma').expect(200);
138+
assert.ok(Array.isArray(r.body), r.text);
139+
assert.equal(r.body.length, 2, `expected 2 records with tag "gamma", got ${r.body.length}: ${r.text}`);
140+
const ids = r.body.map((item) => item.id).sort();
141+
assert.deepEqual(ids, ['2', '3'], r.text);
142+
});
143+
144+
test('[String] @indexed: search by absent element returns empty array', async () => {
145+
const r = await client.reqRest('/TaggedItem/?tags=nope').expect(200);
146+
assert.ok(Array.isArray(r.body), r.text);
147+
assert.equal(r.body.length, 0, `expected 0 records for absent tag, got ${r.body.length}: ${r.text}`);
148+
});
149+
});

0 commit comments

Comments
 (0)