From 2af2b43ecede2cae4cda843c3e3f471e284d8771 Mon Sep 17 00:00:00 2001 From: Julien Le Guellec Date: Fri, 18 Dec 2020 10:03:38 +0100 Subject: [PATCH] feat(assertObjectMatchSpec): Compare object keys when doing a full match #44 --- src/core/assertions.js | 75 ++++--- tests/core/assertions.test.js | 211 +++++++++++++----- tests/extensions/http_api/definitions.test.js | 2 +- 3 files changed, 208 insertions(+), 80 deletions(-) diff --git a/src/core/assertions.js b/src/core/assertions.js index 8de65063..43668b66 100644 --- a/src/core/assertions.js +++ b/src/core/assertions.js @@ -28,41 +28,59 @@ const RuleName = Object.freeze({ }) /** - * Count object properties including nested objects ones. - * If a property is an object, its key is ignored. + * Acts as `Object.keys()`, but runs recursively, + * another difference is that when one of the key refers to + * a non-empty object, it's gonna be ignored. + * + * Keys for nested objects are prefixed with their parent key. + * + * Also note that this is not fully interoperable with `lodash.get` + * for example as keys themselves can contain dots or special characters. * * @example - * Assertions.countNestedProperties({ + * Assertions.objectKeysDeep({ * a: true, * b: true, * c: true, * }) - * // => 3 - * Assertions.countNestedProperties({ + * // => ["a", "b", "c"] + * Assertions.objectKeysDeep({ * a: true, * b: true, * c: { - * a: true, - * b: true, + * d: true, + * e: {}, + * f: { + * g: true + * } * }, * }) - * // => 4 (c is ignored because it's a nested object) + * // => ["a", "b", "c.d", "c.e", "c.f.g"] (c and c.f are ignored as non empty nested objects) * * @param {Object} object - * @return {number} + * @param {Array} [keysAccumulator = []] + * @param {string} [parentPath = ""] + * @return {Array} */ -exports.countNestedProperties = (object) => { - let propertiesCount = 0 - Object.keys(object).forEach((key) => { - if (!_.isEmpty(object[key]) && typeof object[key] === 'object') { - const count = exports.countNestedProperties(object[key]) - propertiesCount += count - } else { - propertiesCount++ - } - }) +exports.objectKeysDeep = (object, keysAccumulator = [], parentPath = '') => { + if (_.isPlainObject(object) || Array.isArray(object)) { + Object.keys(object).forEach((key) => { + if ( + !_.isEmpty(object[key]) && + (_.isPlainObject(object[key]) || Array.isArray(object[key])) + ) { + keysAccumulator = exports.objectKeysDeep( + object[key], + keysAccumulator, + `${parentPath}${key}.` + ) + } else { + keysAccumulator.push(`${parentPath}${key}`) + } + }) + } - return propertiesCount + return keysAccumulator } /** @@ -106,10 +124,13 @@ exports.countNestedProperties = (object) => { * @param {boolean} [exact=false] - if `true`, specification must match all object's properties */ exports.assertObjectMatchSpec = (object, spec, exact = false) => { + expect(_.isPlainObject(object), 'Expected json response to be a valid object, but it is not').to + .be.true + const specPath = new Set() spec.forEach(({ field, matcher, value }) => { const currentValue = _.get(object, field) const expectedValue = Cast.value(value) - + specPath.add(field) const rule = exports.getMatchingRule(matcher) switch (rule.name) { @@ -208,11 +229,12 @@ exports.assertObjectMatchSpec = (object, spec, exact = false) => { // We check we have exactly the same number of properties as expected if (exact === true) { - const propertiesCount = exports.countNestedProperties(object) + const objectKeys = exports.objectKeysDeep(object) + const specObjectKeys = Array.from(specPath) expect( - propertiesCount, + objectKeys, 'Expected json response to fully match spec, but it does not' - ).to.be.equal(spec.length) + ).to.be.deep.equal(specObjectKeys) } } @@ -261,7 +283,10 @@ exports.getMatchingRule = (matcher) => { const relativeDateGroups = relativeDateRegex.exec(matcher) if (relativeDateGroups) { - return { name: RuleName.RelativeDate, isNegated: !!relativeDateGroups[1] } + return { + name: RuleName.RelativeDate, + isNegated: !!relativeDateGroups[1], + } } expect.fail(`Matcher "${matcher}" did not match any supported assertions`) diff --git a/tests/core/assertions.test.js b/tests/core/assertions.test.js index b2f6e3b3..d97539f5 100644 --- a/tests/core/assertions.test.js +++ b/tests/core/assertions.test.js @@ -1,6 +1,6 @@ 'use strict' -const { countNestedProperties, assertObjectMatchSpec } = require('../../src/core/assertions') +const { assertObjectMatchSpec, objectKeysDeep } = require('../../src/core/assertions') beforeAll(() => { const MockDate = (lastDate) => () => new lastDate(2018, 4, 1) @@ -13,17 +13,17 @@ afterAll(() => { beforeEach(() => {}) -test('should allow to count object properties', () => { +test('should allow to build an array of object properties', () => { expect( - countNestedProperties({ + objectKeysDeep({ a: true, b: true, c: true, }) - ).toBe(3) + ).toStrictEqual(['a', 'b', 'c']) expect( - countNestedProperties({ + objectKeysDeep({ a: true, b: true, c: true, @@ -32,12 +32,12 @@ test('should allow to count object properties', () => { b: true, }, }) - ).toBe(5) + ).toStrictEqual(['a', 'b', 'c', 'd.a', 'd.b']) }) -test('should allow to count nested objects properties', () => { +test('should allow to build an array of object properties with nested objects properties', () => { expect( - countNestedProperties({ + objectKeysDeep({ a: true, b: true, c: { @@ -45,32 +45,32 @@ test('should allow to count nested objects properties', () => { e: 'value2', }, }) - ).toBe(4) + ).toStrictEqual(['a', 'b', 'c.d', 'c.e']) }) -test('should allow to count object properties with null, undefined properties ', () => { +test('should allow to build an array of object properties with null, undefined properties ', () => { expect( - countNestedProperties({ + objectKeysDeep({ a: null, b: undefined, c: 'value3', }) - ).toBe(3) + ).toStrictEqual(['a', 'b', 'c']) }) -test('should allow to count object with properties array property', () => { +test('should allow to build an array of object properties with properties array property', () => { expect( - countNestedProperties({ + objectKeysDeep({ a: [1, 2], b: true, c: true, }) - ).toBe(4) + ).toStrictEqual(['a.0', 'a.1', 'b', 'c']) }) -test('should allow to count object properties with empty array property', () => { +test('should allow to build an array of object properties with empty array property', () => { expect( - countNestedProperties({ + objectKeysDeep({ a: true, b: true, c: { @@ -78,7 +78,21 @@ test('should allow to count object properties with empty array property', () => e: [], }, }) - ).toBe(4) + ).toStrictEqual(['a', 'b', 'c.d', 'c.e']) +}) + +test('should allow to build an array of object properties from nested object', () => { + expect( + objectKeysDeep({ + a: true, + b: { + b1: true, + b2: true, + b3: {}, + }, + c: true, + }) + ).toStrictEqual(['a', 'b.b1', 'b.b2', 'b.b3', 'c']) }) test('object property is defined', () => { @@ -140,7 +154,12 @@ test('object property is not defined', () => { ) expect(() => assertObjectMatchSpec( - { name: 'john', gender: 'male', city: 'paris', street: 'rue du chat qui pĂȘche' }, + { + name: 'john', + gender: 'male', + city: 'paris', + street: 'rue du chat qui pĂȘche', + }, spec ) ).toThrow(`Property 'name' is defined: expected 'john' to be undefined`) @@ -227,13 +246,23 @@ test('check object property does not contain value', () => { expect(() => assertObjectMatchSpec( - { first_name: 'foo', last_name: 'bar', city: 'miami', street: 'calle ocho' }, + { + first_name: 'foo', + last_name: 'bar', + city: 'miami', + street: 'calle ocho', + }, spec ) ).not.toThrow() expect(() => assertObjectMatchSpec( - { first_name: 'johnny', last_name: 'bar', city: 'miami', street: 'calle ocho' }, + { + first_name: 'johnny', + last_name: 'bar', + city: 'miami', + street: 'calle ocho', + }, spec ) ).toThrow( @@ -241,13 +270,23 @@ test('check object property does not contain value', () => { ) expect(() => assertObjectMatchSpec( - { first_name: 'foo', last_name: 'doet', city: 'miami', street: 'calle ocho' }, + { + first_name: 'foo', + last_name: 'doet', + city: 'miami', + street: 'calle ocho', + }, spec ) ).toThrow(`Property 'last_name' (doet) contains 'doe': expected 'doet' to not include 'doe'`) expect(() => assertObjectMatchSpec( - { first_name: 'foo', last_name: 'bar', city: 'new york', street: 'calle ocho' }, + { + first_name: 'foo', + last_name: 'bar', + city: 'new york', + street: 'calle ocho', + }, spec ) ).toThrow( @@ -255,7 +294,12 @@ test('check object property does not contain value', () => { ) expect(() => assertObjectMatchSpec( - { first_name: 'foo', last_name: 'bar', city: 'miami', street: 'krome avenue' }, + { + first_name: 'foo', + last_name: 'bar', + city: 'miami', + street: 'krome avenue', + }, spec ) ).toThrow( @@ -303,36 +347,6 @@ test('check object property does not match regexp', () => { ) }) -test('check object fully matches spec', () => { - const spec = [ - { - field: 'first_name', - matcher: 'equal', - value: 'john', - }, - { - field: 'last_name', - matcher: 'match', - value: '^doe', - }, - ] - - expect(() => - assertObjectMatchSpec({ first_name: 'john', last_name: 'doet' }, spec, true) - ).not.toThrow() - expect(() => - assertObjectMatchSpec({ first_name: 'john', last_name: 'doet', gender: 'male' }, spec, true) - ).toThrow(`Expected json response to fully match spec, but it does not: expected 3 to equal 2`) - expect(() => - assertObjectMatchSpec({ first_name: 'john', last_name: 'john' }, spec, true) - ).toThrow(`Property 'last_name' (john) does not match '^doe': expected 'john' to match /^doe/`) - expect(() => - assertObjectMatchSpec({ first_name: 'doe', last_name: 'doe' }, spec, true) - ).toThrow( - `Expected property 'first_name' to equal 'john', but found 'doe': expected 'doe' to deeply equal 'john'` - ) -}) - test('check object property type', () => { const spec = [ { @@ -531,3 +545,92 @@ test('check unsupported matcher should fail', () => { `Matcher "unknown" did not match any supported assertions` ) }) + +test('check object fully matches spec', () => { + const spec = [ + { + field: 'first_name', + matcher: 'equal', + value: 'john', + }, + { + field: 'last_name', + matcher: 'match', + value: '^doe', + }, + { + field: 'address', + matcher: 'type', + value: 'object', + }, + { + field: 'phone.mobile', + matcher: 'match', + value: '^06', + }, + { + field: 'phone.mobile', + matcher: 'type', + value: 'string', + }, + ] + + const specNonObject = [ + { + field: 'first_name', + matcher: 'type', + value: 'undefined', + }, + ] + + expect(() => + assertObjectMatchSpec( + { + first_name: 'john', + last_name: 'doet', + address: {}, + phone: { mobile: '0600000000' }, + }, + spec, + true + ) + ).not.toThrow() + expect(() => + assertObjectMatchSpec( + { + first_name: 'john', + last_name: 'doet', + gender: 'male', + address: {}, + phone: { mobile: '0600000000' }, + }, + spec, + true + ) + ).toThrow( + `Expected json response to fully match spec, but it does not: expected [ Array(5) ] to deeply equal [ Array(4) ]` + ) + expect(() => + assertObjectMatchSpec({ first_name: 'john', last_name: 'john' }, spec, true) + ).toThrow(`Property 'last_name' (john) does not match '^doe': expected 'john' to match /^doe/`) + expect(() => + assertObjectMatchSpec({ first_name: 'doe', last_name: 'doe' }, spec, true) + ).toThrow( + `Expected property 'first_name' to equal 'john', but found 'doe': expected 'doe' to deeply equal 'john'` + ) + expect(() => assertObjectMatchSpec(undefined, specNonObject, true)).toThrow( + `Expected json response to be a valid object, but it is not: expected false to be true` + ) + expect(() => assertObjectMatchSpec(null, specNonObject, true)).toThrow( + `Expected json response to be a valid object, but it is not: expected false to be true` + ) + expect(() => assertObjectMatchSpec('undef', specNonObject, true)).toThrow( + `Expected json response to be a valid object, but it is not: expected false to be true` + ) + expect(() => assertObjectMatchSpec([], specNonObject, true)).toThrow( + `Expected json response to be a valid object, but it is not: expected false to be true` + ) + expect(() => assertObjectMatchSpec([{ first_name: 'doe' }], specNonObject, true)).toThrow( + `Expected json response to be a valid object, but it is not: expected false to be true` + ) +}) diff --git a/tests/extensions/http_api/definitions.test.js b/tests/extensions/http_api/definitions.test.js index 2ef46a29..291f2bab 100644 --- a/tests/extensions/http_api/definitions.test.js +++ b/tests/extensions/http_api/definitions.test.js @@ -744,7 +744,7 @@ test('check json response fully matches spec', () => { expect(() => def.exec(clientMock, 'fully ', { hashes: () => spec })).not.toThrow() expect(() => def.exec(clientMock, 'fully ', { hashes: () => spec })).toThrow( - `Expected json response to fully match spec, but it does not: expected 3 to equal 2` + `Expected json response to fully match spec, but it does not: expected [ 'first_name', 'last_name', 'gender' ] to deeply equal [ 'first_name', 'last_name' ]` ) expect(() => def.exec(clientMock, 'fully ', { hashes: () => spec })).toThrow( `Property 'last_name' (be) does not match 'ben': expected 'be' to match /ben/`