@ln-maf/validations
Version:
Validation step definitions for MAF
607 lines (511 loc) • 22.1 kB
JavaScript
require('@ln-maf/core/parameter_types')
const { Then } = require('@cucumber/cucumber')
const { fillTemplate, performJSONObjectTransform } = require('@ln-maf/core')
const validator = require('validator')
// Constants
const TIME_FUNCTIONS = {
before: 'isBefore',
after: 'isAfter'
}
/**
* Converts a value to ISO date string if it's a valid timestamp
* @param {*} value - The value to convert
* @returns {string} ISO date string or original value
*/
const toISO = (value) => {
if (value == null) return value
const numericValue = Number(value)
if (isNaN(numericValue)) {
return value
}
try {
return new Date(numericValue).toISOString()
} catch {
return value
}
}
/**
* Normalizes values for comparison by converting numbers and booleans to strings
* @param {*} value - The value to normalize
* @returns {string|*} Normalized value
*/
const normalizeForComparison = (value) => {
if (value == null) return value
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return value
}
/**
* Checks if a value is a non-null object (excluding arrays)
* @param {*} value - The value to check
* @returns {boolean} True if value is a non-null, non-array object
*/
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
/**
* Deep comparison of objects that ignores property order
* @param {*} obj1 - First object to compare
* @param {*} obj2 - Second object to compare
* @returns {boolean} True if objects are deeply equal regardless of property order
*/
const deepEqual = (obj1, obj2) => {
// Fast path for identical references
if (obj1 === obj2) return true
// Handle null/undefined cases
if (obj1 == null || obj2 == null) return obj1 === obj2
// Type check
if (typeof obj1 !== typeof obj2) return false
// Primitive types
if (typeof obj1 !== 'object') return obj1 === obj2
// Array handling
if (Array.isArray(obj1)) {
if (!Array.isArray(obj2)) return false
if (obj1.length !== obj2.length) return false
return obj1.every((item, index) => deepEqual(item, obj2[index]))
}
// Ensure obj2 is not an array when obj1 is an object
if (Array.isArray(obj2)) return false
// Object comparison
const keys1 = Object.keys(obj1)
const keys2 = Object.keys(obj2)
if (keys1.length !== keys2.length) return false
const keys2Set = new Set(keys2)
return keys1.every(key =>
keys2Set.has(key) && deepEqual(obj1[key], obj2[key])
)
}
/**
* Performs equality comparison between two values using appropriate strategy
* @param {*} value1 - First value to compare
* @param {*} value2 - Second value to compare
* @returns {boolean} True if values are equal
*/
const performEqualityComparison = (value1, value2) => {
if (isObject(value1) && isObject(value2)) {
return deepEqual(value1, value2)
}
if (Array.isArray(value1) && Array.isArray(value2)) {
return deepEqual(value1, value2)
}
return normalizeForComparison(value1) === normalizeForComparison(value2)
}
/**
* Safely parses JSON string, returns original value if parsing fails
* @param {string} jsonString - String to parse as JSON
* @returns {*} Parsed object or original string
*/
const safeJsonParse = (jsonString) => {
try {
return JSON.parse(jsonString)
} catch {
return jsonString
}
}
/**
* Finds differences between two objects and returns a summary
* @param {*} actual - The actual object
* @param {*} expected - The expected object
* @param {string} path - Current path being compared
* @returns {string[]} Array of difference descriptions
*/
const findObjectDifferences = (actual, expected, path = '') => {
const differences = []
if (actual === expected) return differences
// Handle null/undefined cases
if (actual == null || expected == null) {
differences.push(`${path || 'root'}: actual=${actual}, expected=${expected}`)
return differences
}
// Handle type differences
if (typeof actual !== typeof expected) {
differences.push(`${path || 'root'}: type mismatch - actual=${typeof actual}, expected=${typeof expected}`)
return differences
}
// Handle array differences
if (Array.isArray(actual) && Array.isArray(expected)) {
if (actual.length !== expected.length) {
differences.push(`${path || 'root'}: array length - actual=${actual.length}, expected=${expected.length}`)
}
const maxLength = Math.max(actual.length, expected.length)
for (let i = 0; i < maxLength; i++) {
const newPath = `${path}[${i}]`
if (i >= actual.length) {
differences.push(`${newPath}: missing in actual, expected=${JSON.stringify(expected[i])}`)
} else if (i >= expected.length) {
differences.push(`${newPath}: extra in actual, actual=${JSON.stringify(actual[i])}`)
} else {
differences.push(...findObjectDifferences(actual[i], expected[i], newPath))
}
}
return differences
}
// Handle object differences
if (isObject(actual) && isObject(expected)) {
const allKeys = new Set([...Object.keys(actual), ...Object.keys(expected)])
for (const key of allKeys) {
const newPath = path ? `${path}.${key}` : key
if (!(key in actual)) {
differences.push(`${newPath}: missing in actual, expected=${JSON.stringify(expected[key])}`)
} else if (!(key in expected)) {
differences.push(`${newPath}: extra in actual, actual=${JSON.stringify(actual[key])}`)
} else {
differences.push(...findObjectDifferences(actual[key], expected[key], newPath))
}
}
return differences
}
// Handle primitive differences
if (actual !== expected) {
differences.push(`${path || 'root'}: actual=${JSON.stringify(actual)}, expected=${JSON.stringify(expected)}`)
}
return differences
}
/**
* Finds differences in long strings and returns a concise summary
* @param {string} actual - The actual string
* @param {string} expected - The expected string
* @returns {string} Formatted difference summary
*/
const findStringDifferences = (actual, expected) => {
const maxDisplayLength = 200 // Show at most 200 characters of context around differences
const contextLength = 50 // Show 50 characters before and after differences
if (actual === expected) {
return 'Strings are identical'
}
// For short strings, show them in full
if (actual.length <= maxDisplayLength && expected.length <= maxDisplayLength) {
return `Actual: "${actual}"\nExpected: "${expected}"`
}
// Find first difference
let firstDiff = 0
while (firstDiff < Math.min(actual.length, expected.length) && actual[firstDiff] === expected[firstDiff]) {
firstDiff++
}
// Find last difference (working backwards)
let lastDiffActual = actual.length - 1
let lastDiffExpected = expected.length - 1
while (lastDiffActual >= firstDiff && lastDiffExpected >= firstDiff &&
actual[lastDiffActual] === expected[lastDiffExpected]) {
lastDiffActual--
lastDiffExpected--
}
// If strings differ only at the end (length difference)
if (firstDiff === Math.min(actual.length, expected.length)) {
const shorterLength = Math.min(actual.length, expected.length)
const longerString = actual.length > expected.length ? actual : expected
const extraPart = longerString.substring(shorterLength)
const contextStart = Math.max(0, shorterLength - contextLength)
const contextBefore = longerString.substring(contextStart, shorterLength)
return `Strings differ in length:\n` +
`Length: actual=${actual.length}, expected=${expected.length}\n` +
`...${contextBefore}[${actual.length > expected.length ? `+${extraPart}` : `missing: ${extraPart}`}]`
}
// Calculate context boundaries
const contextStart = Math.max(0, firstDiff - contextLength)
const contextEndActual = Math.min(actual.length, lastDiffActual + contextLength + 1)
const contextEndExpected = Math.min(expected.length, lastDiffExpected + contextLength + 1)
// Extract context with differences
const actualContext = actual.substring(contextStart, contextEndActual)
const expectedContext = expected.substring(contextStart, contextEndExpected)
// Mark the difference position
const diffPosition = firstDiff - contextStart
let result = `Strings differ at position ${firstDiff}`
if (actual.length !== expected.length) {
result += ` (lengths: actual=${actual.length}, expected=${expected.length})`
}
result += ':\n'
// Show context with difference markers
const prefix = contextStart > 0 ? '...' : ''
const suffixActual = contextEndActual < actual.length ? '...' : ''
const suffixExpected = contextEndExpected < expected.length ? '...' : ''
result += `Actual: ${prefix}${actualContext}${suffixActual}\n`
result += `Expected: ${prefix}${expectedContext}${suffixExpected}\n`
// Add a pointer to show where the difference starts
const pointer = ' '.repeat(10 + prefix.length + diffPosition) + '^'
result += `${pointer} (first difference)`
return result
}
/**
* Formats an error message for equality comparisons
* @param {*} actual - The actual value
* @param {*} expected - The expected value
* @param {boolean} shouldEqual - Whether values should be equal
* @returns {string} Formatted error message
*/
const formatEqualityError = (actual, expected, shouldEqual = true) => {
const action = shouldEqual ? 'equal' : 'NOT equal'
// For non-equal case with identical values, show a simple message
if (!shouldEqual && performEqualityComparison(actual, expected)) {
return `Expected values to be different but they were identical:\nValue: ${JSON.stringify(actual)}`
}
// Handle string comparisons
if (typeof actual === 'string' && typeof expected === 'string') {
if (shouldEqual) {
return `Expected strings to be equal but they differ:\n${findStringDifferences(actual, expected)}`
} else {
return `Expected strings to be different but they are identical`
}
}
// For simple types (non-strings), show the values
if (!isObject(actual) || !isObject(expected)) {
const actualStr = String(actual)
const expectedStr = String(expected)
const typeInfo = shouldEqual ? ` (type: ${typeof actual})\nExpected: "${expectedStr}" (type: ${typeof expected})` : ` (should be different): "${expectedStr}"`
return `Expected actual value to ${action} expected value:\nActual: "${actualStr}"${typeInfo}`
}
// For objects, show only the differences
if (shouldEqual) {
const differences = findObjectDifferences(actual, expected)
if (differences.length === 0) {
return `Expected objects to be equal but they differ in structure`
}
const maxDifferencesToShow = 10
const shownDifferences = differences.slice(0, maxDifferencesToShow)
let diffMessage = `Expected objects to be equal but found ${differences.length} difference(s):\n`
diffMessage += shownDifferences.map(diff => ` • ${diff}`).join('\n')
if (differences.length > maxDifferencesToShow) {
diffMessage += `\n ... and ${differences.length - maxDifferencesToShow} more difference(s)`
}
return diffMessage
} else {
// For "not equal" case, just confirm they should be different
return `Expected objects to be different but they are identical`
}
}
/**
* Evaluates a comparison between two numeric values
* @param {number} value1 - First value
* @param {string} operator - Comparison operator
* @param {number} value2 - Second value
* @returns {boolean} Result of the comparison
* @throws {Error} If operator is invalid
*/
const evaluateComparison = (value1, operator, value2) => {
switch (operator) {
case '=':
case '==':
case '===':
return value1 === value2
case '!=':
return value1 !== value2
case '>':
return value1 > value2
case '>=':
return value1 >= value2
case '<':
return value1 < value2
case '<=':
return value1 <= value2
default:
throw new Error(`Invalid equivalence operator: ${operator}`)
}
}
Then('{jsonObject} {validationsEquivalence} {jsonObject}', function (obj1, operator, obj2) {
const numValue1 = Number(performJSONObjectTransform.call(this, obj1))
const numValue2 = Number(performJSONObjectTransform.call(this, obj2))
const result = evaluateComparison(numValue1, operator, numValue2)
if (!result) {
throw new Error(
'Expected comparison to be true:\n' +
`Actual: ${numValue1}\n` +
`Operator: ${operator}\n` +
`Expected: ${numValue2}\n` +
`Result: ${numValue1} ${operator} ${numValue2} = ${result}`
)
}
})
/**
* Validates date comparison using validator library
* @param {string} dateValue1 - First date as ISO string
* @param {string} timeQualifier - 'before' or 'after'
* @param {string} dateValue2 - Second date as ISO string
* @returns {boolean} True if comparison is valid
*/
const validateDateComparison = (dateValue1, timeQualifier, dateValue2) => {
const functionName = TIME_FUNCTIONS[timeQualifier]
if (!functionName) {
throw new Error(`Invalid time qualifier: ${timeQualifier}`)
}
return validator[functionName](dateValue1, dateValue2)
}
Then('{jsonObject} is {timeQualifier} now', function (jsonObject, timeQualifier) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const dateValue = toISO(obj)
const currentTime = new Date().toISOString()
const isValid = validateDateComparison(dateValue, timeQualifier, currentTime)
if (!isValid) {
throw new Error(
`Expected actual date to be ${timeQualifier} current time:\n` +
`Actual: ${dateValue}\n` +
`Current time: ${currentTime}\n` +
`Expected: ${dateValue} should be ${timeQualifier} ${currentTime}`
)
}
})
Then('{jsonObject} is {timeQualifier} {jsonObject}', function (value1, timeQualifier, value2) {
const obj1 = performJSONObjectTransform.call(this, value1)
const obj2 = performJSONObjectTransform.call(this, value2)
const dateValue1 = toISO(obj1)
const dateValue2 = toISO(obj2)
const isValid = validateDateComparison(dateValue1, timeQualifier, dateValue2)
if (!isValid) {
throw new Error(
`Expected actual date to be ${timeQualifier} expected date:\n` +
`Actual: ${dateValue1}\n` +
`Expected: ${dateValue2}\n` +
`Comparison: ${dateValue1} should be ${timeQualifier} ${dateValue2}`
)
}
})
/**
* Checks if a value is null or undefined
* @param {*} value - Value to check
* @returns {boolean} True if value is null or undefined
*/
const isNullOrUndefined = (value) => value === null || value === undefined
Then('{jsonObject} is not null', function (jsonObject) {
const obj = performJSONObjectTransform.call(this, jsonObject)
if (isNullOrUndefined(obj)) {
throw new Error(
'Expected actual value to not be null:\n' +
`Actual: ${obj}\n` +
'Expected: not null/undefined'
)
}
})
Then('{jsonObject} is null', function (jsonObject) {
const obj = performJSONObjectTransform.call(this, jsonObject)
if (!isNullOrUndefined(obj)) {
const objStr = typeof obj === 'object' ? JSON.stringify(obj, null, 2) : String(obj)
throw new Error(
'Expected actual value to be null:\n' +
`Actual: ${objStr}\n` +
'Expected: null'
)
}
})
Then('{jsonObject} is not equal to {jsonObject}', function (item1, item2) {
const value1 = performJSONObjectTransform.call(this, item1)
const value2 = performJSONObjectTransform.call(this, item2)
if (performEqualityComparison(value1, value2)) {
throw new Error(formatEqualityError(value1, value2, false))
}
})
Then('{jsonObject} is equal to {jsonObject}', function (item1, item2) {
const value1 = performJSONObjectTransform.call(this, item1)
const value2 = performJSONObjectTransform.call(this, item2)
if (!performEqualityComparison(value1, value2)) {
throw new Error(formatEqualityError(value1, value2, true))
}
})
Then('{jsonObject} is not equal to:', function (item1, templateString) {
const value1 = performJSONObjectTransform.call(this, item1)
const expectedString = fillTemplate(templateString, this.results)
const expectedValue = safeJsonParse(expectedString)
if (performEqualityComparison(value1, expectedValue)) {
throw new Error(formatEqualityError(value1, expectedValue, false))
}
})
Then('{jsonObject} is equal to:', function (item1, templateString) {
const value1 = performJSONObjectTransform.call(this, item1)
const expectedString = fillTemplate(templateString, this.results)
const expectedValue = safeJsonParse(expectedString)
if (!performEqualityComparison(value1, expectedValue)) {
throw new Error(formatEqualityError(value1, expectedValue, true))
}
})
Then('{jsonObject} contains {string}', function (jsonObject, searchString) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const processedSearchString = fillTemplate(searchString, this.results)
const objStr = JSON.stringify(obj)
if (!objStr.includes(processedSearchString)) {
throw new Error(
'Expected actual value to contain expected string:\n' +
`Actual: ${objStr}\n` +
`Expected to contain: "${processedSearchString}"`
)
}
})
Then('{jsonObject} does not contain {string}', function (jsonObject, searchString) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const processedSearchString = fillTemplate(searchString, this.results)
const objStr = JSON.stringify(obj)
if (objStr.includes(processedSearchString)) {
throw new Error(
'Expected actual value to NOT contain expected string:\n' +
`Actual: ${objStr}\n` +
`Expected to NOT contain: "${processedSearchString}"`
)
}
})
/**
* Gets the size/length of a value
* @param {*} value - The value to get size of
* @returns {number} The size/length of the value
*/
const getSize = (value) => {
if (value == null) return 0
if (Array.isArray(value)) return value.length
if (typeof value === 'string') return value.length
if (typeof value === 'object') return Object.keys(value).length
if (typeof value === 'number') return String(value).length
return String(value).length
}
Then('{jsonObject} has a length of {int}', function (jsonObject, expectedLength) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const actualLength = getSize(obj)
if (actualLength !== expectedLength) {
const objType = Array.isArray(obj) ? 'array' : typeof obj
const objStr = typeof obj === 'object' ? JSON.stringify(obj, null, 2) : String(obj)
throw new Error(
`Expected ${objType} to have length ${expectedLength}:\n` +
`Actual: ${objStr}\n` +
`Actual length: ${actualLength}\n` +
`Expected length: ${expectedLength}`
)
}
})
Then('{jsonObject} has a length greater than {int}', function (jsonObject, expectedLength) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const actualLength = getSize(obj)
if (actualLength <= expectedLength) {
const objType = Array.isArray(obj) ? 'array' : typeof obj
const objStr = typeof obj === 'object' ? JSON.stringify(obj, null, 2) : String(obj)
throw new Error(
`Expected ${objType} to have length greater than ${expectedLength}:\n` +
`Actual: ${objStr}\n` +
`Actual length: ${actualLength}\n` +
`Expected: length > ${expectedLength}`
)
}
})
Then('{jsonObject} has a length less than {int}', function (jsonObject, expectedLength) {
const obj = performJSONObjectTransform.call(this, jsonObject)
const actualLength = getSize(obj)
if (actualLength >= expectedLength) {
const objType = Array.isArray(obj) ? 'array' : typeof obj
const objStr = typeof obj === 'object' ? JSON.stringify(obj, null, 2) : String(obj)
throw new Error(
`Expected ${objType} to have length less than ${expectedLength}:\n` +
`Actual: ${objStr}\n` +
`Actual length: ${actualLength}\n` +
`Expected: length < ${expectedLength}`
)
}
})
Then('{jsonObject} is greater than {int}', function (itemPath, expectedValue) {
const actualValue = performJSONObjectTransform.call(this, itemPath)
const numActual = Number(actualValue)
const numExpected = Number(expectedValue)
if (isNaN(numActual)) {
throw new Error(`Expected numeric value but got: ${JSON.stringify(actualValue)} (type: ${typeof actualValue})`)
}
if (numActual <= numExpected) {
throw new Error(`Expected ${actualValue} to be greater than ${expectedValue}`)
}
})
Then('{jsonObject} is equal to null', function (itemPath) {
const actualValue = performJSONObjectTransform.call(this, itemPath)
if (actualValue !== null) {
throw new Error(`Expected null but got: ${JSON.stringify(actualValue)} (type: ${typeof actualValue})`)
}
})