@ln-maf/validations
Version:
Validation step definitions for MAF
425 lines (374 loc) • 14.2 kB
JavaScript
require('@ln-maf/core/parameter_types')
const { When, Then } = require('@cucumber/cucumber')
const chai = require('chai')
const assert = chai.assert
const Ajv = require('ajv')
const { fillTemplate } = require('@ln-maf/core')
const { MAFWhen, tryAttach, performJSONObjectTransform } = require('@ln-maf/core')
const ajv = new Ajv()
/**
* Utility function to parse array strings consistently
* @param {string} arrayString - String representation of an array
* @returns {string[]} Parsed array
*/
function parseArrayString(arrayString) {
try {
return JSON.parse(arrayString)
} catch {
return arrayString
.replace(/^\[/, '')
.replace(/\]$/, '')
.split(',')
.map(item => item.trim())
}
}
/**
* Removes keys from a JSON object using dot notation
* @param {string} jsonKey - The key path to remove from the JSON object (supports dot notation and array indices)
* @param {Object} object - A JSON object
* @returns {boolean} true if the key was successfully removed
*/
function jsonDeleteKey(jsonKey, object) {
if (!object || typeof object !== 'object' || Array.isArray(object)) {
return false
}
const original = JSON.parse(JSON.stringify(object))
const keys = jsonKey.split('.')
let currentItem = object
// Navigate to the parent of the key to delete
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (key.includes('[') && key.includes(']')) {
const match = key.match(/^(.+?)\[(\d+)\]$/)
if (!match) {
return false
}
const [, arrayKey, index] = match
if (currentItem && currentItem[arrayKey] && Array.isArray(currentItem[arrayKey])) {
currentItem = currentItem[arrayKey][parseInt(index, 10)]
} else {
return false
}
} else {
if (!currentItem[key] || typeof currentItem[key] !== 'object') {
return false // Path doesn't exist or is not an object
}
currentItem = currentItem[key]
}
}
// Delete the final key (handle array indexing in final key too)
const finalKey = keys[keys.length - 1]
if (finalKey.includes('[') && finalKey.includes(']')) {
const match = finalKey.match(/^(.+?)\[(\d+)\]$/)
if (!match) {
return false
}
const [, arrayKey, index] = match
if (currentItem && currentItem[arrayKey] && Array.isArray(currentItem[arrayKey])) {
if (parseInt(index, 10) < currentItem[arrayKey].length) {
currentItem[arrayKey].splice(parseInt(index, 10), 1)
assert.notDeepEqual(object, original)
return true
}
}
return false
} else {
if (finalKey in currentItem) {
delete currentItem[finalKey]
assert.notDeepEqual(object, original)
return true
}
return false
}
}
/**
* Pulls the JSON keys from a JSON object into a new, smaller JSON object
* @param {Object} sourceJSON - The source JSON object to perform whitelist on
* @param {string[]} whitelist - A list of keys to keep from sourceJSON
* @param {string} [separator='.'] - A one character string to signal a split
* @returns {Object} A new filtered JSON object
*/
function whitelistJson(sourceJSON, whitelist, separator = '.') {
if (!sourceJSON || typeof sourceJSON !== 'object' || !Array.isArray(whitelist)) {
return {}
}
const result = {}
for (const keyPath of whitelist) {
const keys = keyPath.split(separator)
let sourceValue = sourceJSON
let targetRef = result
// Navigate through the key path
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (!sourceValue || typeof sourceValue !== 'object' || !(key in sourceValue)) {
break // Path doesn't exist in source
}
sourceValue = sourceValue[key]
if (!(key in targetRef)) {
targetRef[key] = {}
}
targetRef = targetRef[key]
}
// Set the final value if the path exists
const finalKey = keys[keys.length - 1]
if (sourceValue && typeof sourceValue === 'object' && finalKey in sourceValue) {
targetRef[finalKey] = sourceValue[finalKey]
}
}
return result
}
/**
* Removes the JSON key/value from the JSON Object provided
*/
MAFWhen('JSON key {string} is removed from {jsonObject}', function (jsonpath, jsonObject) {
jsonpath = fillTemplate(jsonpath, this.results)
let obj = performJSONObjectTransform.call(this, jsonObject)
if (typeof obj === 'string') {
obj = this.results[obj]
}
const success = jsonDeleteKey(jsonpath, obj)
assert(success, 'Could not delete key: ' + jsonpath)
return obj
})
/**
* Extracts a JSON value using dot notation path, supporting array indices
* @param {string} jsonPath - The path to extract (supports dot notation and array indices like key[0])
* @param {Object} jsonObject - The source JSON object
* @returns {any} The extracted value or 'undefined' if not found
*/
function extractJsonValue(jsonPath, jsonObject) {
if (!jsonObject || typeof jsonObject !== 'object') {
return 'undefined'
}
let value = jsonObject
const keys = jsonPath.split('.')
for (const key of keys) {
if (key.includes('[') && key.includes(']')) {
const match = key.match(/^(.+?)\[(\d+)\]$/)
if (!match) {
return 'undefined'
}
const [, arrayKey, index] = match
if (value && value[arrayKey] && Array.isArray(value[arrayKey])) {
value = value[arrayKey][parseInt(index, 10)]
} else {
return 'undefined'
}
} else {
if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)) {
value = value[key]
} else {
return 'undefined'
}
}
if (value === undefined || value === null) {
return 'undefined'
}
}
return value
}
/**
* Returns the JSON key from a variable to lastRun
*/
MAFWhen('JSON key {string} is extracted from {jsonObject}', function (jsonPath, jsonObject) {
jsonPath = fillTemplate(jsonPath, this.results)
jsonObject = performJSONObjectTransform.call(this, jsonObject)
return extractJsonValue(jsonPath, jsonObject)
})
/**
* Returns the JSON keys from a variable {jsonObject} to lastRun
*/
MAFWhen('JSON keys {string} are extracted from {jsonObject}', function (array, variable) {
let obj = performJSONObjectTransform.call(this, variable)
if (typeof obj === 'string') {
obj = this.results[obj]
}
array = fillTemplate(array, this.results)
const parsedArray = parseArrayString(array)
return whitelistJson(obj, parsedArray)
})
MAFWhen('run json path {string} on {jsonObject}', function (jPath, jsonObject) {
jPath = fillTemplate(jPath, this.results)
const { JSONPath } = require('jsonpath-plus')
const obj = performJSONObjectTransform.call(this, jsonObject)
return JSONPath({ path: jPath, json: obj })
})
/**
* Recursively converts all object keys to lowercase
* @param {Object} item - The object to process
*/
function lowerCaseItemKeys(item) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return
}
Object.keys(item).forEach(key => {
const lowerKey = key.toLowerCase()
if (lowerKey !== key) {
item[lowerKey] = item[key]
delete item[key]
}
if (item[lowerKey] && typeof item[lowerKey] === 'object' && !Array.isArray(item[lowerKey])) {
lowerCaseItemKeys(item[lowerKey])
}
})
}
When('make json keys for item {string} lower case', function (item) {
item = fillTemplate(item, this.results)
lowerCaseItemKeys(this.results[item])
tryAttach.call(this, this.results[item])
})
/**
* Recursively flattens a nested object structure
* @param {Object} item - The object to flatten
* @param {Object} result - The result object to populate
*/
function flattenObject(item, result) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return
}
Object.keys(item).forEach(key => {
if (typeof item[key] === 'object' && !Array.isArray(item[key]) && item[key] !== null) {
flattenObject(item[key], result)
} else {
result[key] = item[key]
}
})
}
When('json item {string} is flattened', function (item) {
const result = {}
flattenObject(this.results[item], result)
this.results[item] = result
tryAttach.call(this, this.results[item])
})
/**
* Recursively converts string values to numbers where possible
* @param {Object} item - The object to process
*/
function convertToNumbers(item) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return
}
Object.keys(item).forEach(key => {
if (typeof item[key] === 'object' && !Array.isArray(item[key]) && item[key] !== null) {
convertToNumbers(item[key])
} else if (typeof item[key] === 'string') {
const numericValue = Number(item[key])
if (!Number.isNaN(numericValue) && isFinite(numericValue)) {
item[key] = numericValue
}
}
})
}
When('json item {string} is numberifyed', function (item) {
convertToNumbers(this.results[item])
tryAttach.call(this, this.results[item])
})
/**
* Recursively trims whitespace from string values
* @param {Object} item - The object to process
*/
function trimStringValues(item) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return
}
Object.keys(item).forEach(key => {
if (typeof item[key] === 'object' && !Array.isArray(item[key]) && item[key] !== null) {
trimStringValues(item[key])
} else if (typeof item[key] === 'string') {
item[key] = item[key].trim()
}
})
}
When('json item {string} is trimmed', function (item) {
trimStringValues(this.results[item])
tryAttach.call(this, this.results[item])
})
/**
* Checks if a nested path exists in an object (supports dot notation and array indices)
* @param {string} path - The path to check (supports dot notation and array indices like key[0])
* @param {Object} object - The source object
* @returns {boolean} true if the path exists
*/
function pathExists(path, object) {
if (!object || typeof object !== 'object') {
return false
}
// First check if the path exists as a literal key (handles keys with dots/brackets in their names)
if (Object.prototype.hasOwnProperty.call(object, path)) {
return true
}
// If the path is a simple key without dots or brackets, and we already checked literal existence
if (!path.includes('.') && !path.includes('[')) {
return false
}
// For nested paths, use the same logic as extractJsonValue
let value = object
const keys = path.split('.')
for (const key of keys) {
if (key.includes('[') && key.includes(']')) {
const match = key.match(/^(.+?)\[(\d+)\]$/)
if (!match) {
return false
}
const [, arrayKey, index] = match
if (value && value[arrayKey] && Array.isArray(value[arrayKey])) {
if (parseInt(index, 10) >= value[arrayKey].length) {
return false
}
value = value[arrayKey][parseInt(index, 10)]
} else {
return false
}
} else {
if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)) {
value = value[key]
} else {
return false
}
}
if (value === undefined || value === null) {
return false
}
}
return true
}
Then('element {string} does not exist in {jsonObject}', function (element, jsonObject) {
const obj = performJSONObjectTransform.call(this, jsonObject)
element = fillTemplate(element, this.results)
assert(!pathExists(element, obj), `Expected path '${element}' to not exist`)
})
Then('element {string} exists in {jsonObject}', function (element, jsonObject) {
const obj = performJSONObjectTransform.call(this, jsonObject)
element = fillTemplate(element, this.results)
assert(pathExists(element, obj), `Expected path '${element}' to exist`)
})
Then('elements {string} do not exist in {jsonObject}', function (elementString, jsonObject) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const elements = parseArrayString(elementString)
assert.doesNotHaveAnyKeys(obj, elements)
})
Then('elements {string} exist in {jsonObject}', function (elementString, jsonObject) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const elements = parseArrayString(fillTemplate(elementString, this.results))
assert.containsAllKeys(obj, elements)
})
MAFWhen('{jsonObject} is validated against schema {jsonObject}', function (json, schema) {
json = performJSONObjectTransform.call(this, json)
schema = performJSONObjectTransform.call(this, schema)
let schemaName = 'unknown'
if (schema.value) {
schemaName = schema.value
} else if (typeof schema === 'object' && schema !== null) {
const keys = Object.keys(schema)
if (keys.length > 0) {
schemaName = keys[0]
}
}
// Ensure json is an array for consistent processing
const jsonArray = Array.isArray(json) ? json : [json]
for (const item of jsonArray) {
if (!ajv.validate(schema, item)) {
throw new Error(`The schema '${schemaName}' does not match the provided JSON:\n${JSON.stringify(ajv.errors, null, 2)}`)
}
}
})