From e12b7365169a636f129fbbffcfa3f1aea233c68c Mon Sep 17 00:00:00 2001 From: sanish chirayath Date: Wed, 22 Apr 2026 12:59:32 +0530 Subject: [PATCH] feat: add custom jsonBody Chai assertion + simplify Postman translation (#7299) * feat: enhance jsonBody translation handling in Postman to Bruno converter * feat: implement jsonBody assertion for Postman compatibility and enhance translation handling - Added custom Chai assertion for jsonBody to validate JSON structures, including deep equality and nested properties. - Updated Postman to Bruno translation logic to utilize the new jsonBody assertion, improving the handling of response validations. - Enhanced test coverage for jsonBody translations, including positive and negative cases for nested properties and deep equality checks. * feat: enhance jsonBody assertion translations for Postman compatibility - Added translations for `pm.response.not.to.have.jsonBody` and `pm.response.to.have.not.jsonBody` to the Postman to Bruno converter. - Updated tests to cover new translation cases, ensuring proper handling of negation scenarios for JSON body assertions. - Enhanced existing jsonBody assertion logic to support new translation patterns, improving overall compatibility with Postman syntax. * feat: add advanced path parsing for jsonBody assertions - Introduced a new `parsePath` function to handle various property path formats, including dot notation, numeric brackets, and quoted keys. - Updated the `getNestedValue` function to utilize the new path parsing logic, enhancing the robustness of jsonBody assertions. - Expanded test cases to cover a wide range of scenarios, including edge cases for bracket notation and keys with special characters. * docs: add examples for parsePath function in jsonBody assertions - Enhanced documentation for the `parsePath` function by including examples of various property path formats. - Updated comments in both `assert-runtime.js` and `test.js` to clarify the handling of dot notation, numeric brackets, and quoted keys. * fix: improve path handling in assertions for quoted keys - Updated condition checks in `assert-runtime.js` and `test.js` to ensure proper handling of quoted keys in path parsing. - Enhanced robustness of the path parsing logic to prevent potential out-of-bounds errors. --- .../src/postman/postman-translations.js | 4 + .../src/utils/postman-to-bruno-translator.js | 73 +++--- .../transpiler-tests/multiline-syntax.test.js | 4 + .../transpiler-tests/response.test.js | 98 +++++++- .../bruno-js/src/runtime/assert-runtime.js | 112 +++++++++ .../src/sandbox/quickjs/shims/test.js | 124 ++++++++++ .../collection/scripting/api/res/jsonBody.bru | 220 ++++++++++++++++++ 7 files changed, 602 insertions(+), 33 deletions(-) create mode 100644 packages/bruno-tests/collection/scripting/api/res/jsonBody.bru diff --git a/packages/bruno-converters/src/postman/postman-translations.js b/packages/bruno-converters/src/postman/postman-translations.js index 63dcdee47..1e96c2e9b 100644 --- a/packages/bruno-converters/src/postman/postman-translations.js +++ b/packages/bruno-converters/src/postman/postman-translations.js @@ -40,6 +40,10 @@ const replacements = { '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\\.jsonBody\\(': 'expect(res.getBody()).to.have.jsonBody(', + 'pm\\.response\\.to\\.not\\.have\\.jsonBody\\(': 'expect(res.getBody()).to.not.have.jsonBody(', + 'pm\\.response\\.not\\.to\\.have\\.jsonBody\\(': 'expect(res.getBody()).not.to.have.jsonBody(', + 'pm\\.response\\.to\\.have\\.not\\.jsonBody\\(': 'expect(res.getBody()).to.have.not.jsonBody(', '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 42e1a6161..df27aea4c 100644 --- a/packages/bruno-converters/src/utils/postman-to-bruno-translator.js +++ b/packages/bruno-converters/src/utils/postman-to-bruno-translator.js @@ -467,38 +467,31 @@ const complexTransformations = [ } }, - // pm.response.to.have.jsonBody(path) -> expect(res.getBody()).to.have.nested.property(path) + // pm.response.to.have.jsonBody(...) -> expect(res.getBody()).to.have.jsonBody(...) { pattern: 'pm.response.to.have.jsonBody', transform: (path, j) => { const callExpr = path.parent.value; const args = callExpr.arguments; + const expectGetBody = j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]); + return j.callExpression( + j.memberExpression(expectGetBody, j.identifier('to.have.jsonBody')), + args + ); + } + }, - if (args.length === 0) { - // No path provided, just check that body exists - return j.memberExpression( - j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]), - j.identifier('to.exist') - ); - } else if (args.length === 1) { - // Path provided, check property exists - return j.callExpression( - j.memberExpression( - j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]), - j.identifier('to.have.nested.property') - ), - args - ); - } else { - // Path and value provided, check property equals value - return j.callExpression( - j.memberExpression( - j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]), - j.identifier('to.have.nested.property') - ), - args - ); - } + // pm.response.to.not.have.jsonBody(...) -> expect(res.getBody()).to.not.have.jsonBody(...) + { + pattern: 'pm.response.to.not.have.jsonBody', + transform: (path, j) => { + const callExpr = path.parent.value; + const args = callExpr.arguments; + const expectGetBody = j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]); + return j.callExpression( + j.memberExpression(expectGetBody, j.identifier('to.not.have.jsonBody')), + args + ); } }, @@ -570,6 +563,34 @@ const complexTransformations = [ } }, + // pm.response.not.to.have.jsonBody(...) -> expect(res.getBody()).not.to.have.jsonBody(...) + { + pattern: 'pm.response.not.to.have.jsonBody', + transform: (path, j) => { + const callExpr = path.parent.value; + const args = callExpr.arguments; + const expectGetBody = j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]); + return j.callExpression( + j.memberExpression(expectGetBody, j.identifier('not.to.have.jsonBody')), + args + ); + } + }, + + // pm.response.to.have.not.jsonBody(...) -> expect(res.getBody()).to.have.not.jsonBody(...) + { + pattern: 'pm.response.to.have.not.jsonBody', + transform: (path, j) => { + const callExpr = path.parent.value; + const args = callExpr.arguments; + const expectGetBody = j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]); + return j.callExpression( + j.memberExpression(expectGetBody, j.identifier('to.have.not.jsonBody')), + args + ); + } + }, + // Legacy postman.getResponseHeader(name) -> res.getHeader(name) { pattern: 'pm.getResponseHeader', diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js index 60e5db0d9..88a3b9db9 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js @@ -277,6 +277,10 @@ describe('Multiline Syntax Handling', () => { expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200)'); expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("content-type".toLowerCase())'); + // Check jsonBody translations (positive and negation) + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody("success", true)'); + expect(translatedCode).toContain('expect(res.getBody()).to.not.have.jsonBody("error")'); + // Check flow control expect(translatedCode).toContain('if (res.getStatus() === 401)'); expect(translatedCode).toContain('bru.runner.stopExecution()'); 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 2964a7bf6..8e11b873d 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 @@ -643,19 +643,38 @@ describe('Response Translation', () => { it('should translate pm.response.to.have.jsonBody with path', () => { const code = 'pm.response.to.have.jsonBody("user.id");'; const translatedCode = translateCode(code); - expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("user.id")'); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody("user.id")'); }); it('should translate pm.response.to.have.jsonBody with path and value', () => { const code = 'pm.response.to.have.jsonBody("status", "success");'; const translatedCode = translateCode(code); - expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("status", "success")'); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody("status", "success")'); }); it('should translate pm.response.to.have.jsonBody without arguments', () => { const code = 'pm.response.to.have.jsonBody();'; const translatedCode = translateCode(code); - expect(translatedCode).toContain('expect(res.getBody()).to.exist'); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody()'); + }); + + it('should translate pm.response.to.have.jsonBody with object argument', () => { + const code = 'pm.response.to.have.jsonBody({ success: true, data: { id: 1 } });'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody'); + expect(translatedCode).toContain('success: true'); + }); + + it('should translate pm.response.to.have.jsonBody with path and numeric value', () => { + const code = 'pm.response.to.have.jsonBody("data.count", 42);'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody("data.count", 42)'); + }); + + it('should translate pm.response.to.have.jsonBody with array argument as nested property', () => { + const code = 'pm.response.to.have.jsonBody(["a", "b"]);'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody(["a", "b"])'); }); it('should handle pm.response.to.have.jsonBody inside test blocks', () => { @@ -667,20 +686,45 @@ describe('Response Translation', () => { `; const translatedCode = translateCode(code); expect(translatedCode).toContain('test("Response validation", function() {'); - expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("data")'); - expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("data.id", 123)'); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody("data")'); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody("data.id", 123)'); }); it('should translate pm.response.to.have.jsonBody with nested path', () => { const code = 'pm.response.to.have.jsonBody("response.data.items[0].name");'; const translatedCode = translateCode(code); - expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("response.data.items[0].name")'); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody("response.data.items[0].name")'); }); it('should translate pm.response.to.have.jsonBody with variable path', () => { const code = 'const path = "user.id"; pm.response.to.have.jsonBody(path);'; const translatedCode = translateCode(code); - expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property(path)'); + expect(translatedCode).toContain('expect(res.getBody()).to.have.jsonBody(path)'); + }); + + it('should translate pm.response.to.not.have.jsonBody without arguments', () => { + const code = 'pm.response.to.not.have.jsonBody();'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.not.have.jsonBody()'); + }); + + it('should translate pm.response.to.not.have.jsonBody with path', () => { + const code = 'pm.response.to.not.have.jsonBody("error");'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.not.have.jsonBody("error")'); + }); + + it('should translate pm.response.to.not.have.jsonBody with path and value', () => { + const code = 'pm.response.to.not.have.jsonBody("status", "error");'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.not.have.jsonBody("status", "error")'); + }); + + it('should translate pm.response.to.not.have.jsonBody with object argument', () => { + const code = 'pm.response.to.not.have.jsonBody({ error: true });'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.not.have.jsonBody'); + expect(translatedCode).toContain('error: true'); }); // --- JSON Schema assertions --------------------------- @@ -772,4 +816,44 @@ describe('Response Translation', () => { expect(res.getBody()).to.not.have.jsonSchema(schema); `); }); + + // --- not.to.have.jsonBody --- + + it('should translate pm.response.not.to.have.jsonBody without arguments', () => { + const code = 'pm.response.not.to.have.jsonBody();'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).not.to.have.jsonBody()'); + }); + + it('should translate pm.response.not.to.have.jsonBody with path', () => { + const code = 'pm.response.not.to.have.jsonBody("error");'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).not.to.have.jsonBody("error")'); + }); + + it('should translate pm.response.not.to.have.jsonBody with path and value', () => { + const code = 'pm.response.not.to.have.jsonBody("status", "error");'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).not.to.have.jsonBody("status", "error")'); + }); + + // --- to.have.not.jsonBody --- + + it('should translate pm.response.to.have.not.jsonBody without arguments', () => { + const code = 'pm.response.to.have.not.jsonBody();'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.have.not.jsonBody()'); + }); + + it('should translate pm.response.to.have.not.jsonBody with path', () => { + const code = 'pm.response.to.have.not.jsonBody("error");'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.have.not.jsonBody("error")'); + }); + + it('should translate pm.response.to.have.not.jsonBody with path and value', () => { + const code = 'pm.response.to.have.not.jsonBody("status", "error");'; + const translatedCode = translateCode(code); + expect(translatedCode).toContain('expect(res.getBody()).to.have.not.jsonBody("status", "error")'); + }); }); diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index e6660a408..f826b37b5 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -65,6 +65,118 @@ chai.use(function (chai, utils) { }); }); +// Custom assertion for jsonBody (Postman parity) +chai.use(function (chai, utils) { + // Parse a property path into an array of keys. + // Handles: dot notation (a.b), numeric brackets (a[0]), quoted brackets (a["b.c"], a['key']), + // and combinations like data[0]["a.b"].name + // + // Examples: + // "a.b.c" -> ["a", "b", "c"] + // "items[0].name" -> ["items", "0", "name"] + // 'data["a.b"]' -> ["data", "a.b"] + // "matrix[0][1]" -> ["matrix", "0", "1"] + // 'nested["x.y"].z' -> ["nested", "x.y", "z"] + // '["say \\"hi\\""]' -> ["say \"hi\""] + function parsePath(path) { + const keys = []; + let i = 0; + while (i < path.length) { + if (path[i] === '.') { + // Skip dot separator + i++; + } else if (path[i] === '[') { + i++; // skip '[' + if (i < path.length && (path[i] === '\'' || path[i] === '"')) { + // Quoted key — collect until matching unescaped quote + ']' + const quote = path[i]; + i++; // skip opening quote + let key = ''; + while (i < path.length && path[i] !== quote) { + if (path[i] === '\\' && i + 1 < path.length && path[i + 1] === quote) { + key += quote; + i += 2; // skip backslash + escaped quote + } else { + key += path[i]; + i++; + } + } + i++; // skip closing quote + i++; // skip ']' + keys.push(key); + } else { + // Unquoted (numeric) key — collect until ']' + let key = ''; + while (i < path.length && path[i] !== ']') { + key += path[i]; + i++; + } + i++; // skip ']' + keys.push(key); + } + } else { + // Bare key — collect until '.', '[', or end + let key = ''; + while (i < path.length && path[i] !== '.' && path[i] !== '[') { + key += path[i]; + i++; + } + keys.push(key); + } + } + return keys; + } + + function getNestedValue(obj, path) { + const keys = parsePath(path); + let current = obj; + for (const key of keys) { + if (current === null || current === undefined || !Object.prototype.hasOwnProperty.call(Object(current), key)) { + return { found: false }; + } + current = current[key]; + } + return { found: true, value: current }; + } + + chai.Assertion.addMethod('jsonBody', function () { + const obj = this._obj; + const args = Array.prototype.slice.call(arguments); + + if (args.length === 0) { + // No args: check body is valid JSON (object or array) + this.assert( + typeof obj === 'object' && obj !== null, + `expected ${utils.inspect(obj)} to be a JSON body (object or array)`, + `expected ${utils.inspect(obj)} not to be a JSON body` + ); + } else if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null) { + // Object arg: deep equality + this.assert( + utils.eql(obj, args[0]), + `expected body to deeply equal ${utils.inspect(args[0])}`, + `expected body to not deeply equal ${utils.inspect(args[0])}` + ); + } else if (args.length === 1) { + // String path: check nested property exists + const result = getNestedValue(obj, String(args[0])); + this.assert( + result.found, + `expected body to have nested property '${args[0]}'`, + `expected body to not have nested property '${args[0]}'` + ); + } else { + // Path + value: check nested property equals value + const result = getNestedValue(obj, String(args[0])); + this.assert( + result.found && utils.eql(result.value, args[1]), + `expected body to have nested property '${args[0]}' equal to ${utils.inspect(args[1])}`, + `expected body to not have nested property '${args[0]}' equal to ${utils.inspect(args[1])}` + ); + } + }); +}); + /** * Assertion operators * diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/test.js b/packages/bruno-js/src/sandbox/quickjs/shims/test.js index 967cf154f..e3666e5c5 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/test.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/test.js @@ -108,6 +108,130 @@ const addBruShimToContext = (vm, __brunoTestResults) => { })(); ` ); + // Register custom chai assertion for jsonBody (Postman parity) + vm.evalCode( + ` + (function() { + var proto = Object.getPrototypeOf(expect(null)); + + // Parse a property path into an array of keys. + // Handles: dot notation (a.b), numeric brackets (a[0]), quoted brackets (a["b.c"], a['key']), + // and combinations like data[0]["a.b"].name + // + // Examples: + // "a.b.c" -> ["a", "b", "c"] + // "items[0].name" -> ["items", "0", "name"] + // 'data["a.b"]' -> ["data", "a.b"] + // "matrix[0][1]" -> ["matrix", "0", "1"] + // 'nested["x.y"].z' -> ["nested", "x.y", "z"] + // '["say \\"hi\\""]' -> ["say \\"hi\\""] + function parsePath(path) { + var keys = []; + var i = 0; + while (i < path.length) { + if (path[i] === '.') { + i++; + } else if (path[i] === '[') { + i++; + if (i < path.length && (path[i] === "'" || path[i] === '"')) { + var quote = path[i]; + i++; + var key = ''; + while (i < path.length && path[i] !== quote) { + if (path[i] === '\\\\' && i + 1 < path.length && path[i + 1] === quote) { + key += quote; + i += 2; + } else { + key += path[i]; + i++; + } + } + i++; // skip closing quote + i++; // skip ']' + keys.push(key); + } else { + var key = ''; + while (i < path.length && path[i] !== ']') { + key += path[i]; + i++; + } + i++; // skip ']' + keys.push(key); + } + } else { + var key = ''; + while (i < path.length && path[i] !== '.' && path[i] !== '[') { + key += path[i]; + i++; + } + keys.push(key); + } + } + return keys; + } + + function getNestedValue(obj, path) { + var keys = parsePath(path); + var current = obj; + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (current === null || current === undefined || !Object.prototype.hasOwnProperty.call(Object(current), key)) { + return { found: false }; + } + current = current[key]; + } + return { found: true, value: current }; + } + + function deepEqual(a, b) { + if (a === b) return true; + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + var keysA = Object.keys(a); + var keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (var i = 0; i < keysA.length; i++) { + if (!Object.prototype.hasOwnProperty.call(b, keysA[i]) || !deepEqual(a[keysA[i]], b[keysA[i]])) return false; + } + return true; + } + + proto.jsonBody = function() { + var obj = this._obj; + var args = Array.prototype.slice.call(arguments); + + if (args.length === 0) { + this.assert( + typeof obj === 'object' && obj !== null, + 'expected value to be a JSON body (object or array)', + 'expected value not to be a JSON body' + ); + } else if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null) { + this.assert( + deepEqual(obj, args[0]), + 'expected body to deeply equal given object', + 'expected body to not deeply equal given object' + ); + } else if (args.length === 1) { + var result = getNestedValue(obj, String(args[0])); + this.assert( + result.found, + "expected body to have nested property '" + args[0] + "'", + "expected body to not have nested property '" + args[0] + "'" + ); + } else { + var result = getNestedValue(obj, String(args[0])); + this.assert( + result.found && deepEqual(result.value, args[1]), + "expected body to have nested property '" + args[0] + "' equal to given value", + "expected body to not have nested property '" + args[0] + "' equal to given value" + ); + } + return this; + }; + })(); + ` + ); }; module.exports = addBruShimToContext; diff --git a/packages/bruno-tests/collection/scripting/api/res/jsonBody.bru b/packages/bruno-tests/collection/scripting/api/res/jsonBody.bru new file mode 100644 index 000000000..5e422829b --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/res/jsonBody.bru @@ -0,0 +1,220 @@ +meta { + name: jsonBody + type: http + seq: 9 +} + +post { + url: {{host}}/api/echo/json + body: json + auth: none +} + +body:json { + { + "hello": "bruno", + "data": { + "items": [ + { "name": "first" }, + { "name": "second" } + ] + }, + "matrix": [[1, 2], [3, 4]], + "tags": ["api", "test"], + "some-key": "hyphenated", + "a.b": "dotted-key", + "nested": { + "x.y": { "z": "deep-dotted" } + }, + "it's": "apostrophe-key", + "say \"hi\"": "quoted-key" + } +} + +assert { + res.status: eq 200 +} + +tests { + test("jsonBody() - no args validates JSON body", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody(); + }); + + test("jsonBody(object) - deep equality", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody({ + "hello": "bruno", + "data": { + "items": [ + { "name": "first" }, + { "name": "second" } + ] + }, + "matrix": [[1, 2], [3, 4]], + "tags": ["api", "test"], + "some-key": "hyphenated", + "a.b": "dotted-key", + "nested": { + "x.y": { "z": "deep-dotted" } + }, + "it's": "apostrophe-key", + "say \"hi\"": "quoted-key" + }); + }); + + test("jsonBody(path) - nested property exists", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("hello"); + expect(body).to.have.jsonBody("data.items"); + }); + + test("jsonBody(path, value) - nested property equals value", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("hello", "bruno"); + }); + + test("jsonBody with bracket notation", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("data.items[0].name", "first"); + }); + + // --- bracket notation and array access --- + + test("bracket notation - access array element returns object", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("data.items[0]", { "name": "first" }); + expect(body).to.have.jsonBody("data.items[1]", { "name": "second" }); + }); + + test("bracket notation - access top-level array elements", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("tags[0]", "api"); + expect(body).to.have.jsonBody("tags[1]", "test"); + }); + + test("bracket notation - consecutive brackets for nested arrays", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("matrix[0][0]", 1); + expect(body).to.have.jsonBody("matrix[0][1]", 2); + expect(body).to.have.jsonBody("matrix[1][0]", 3); + expect(body).to.have.jsonBody("matrix[1][1]", 4); + }); + + test("bracket notation - access nested array as whole value", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("matrix[0]", [1, 2]); + expect(body).to.have.jsonBody("matrix[1]", [3, 4]); + }); + + test("bracket notation - out of bounds index is not found", function() { + const body = res.getBody(); + expect(body).to.not.have.jsonBody("tags[5]"); + expect(body).to.not.have.jsonBody("data.items[99]"); + }); + + // --- edge cases: string bracket keys and keys with dots --- + + test("quoted bracket notation - double quotes for string keys", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody('["some-key"]', "hyphenated"); + }); + + test("quoted bracket notation - single quotes for string keys", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody("['some-key']", "hyphenated"); + }); + + test("quoted bracket notation - keys containing dots", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody('["a.b"]', "dotted-key"); + }); + + test("quoted bracket notation - nested path with dotted keys", function() { + const body = res.getBody(); + expect(body).to.have.jsonBody('nested["x.y"].z', "deep-dotted"); + }); + + test("quoted bracket notation - key containing the other quote type", function() { + const body = res.getBody(); + // Key is: it's — use double quotes so the single quote is not a delimiter + expect(body).to.have.jsonBody("[\"it's\"]", "apostrophe-key"); + }); + + test("quoted bracket notation - escaped quotes in key", function() { + const body = res.getBody(); + // Key is: say "hi" — use escaped double quotes inside double-quoted bracket + expect(body).to.have.jsonBody('["say \\"hi\\""]', "quoted-key"); + }); + + test("to.not.have.jsonBody(path) - negation for missing property", function() { + const body = res.getBody(); + expect(body).to.not.have.jsonBody("nonexistent"); + }); + + test("to.not.have.jsonBody(path, value) - negation for wrong value", function() { + const body = res.getBody(); + expect(body).to.not.have.jsonBody("hello", "wrong"); + }); + + test("to.not.have.jsonBody(object) - negation for deep inequality", function() { + const body = res.getBody(); + expect(body).to.not.have.jsonBody({ "wrong": "data" }); + }); + + // --- not.to.have.jsonBody --- + + test("not.to.have.jsonBody(path) - negation for missing property", function() { + const body = res.getBody(); + expect(body).not.to.have.jsonBody("nonexistent"); + }); + + test("not.to.have.jsonBody(path, value) - negation for wrong value", function() { + const body = res.getBody(); + expect(body).not.to.have.jsonBody("hello", "wrong"); + }); + + test("not.to.have.jsonBody(object) - negation for deep inequality", function() { + const body = res.getBody(); + expect(body).not.to.have.jsonBody({ "wrong": "data" }); + }); + + test("not.to.have.jsonBody fails when body matches", function() { + const body = res.getBody(); + let failed = false; + try { + expect(body).not.to.have.jsonBody("hello"); + } catch (e) { + failed = true; + } + expect(failed).to.be.true; + }); + + // --- to.have.not.jsonBody --- + + test("to.have.not.jsonBody(path) - negation for missing property", function() { + const body = res.getBody(); + expect(body).to.have.not.jsonBody("nonexistent"); + }); + + test("to.have.not.jsonBody(path, value) - negation for wrong value", function() { + const body = res.getBody(); + expect(body).to.have.not.jsonBody("hello", "wrong"); + }); + + test("to.have.not.jsonBody(object) - negation for deep inequality", function() { + const body = res.getBody(); + expect(body).to.have.not.jsonBody({ "wrong": "data" }); + }); + + test("to.have.not.jsonBody fails when body matches", function() { + const body = res.getBody(); + let failed = false; + try { + expect(body).to.have.not.jsonBody("hello"); + } catch (e) { + failed = true; + } + expect(failed).to.be.true; + }); +}