diff --git a/package-lock.json b/package-lock.json index 82dda71c0..64525a373 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/bruno-converters/src/postman/postman-translations.js b/packages/bruno-converters/src/postman/postman-translations.js index 214f9058a..63dcdee47 100644 --- a/packages/bruno-converters/src/postman/postman-translations.js +++ b/packages/bruno-converters/src/postman/postman-translations.js @@ -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()', diff --git a/packages/bruno-converters/src/utils/postman-to-bruno-translator.js b/packages/bruno-converters/src/utils/postman-to-bruno-translator.js index a36f9a196..42e1a6161 100644 --- a/packages/bruno-converters/src/utils/postman-to-bruno-translator.js +++ b/packages/bruno-converters/src/utils/postman-to-bruno-translator.js @@ -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', diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js index 1fb6cbf50..2964a7bf6 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js @@ -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); + `); + }); }); diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json index bfc2f7300..1129bd362 100644 --- a/packages/bruno-js/package.json +++ b/packages/bruno-js/package.json @@ -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" diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index 1af9c338a..e6660a408 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -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) { diff --git a/packages/bruno-js/src/sandbox/bundle-libraries.js b/packages/bruno-js/src/sandbox/bundle-libraries.js index 930552749..34041363f 100644 --- a/packages/bruno-js/src/sandbox/bundle-libraries.js +++ b/packages/bruno-js/src/sandbox/bundle-libraries.js @@ -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() ] }, diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/test.js b/packages/bruno-js/src/sandbox/quickjs/shims/test.js index f7ef90854..967cf154f 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/test.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/test.js @@ -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; diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js index a925db847..019b053f5 100644 --- a/packages/bruno-js/tests/runtime.spec.js +++ b/packages/bruno-js/tests/runtime.spec.js @@ -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); + }); + }); }); }); diff --git a/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru b/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru new file mode 100644 index 000000000..027d9c9e7 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru @@ -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; + }); +}