feat: add JSON Schema validation support with custom chai assertion (#7301)

* feat: add JSON Schema validation support with custom chai assertion

- Introduced a new custom assertion for JSON Schema validation in chai, allowing users to validate response bodies against defined schemas.
- Updated the postman translation logic to translate `pm.response.to.have.jsonSchema` to the new assertion format.
- Enhanced tests to cover various scenarios for JSON Schema validation, ensuring accurate translations and functionality.
- Updated package dependencies to include the latest versions of relevant libraries.

* refactor: enhance JSON Schema validation assertion and add comprehensive test cases

* chore: add @rollup/plugin-json dependency and enhance JSON Schema validation tests

- Added @rollup/plugin-json as a development dependency in package.json and package-lock.json.
- Introduced new test cases for JSON Schema validation, covering various scenarios including valid schema matching, type mismatches, and required field checks.
- Updated existing assertions to utilize the new validation capabilities.

* refactor: streamline JSON Schema validation with default Ajv instance

- Updated the custom chai assertion for JSON Schema validation to utilize a default Ajv instance, improving consistency and reducing redundancy in the code.
- Enhanced the error messages in the assertion to include the actual data being validated, providing clearer feedback during validation failures.

* refactor: improve error messaging in JSON Schema validation assertion

- Enhanced the custom chai assertion for JSON Schema validation to provide clearer error messages by including a stringified version of the data being validated, improving feedback during validation failures.

* refactor: simplify Ajv instance creation in JSON Schema validation

- Removed the default Ajv instance and streamlined the creation of Ajv instances in the custom chai assertion for JSON Schema validation, ensuring consistent handling of ajvOptions across the codebase.

* feat: add support for negated JSON Schema assertions in Postman translations

- Introduced translations for `pm.response.to.not.have.jsonSchema`, `pm.response.not.to.have.jsonSchema`, and `pm.response.to.have.not.jsonSchema` to the new assertion format using `expect`.
- Enhanced the translation logic to handle these new patterns and added corresponding test cases to ensure accurate functionality.
- Updated existing tests to cover various scenarios for negated assertions, improving overall test coverage for JSON Schema validation.

* fix: improve error handling in JSON Schema validation assertions

- Added error handling for JSON schema compilation in the custom chai assertion, ensuring that any compilation errors are caught and reported with a clear message.
- Updated tests to verify that malformed schemas correctly trigger assertion errors, enhancing the robustness of JSON Schema validation.
This commit is contained in:
sanish chirayath
2026-04-21 17:15:33 +05:30
committed by GitHub
parent 9e92e6f04e
commit c4dc0bc10d
10 changed files with 701 additions and 70 deletions

137
package-lock.json generated
View File

@@ -8957,48 +8957,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@jitl/quickjs-ffi-types": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.29.2.tgz",
"integrity": "sha512-069uQTiEla2PphXg6UpyyJ4QXHkTj3S9TeXgaMCd8NDYz3ODBw5U/rkg6fhuU8SMpoDrWjEzybmV5Mi2Pafb5w==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.29.2.tgz",
"integrity": "sha512-YdRw2414pFkxzyyoJGv81Grbo9THp/5athDMKipaSBNNQvFE9FGRrgE9tt2DT2mhNnBx1kamtOGj0dX84Yy9bg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jitl/quickjs-wasmfile-debug-sync": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.29.2.tgz",
"integrity": "sha512-VgisubjyPMWEr44g+OU0QWGyIxu7VkApkLHMxdORX351cw22aLTJ+Z79DJ8IVrTWc7jh4CBPsaK71RBQDuVB7w==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.29.2.tgz",
"integrity": "sha512-sf3luCPr8wBVmGV6UV8Set+ie8wcO6mz5wMvDVO0b90UVCKfgnx65A1JfeA+zaSGoaFyTZ3sEpXSGJU+6qJmLw==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jitl/quickjs-wasmfile-release-sync": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.29.2.tgz",
"integrity": "sha512-UFIcbY3LxBRUjEqCHq3Oa6bgX5znt51V5NQck8L2US4u989ErasiMLUjmhq6UPC837Sjqu37letEK/ZpqlJ7aA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -27099,31 +27057,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quickjs-emscripten": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.29.2.tgz",
"integrity": "sha512-SlvkvyZgarReu2nr4rkf+xz1vN0YDUz7sx4WHz8LFtK6RNg4/vzAGcFjE7nfHYBEbKrzfIWvKnMnxZkctQ898w==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-wasmfile-debug-asyncify": "0.29.2",
"@jitl/quickjs-wasmfile-debug-sync": "0.29.2",
"@jitl/quickjs-wasmfile-release-asyncify": "0.29.2",
"@jitl/quickjs-wasmfile-release-sync": "0.29.2",
"quickjs-emscripten-core": "0.29.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/quickjs-emscripten-core": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.29.2.tgz",
"integrity": "sha512-jEAiURW4jGqwO/fW01VwlWqa2G0AJxnN5FBy1xnVu8VIVhVhiaxUfCe+bHqS6zWzfjFm86HoO40lzpteusvyJA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/ramda": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
@@ -35882,7 +35815,7 @@
"nanoid": "3.3.8",
"node-fetch": "^2.7.0",
"path": "^0.12.7",
"quickjs-emscripten": "^0.29.2",
"quickjs-emscripten": "^0.32.0",
"tv4": "^1.3.0",
"uuid": "^10.0.0",
"xml-formatter": "^3.5.0",
@@ -35891,11 +35824,54 @@
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^1.0.0",
"rollup": "3.30.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
"integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
"integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
"integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
"integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -35945,6 +35921,31 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"packages/bruno-js/node_modules/quickjs-emscripten": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
"integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
"@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
"@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
"quickjs-emscripten-core": "0.32.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"packages/bruno-js/node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",

View File

@@ -36,6 +36,10 @@ const replacements = {
'pm\\.variables\\.toObject\\(': 'bru.getAllVars(',
'pm\\.request\\.headers\\.remove\\(': 'req.deleteHeader(',
'pm\\.response\\.headers\\.get\\(': 'res.getHeader(',
'pm\\.response\\.to\\.have\\.jsonSchema\\(': 'expect(res.getBody()).to.have.jsonSchema(',
'pm\\.response\\.to\\.not\\.have\\.jsonSchema\\(': 'expect(res.getBody()).to.not.have.jsonSchema(',
'pm\\.response\\.not\\.to\\.have\\.jsonSchema\\(': 'expect(res.getBody()).not.to.have.jsonSchema(',
'pm\\.response\\.to\\.have\\.not\\.jsonSchema\\(': 'expect(res.getBody()).to.have.not.jsonSchema(',
'pm\\.response\\.to\\.have\\.body\\(': 'expect(res.getBody()).to.equal(',
'pm\\.response\\.to\\.have\\.header\\(': 'expect(res.getHeaders()).to.have.property(',
'pm\\.response\\.size\\(\\)': 'res.getSize()',

View File

@@ -502,6 +502,74 @@ const complexTransformations = [
}
},
// pm.response.to.have.jsonSchema(schema, options?) -> expect(res.getBody()).to.have.jsonSchema(schema, options?)
{
pattern: 'pm.response.to.have.jsonSchema',
transform: (path, j) => {
const args = path.parent.value.arguments;
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [
j.callExpression(j.identifier('res.getBody'), [])
]),
j.identifier('to.have.jsonSchema')
),
args
);
}
},
// pm.response.to.not.have.jsonSchema(schema, options?) -> expect(res.getBody()).to.not.have.jsonSchema(schema, options?)
{
pattern: 'pm.response.to.not.have.jsonSchema',
transform: (path, j) => {
const args = path.parent.value.arguments;
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [
j.callExpression(j.identifier('res.getBody'), [])
]),
j.identifier('to.not.have.jsonSchema')
),
args
);
}
},
// pm.response.not.to.have.jsonSchema(schema, options?) -> expect(res.getBody()).not.to.have.jsonSchema(schema, options?)
{
pattern: 'pm.response.not.to.have.jsonSchema',
transform: (path, j) => {
const args = path.parent.value.arguments;
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [
j.callExpression(j.identifier('res.getBody'), [])
]),
j.identifier('not.to.have.jsonSchema')
),
args
);
}
},
// pm.response.to.have.not.jsonSchema(schema, options?) -> expect(res.getBody()).to.have.not.jsonSchema(schema, options?)
{
pattern: 'pm.response.to.have.not.jsonSchema',
transform: (path, j) => {
const args = path.parent.value.arguments;
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [
j.callExpression(j.identifier('res.getBody'), [])
]),
j.identifier('to.have.not.jsonSchema')
),
args
);
}
},
// Legacy postman.getResponseHeader(name) -> res.getHeader(name)
{
pattern: 'pm.getResponseHeader',

View File

@@ -682,4 +682,94 @@ describe('Response Translation', () => {
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property(path)');
});
// --- JSON Schema assertions ---------------------------
it('should translate pm.response.to.have.jsonSchema with variable reference', () => {
const code = 'pm.response.to.have.jsonSchema(schema);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getBody()).to.have.jsonSchema(schema);');
});
it('should translate pm.response.to.have.jsonSchema with two arguments', () => {
const code = 'pm.response.to.have.jsonSchema(schema, options);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getBody()).to.have.jsonSchema(schema, options);');
});
it('should translate pm.response.to.have.jsonSchema with inline object', () => {
const code = 'pm.response.to.have.jsonSchema({type: "object"});';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonSchema({type: "object"})');
});
it('should translate pm.response.to.have.jsonSchema inside a test block', () => {
const code = `
pm.test("Schema is valid", function() {
pm.response.to.have.jsonSchema(schema);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Schema is valid", function() {');
expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonSchema(schema)');
});
it('should translate pm.response.to.have.jsonSchema with response alias', () => {
const code = `
const resp = pm.response;
resp.to.have.jsonSchema(schema);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
expect(res.getBody()).to.have.jsonSchema(schema);
`);
});
// --- Negated JSON Schema assertions --------------------
it('should translate pm.response.to.not.have.jsonSchema', () => {
const code = 'pm.response.to.not.have.jsonSchema(schema);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getBody()).to.not.have.jsonSchema(schema);');
});
it('should translate pm.response.not.to.have.jsonSchema', () => {
const code = 'pm.response.not.to.have.jsonSchema(schema);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getBody()).not.to.have.jsonSchema(schema);');
});
it('should translate pm.response.to.have.not.jsonSchema', () => {
const code = 'pm.response.to.have.not.jsonSchema(schema);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getBody()).to.have.not.jsonSchema(schema);');
});
it('should translate pm.response.to.not.have.jsonSchema with two arguments', () => {
const code = 'pm.response.to.not.have.jsonSchema(schema, options);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getBody()).to.not.have.jsonSchema(schema, options);');
});
it('should translate pm.response.to.not.have.jsonSchema inside a test block', () => {
const code = `
pm.test("Schema should not match", function() {
pm.response.to.not.have.jsonSchema(schema);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Schema should not match", function() {');
expect(translatedCode).toContain('expect(res.getBody()).to.not.have.jsonSchema(schema)');
});
it('should translate pm.response.to.not.have.jsonSchema with response alias', () => {
const code = `
const resp = pm.response;
resp.to.not.have.jsonSchema(schema);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
expect(res.getBody()).to.not.have.jsonSchema(schema);
`);
});
});

View File

@@ -30,7 +30,7 @@
"nanoid": "3.3.8",
"node-fetch": "^2.7.0",
"path": "^0.12.7",
"quickjs-emscripten": "^0.29.2",
"quickjs-emscripten": "^0.32.0",
"tv4": "^1.3.0",
"uuid": "^10.0.0",
"xml-formatter": "^3.5.0",
@@ -39,6 +39,7 @@
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"rollup": "3.30.0",
"@rollup/plugin-terser": "^1.0.0"

View File

@@ -7,6 +7,7 @@ const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser }
const { interpolateString } = require('../interpolate-string');
const { executeQuickJsVm } = require('../sandbox/quickjs');
const Ajv = require('ajv');
const { expect } = chai;
chai.use(require('chai-string'));
chai.use(function (chai, utils) {
@@ -24,6 +25,27 @@ chai.use(function (chai, utils) {
});
});
// Custom assertion for JSON Schema validation
chai.use(function (chai) {
chai.Assertion.addMethod('jsonSchema', function (schema, ajvOptions) {
const ajv = new Ajv({ allErrors: true, ...ajvOptions });
let validate;
try {
validate = ajv.compile(schema);
} catch (e) {
this.assert(false, 'JSON schema compile error: ' + e.message, 'JSON schema compile error: ' + e.message);
}
const data = this._obj;
const isValid = validate(data);
this.assert(
isValid,
'expected #{this} to match JSON schema, validation errors: ' + (validate.errors ? JSON.stringify(validate.errors) : 'none'),
'expected #{this} to not match JSON schema'
);
});
});
// Custom assertion for matching regex
chai.use(function (chai, utils) {
chai.Assertion.addMethod('match', function (regex) {

View File

@@ -1,6 +1,7 @@
const rollup = require('rollup');
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const json = require('@rollup/plugin-json');
const fs = require('fs');
const terser = require('@rollup/plugin-terser').default;
@@ -13,6 +14,7 @@ const bundleLibraries = async () => {
import atob from "atob";
import * as cryptoJs from 'crypto-js';
import tv4 from "tv4";
import Ajv from "ajv";
globalThis.expect = expect;
globalThis.assert = assert;
globalThis.moment = moment;
@@ -20,6 +22,7 @@ const bundleLibraries = async () => {
globalThis.atob = atob;
globalThis.Buffer = Buffer;
globalThis.tv4 = tv4;
globalThis.Ajv = Ajv;
globalThis.requireObject = {
...(globalThis.requireObject || {}),
'chai': { expect, assert },
@@ -28,7 +31,8 @@ const bundleLibraries = async () => {
'btoa': btoa,
'atob': atob,
'crypto-js': cryptoJs,
'tv4': tv4
'tv4': tv4,
'ajv': Ajv
};
`;
@@ -56,6 +60,7 @@ const bundleLibraries = async () => {
browser: false
}),
commonjs(),
json(),
terser()
]
},

View File

@@ -79,6 +79,35 @@ const addBruShimToContext = (vm, __brunoTestResults) => {
})();
`
);
// Register custom chai assertion for jsonSchema (expect(...).to.have.jsonSchema(schema, options))
vm.evalCode(
`
(function() {
var Ajv = require('ajv');
var proto = Object.getPrototypeOf(expect(null));
proto.jsonSchema = function(schema, ajvOptions) {
var ajv = new Ajv(Object.assign({ allErrors: true }, ajvOptions || {}));
var validate;
try {
validate = ajv.compile(schema);
} catch (e) {
this.assert(false, 'JSON schema compile error: ' + e.message, 'JSON schema compile error: ' + e.message);
}
var data = this._obj;
var isValid = validate(data);
var dataStr;
try { dataStr = JSON.stringify(data); } catch (e) { dataStr = '[unserializable value]'; }
this.assert(
isValid,
'expected ' + dataStr + ' to match JSON schema, validation errors: ' + (validate.errors ? JSON.stringify(validate.errors) : 'none'),
'expected ' + dataStr + ' to not match JSON schema'
);
return this;
};
})();
`
);
};
module.exports = addBruShimToContext;

View File

@@ -365,5 +365,67 @@ describe('runtime', () => {
expect(results[0].status).toBe('fail');
});
});
describe('jsonSchema', () => {
const chai = require('chai');
it('should pass when body matches a valid schema', () => {
const body = { name: 'John', age: 30 };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name', 'age']
};
chai.expect(body).to.have.jsonSchema(schema);
});
it('should fail when body has a type mismatch', () => {
const body = { name: 'John', age: 'thirty' };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name', 'age']
};
expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/validation errors/);
});
it('should fail when a required field is missing', () => {
const body = { name: 'John' };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name', 'age']
};
expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/validation errors/);
});
it('should pass with custom ajvOptions', () => {
const body = { name: 'John', age: 30 };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name', 'age']
};
chai.expect(body).to.have.jsonSchema(schema, { allErrors: false });
});
it('should support negation with .not', () => {
const body = { name: 'John' };
const schema = { type: 'array' };
chai.expect(body).to.not.have.jsonSchema(schema);
});
});
});
});

View File

@@ -0,0 +1,349 @@
meta {
name: jsonSchema
type: http
seq: 9
}
post {
url: {{host}}/api/echo/json
body: json
auth: none
}
body:json {
{
"name": "John",
"age": 30,
"email": "john@example.com",
"status": "active",
"score": 95.5,
"isVerified": true,
"tags": ["developer", "tester"],
"address": {
"street": "123 Main St",
"city": "Springfield",
"zip": "62701"
}
}
}
tests {
// --- Passing validations ---
test("Basic object with properties and required", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
email: { type: 'string' },
status: { type: 'string' },
score: { type: 'number' },
isVerified: { type: 'boolean' },
tags: { type: 'array' },
address: { type: 'object' }
},
required: ['name', 'age', 'email']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("Nested object validation", function() {
const schema = {
type: 'object',
properties: {
address: {
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' },
zip: { type: 'string' }
},
required: ['street', 'city', 'zip']
}
}
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("Array items validation", function() {
const schema = {
type: 'object',
properties: {
tags: {
type: 'array',
items: { type: 'string' }
}
}
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("String pattern (regex)", function() {
const schema = {
type: 'object',
properties: {
email: {
type: 'string',
pattern: '^[^@]+@[^@]+\\.[^@]+$'
}
}
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("Enum validation", function() {
const schema = {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['active', 'inactive', 'pending']
}
}
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("Number range (minimum/maximum)", function() {
const schema = {
type: 'object',
properties: {
age: {
type: 'number',
minimum: 0,
maximum: 150
}
}
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("String length constraints (minLength/maxLength)", function() {
const schema = {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 100
}
}
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("allOf composition", function() {
const schema = {
allOf: [
{
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
},
{
type: 'object',
properties: { age: { type: 'number' } },
required: ['age']
}
]
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("anyOf composition", function() {
const schema = {
type: 'object',
properties: {
score: {
anyOf: [
{ type: 'number' },
{ type: 'string' }
]
}
}
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("jsonSchema with ajvOptions", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name', 'age']
};
expect(res.getBody()).to.have.jsonSchema(schema, { allErrors: true });
});
// --- Failure validations ---
test("Type mismatch - schema expects array, response is object", function() {
const schema = { type: 'array' };
let failed = false;
try {
expect(res.getBody()).to.have.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
test("Missing required field", function() {
const schema = {
type: 'object',
required: ['nonExistentField']
};
let failed = false;
try {
expect(res.getBody()).to.have.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
test("additionalProperties false rejects extra fields", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string' }
},
additionalProperties: false
};
let failed = false;
try {
expect(res.getBody()).to.have.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
test("Enum mismatch", function() {
const schema = {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['deleted', 'archived']
}
},
required: ['status']
};
let failed = false;
try {
expect(res.getBody()).to.have.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
test("Pattern mismatch - name does not match digits-only", function() {
const schema = {
type: 'object',
properties: {
name: {
type: 'string',
pattern: '^[0-9]+$'
}
},
required: ['name']
};
let failed = false;
try {
expect(res.getBody()).to.have.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
// --- Malformed schema (ajv.compile error) ---
test("Malformed schema - invalid type throws assertion error", function() {
const schema = { type: 'invalidType' };
expect(() => expect(res.getBody()).to.have.jsonSchema(schema)).to.throw('JSON schema compile error');
});
// --- .not (negation) validations ---
test(".not with mismatched type - body is object, not array", function() {
const schema = { type: 'array' };
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test(".not with missing required field", function() {
const schema = {
type: 'object',
required: ['nonExistent']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test(".not fails when schema actually matches", function() {
const schema = { type: 'object' };
let failed = false;
try {
expect(res.getBody()).to.not.have.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
// --- not.to.have (negation) validations ---
test("not.to.have with mismatched type - body is object, not array", function() {
const schema = { type: 'array' };
expect(res.getBody()).not.to.have.jsonSchema(schema);
});
test("not.to.have with missing required field", function() {
const schema = {
type: 'object',
required: ['nonExistent']
};
expect(res.getBody()).not.to.have.jsonSchema(schema);
});
test("not.to.have fails when schema actually matches", function() {
const schema = { type: 'object' };
let failed = false;
try {
expect(res.getBody()).not.to.have.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
// --- to.have.not (negation) validations ---
test("to.have.not with mismatched type - body is object, not array", function() {
const schema = { type: 'array' };
expect(res.getBody()).to.have.not.jsonSchema(schema);
});
test("to.have.not with missing required field", function() {
const schema = {
type: 'object',
required: ['nonExistent']
};
expect(res.getBody()).to.have.not.jsonSchema(schema);
});
test("to.have.not fails when schema actually matches", function() {
const schema = { type: 'object' };
let failed = false;
try {
expect(res.getBody()).to.have.not.jsonSchema(schema);
} catch (e) {
failed = true;
}
expect(failed).to.be.true;
});
}