cumulocity-cypress
Version:
Cypress commands for Cumulocity IoT
431 lines (430 loc) • 19.4 kB
JavaScript
import _ from "lodash";
import * as datefns from "date-fns";
import { get_i } from "../util";
/**
* Error thrown when a C8yPactMatcher fails to match two objects.
* Contains the actual and expected values, the key that failed to match and
* the key path of the property that failed to match.
* The key path is a string representation of the path to the property that failed to match.
* For example: "body > id" for a property "id" in the "body" object.
* This error is used to provide detailed information about the match failure.
*/
export class C8yPactMatchError extends Error {
constructor(message, options) {
super(message);
this.name = "C8yPactMatchError";
this.actual = options.actual;
this.expected = options.expected;
this.key = options.key;
this.keyPath = options.keyPath;
this.schema = options.schema;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, C8yPactMatchError);
}
}
}
/**
* Default implementation of C8yPactMatcher to match C8yPactRecord objects. Pacts
* are matched by comparing the properties of the objects using property matchers.
* If no property matcher is configured for a property, the property will be matched
* by equality. Disable Cypress.c8ypact.config.strictMatching to ignore properties that are
* missing in matched objects. In case objects do not match an C8yPactError is thrown.
*/
export class C8yDefaultPactMatcher {
constructor(propertyMatchers = {
body: new C8yPactBodyMatcher(),
requestBody: new C8yPactBodyMatcher(),
duration: new C8yNumberMatcher(),
date: new C8yIgnoreMatcher(),
Authorization: new C8yIgnoreMatcher(),
auth: new C8yIgnoreMatcher(),
options: new C8yIgnoreMatcher(),
createdObject: new C8yIgnoreMatcher(),
location: new C8yIgnoreMatcher(),
url: new C8yIgnoreMatcher(),
"X-XSRF-TOKEN": new C8yIgnoreMatcher(),
lastMessage: new C8yISODateStringMatcher(),
}, options) {
this.propertyMatchers = {};
this.propertyMatchers = propertyMatchers;
this.options = options;
}
match(obj1, obj2, options) {
if (obj1 === obj2)
return true;
options = _.defaults({}, options, this.options, C8yDefaultPactMatcher.options);
const parents = options?.parents ?? [];
const strictMatching = options?.strictMatching ?? false;
const ignorePrimitiveArrayOrder = options?.ignorePrimitiveArrayOrder ?? true;
const matchSchemaAndObject = options?.matchSchemaAndObject ?? false;
const schemaMatcher = options?.schemaMatcher || C8yDefaultPactMatcher.schemaMatcher;
const addLoggerProps = (props, message, key) => {
if (options?.loggerProps) {
options.loggerProps.error = message;
options.loggerProps.key = key;
options.loggerProps.keypath = keyPath(key);
options.loggerProps.objects =
key && _.isPlainObject(obj1) && _.isPlainObject(obj2)
? [_.pick(obj1, [key]), _.pick(obj2, [key])]
: [obj1, obj2];
}
};
const throwPactError = (message, key) => {
const newErr = new C8yPactMatchError(`Pact validation failed${options?.requestId ? ` for request ${options.requestId}` : ""}! ${message}`, {
actual: obj1,
expected: obj2,
...(key != null ? { key, keyPath: keyPath(key) } : {}),
});
addLoggerProps(options?.loggerProps, newErr.message, key);
throw newErr;
};
const throwSchemaError = (message, key, schema, value) => {
const newErr = new C8yPactMatchError(`Pact validation failed${options?.requestId ? ` for request ${options.requestId}` : ""}! ${message}`, {
actual: value ?? obj1,
expected: schema ?? obj2,
key,
keyPath: keyPath(key),
schema: schema,
});
addLoggerProps(options?.loggerProps, newErr.message, key);
throw newErr;
};
const keyPath = (k) => {
if (_.isArray(k)) {
const segments = k.map((segment) => segment.toString());
return segments.join(" > ");
}
return `${[...parents, ...(k ? [k] : [])].join(" > ")}`;
};
const isArrayOfPrimitivesOrNull = (value) => {
if (!_.isArray(value)) {
return false;
}
const primitiveTypes = ["undefined", "boolean", "number", "string"];
return (value.filter((p) => primitiveTypes.includes(typeof p) || p === null)
.length === value.length);
};
const matchArraysOfPrimitives = (value, pact, parents) => {
if (value.length !== pact.length) {
throwPactError(`Arrays with key "${keyPath(parents)}" have different lengths.`, keyPath(parents));
}
const diff = [];
const sortedValue = ignorePrimitiveArrayOrder
? [...value].sort()
: [...value];
const sortedPact = ignorePrimitiveArrayOrder
? [...pact].sort()
: [...pact];
for (let i = 0; i < sortedValue.length; i++) {
if (i >= sortedValue.length ||
i >= sortedPact.length ||
sortedValue[i] !== sortedPact[i]) {
diff.push(i);
}
}
if (diff.length === 0) {
return;
}
else {
throwPactError(`Arrays with key "${keyPath(parents)}" have mismatches at indices "${diff}".`, keyPath(parents));
}
};
if (_.isString(obj1) && _.isString(obj2) && !_.isEqual(obj1, obj2)) {
throwPactError(`"${keyPath()}" text did not match.`);
}
if (!_.isObject(obj1) || !_.isObject(obj2)) {
throwPactError(`Expected 2 objects as input for matching, but got "${typeof obj1}" and ${typeof obj2}".`);
}
if (_.isArray(obj1) && _.isArray(obj2)) {
if (obj1.length !== obj2.length) {
throwPactError(`Arrays at "${_.isEmpty(parents) ? "root" : keyPath()}" have different lengths.`);
}
}
if (_.isArray(obj1) !== _.isArray(obj2)) {
throwPactError(`Type mismatch at "${_.isEmpty(parents) ? "root" : keyPath()}". Expected ${_.isArray(obj2) ? "array" : "object"} but got ${_.isArray(obj1) ? "array" : "object"}.`);
}
// get keys of objects without schema keys and schema keys separately
const objectKeys = Object.keys(obj1).filter((k) => !this.isSchemaMatcherKey(k));
const schemaKeys = Object.keys(obj2).filter((k) => this.isSchemaMatcherKey(k));
// normalize pact keys and remove keys that have a schema defined
// we do not want for example body and $body
const pactKeys = matchSchemaAndObject === true
? Object.keys(obj2)
: Object.keys(obj2).reduce((acc, key) => {
if (!schemaKeys.includes(`$${key}`)) {
acc.push(key);
}
return acc;
}, []);
if (_.isEmpty(objectKeys) && _.isEmpty(pactKeys)) {
return true;
}
const removeSchemaPrefix = (key) => this.isSchemaMatcherKey(key) ? key.slice(1) : key;
const findActualKey = (obj, keyToFind) => {
if (!options?.ignoreCase)
return keyToFind;
if (obj == null || !_.isObject(obj))
return keyToFind;
const actualKey = Object.keys(obj).find((k) => k.toLowerCase() === keyToFind.toLowerCase());
return actualKey ?? keyToFind;
};
// if strictMatching is disabled, only check properties of the pact for object matching
// strictMatching for schema matching is considered within the matcher -> schema.additionalProperties
const keys = strictMatching === false ? pactKeys : objectKeys;
// When strictMatching is enabled, also ensure every pact key is present in the response.
// The main loop (iterating objectKeys) only catches extra response keys not in the pact;
// this extra pass catches pact keys that are absent from the response.
if (strictMatching === true) {
for (const pactKey of pactKeys) {
if (this.isSchemaMatcherKey(pactKey))
continue;
if (!this.isKeyPathInObject(objectKeys, pactKey, options?.ignoreCase)) {
throwPactError(`"${keyPath(pactKey)}" not found in response object.`);
}
}
}
for (const key of keys) {
// schema is always defined on the pact object - needs special consideration
const isSchema = this.isSchemaMatcherKey(key) || schemaKeys.includes(`$${key}`);
// Resolve actual keys with correct casing when ignoreCase is enabled
// obj1 is always the actual response, obj2 is always the pact/record
const valueSourceObj = obj1;
const pactSourceObj = obj2;
const keyForValue = findActualKey(valueSourceObj, removeSchemaPrefix(key));
const keyForPact = findActualKey(pactSourceObj, isSchema && !key.startsWith("$") ? `$${key}` : key);
const value = _.get(valueSourceObj, keyForValue);
let pact = _.get(pactSourceObj, keyForPact);
if (!isSchema &&
!this.isKeyPathInObject(strictMatching ? pactKeys : objectKeys, key, options?.ignoreCase)) {
if (strictMatching) {
throwPactError(`"${keyPath(key)}" not found in pact object.`);
}
else {
// strictMatching: false — only skip when the key is genuinely absent from the
// response (no match even case-insensitively). If the key exists with different
// casing but ignoreCase: false, surface the casing mismatch by throwing.
if (this.isKeyPathInObject(objectKeys, key, true)) {
throwPactError(`"${keyPath(key)}" not found in response object.`);
}
continue;
}
}
if (isSchema) {
const errorKey = removeSchemaPrefix(key);
if (!schemaMatcher) {
throwSchemaError(`No schema matcher registered to validate "${keyPath(errorKey)}".`, errorKey, pact, value);
}
try {
if (!schemaMatcher.match(value, pact, strictMatching)) {
throwSchemaError(`Schema for "${keyPath(errorKey)}" does not match.`, errorKey, pact, value);
}
}
catch (error) {
throwSchemaError(`Schema for "${keyPath(errorKey)}" does not match (${error?.message ?? error}).`, errorKey, pact, value);
}
if (!matchSchemaAndObject) {
continue;
}
const keyForSchemaAndObject = findActualKey(obj2, removeSchemaPrefix(key));
pact = _.get(obj2, keyForSchemaAndObject);
}
if (this.getPropertyMatcher(key, options?.ignoreCase) != null) {
if (!strictMatching && !value) {
continue;
}
try {
const result = this.getPropertyMatcher(key, options?.ignoreCase)?.match(value, pact, _.extend(options, { parents: [...parents, key] }));
if (!result)
throw new Error("");
}
catch (error) {
// calling match recursively requires to pass the root error
if (_.get(error, "name") === "C8yPactError" ||
_.get(error, "name") === "C8yPactMatchError") {
throw error;
}
else {
throwPactError(`Values for "${keyPath(key)}" do not match.${error != null ? " " + (error?.message ?? error) : ""}`, key);
}
}
}
else if (isArrayOfPrimitivesOrNull(value) &&
isArrayOfPrimitivesOrNull(pact)) {
matchArraysOfPrimitives(value, pact, [...parents, key]);
}
else if (_.isArray(value) && _.isArray(pact)) {
if (value.length !== pact.length) {
throwPactError(`Arrays with key "${keyPath(key)}" have different lengths.`, key);
}
for (let i = 0; i < value.length; i++) {
if (isArrayOfPrimitivesOrNull(value[i]) &&
isArrayOfPrimitivesOrNull(pact[i])) {
matchArraysOfPrimitives(value[i], pact[i], [...parents, key, i]);
}
else {
this.match(value[i], pact[i], _.extend(options, { parents: [...parents, key, i] }));
}
}
}
else if (_.isObjectLike(value) && _.isObjectLike(pact)) {
if (isArrayOfPrimitivesOrNull(value) &&
isArrayOfPrimitivesOrNull(pact)) {
matchArraysOfPrimitives(value, pact, [...parents, key]);
}
else {
this.match(value, pact, _.extend(options, { parents: [...parents, key] }));
}
}
else {
if (value != null && pact != null && !_.isEqual(value, pact)) {
throwPactError(`Values for "${keyPath(key)}" do not match.`, key);
}
}
}
return true;
}
/**
* Check if a key is a schema matcher key (starts with $ but is not a standard JSON Schema keyword)
*/
isSchemaMatcherKey(key) {
if (!key.startsWith("$")) {
return false;
}
return !C8yDefaultPactMatcher.JSON_SCHEMA_KEYWORDS.has(key);
}
isKeyPathInObject(keys, keyPath, ignoreCase = false) {
if (!Array.isArray(keys)) {
return false;
}
if (ignoreCase) {
const lowerKeyPath = keyPath.toLowerCase();
return keys.some((item) => typeof item === "string" && item.toLowerCase() === lowerKeyPath);
}
return keys.includes(keyPath);
}
/**
* Returns the property matcher for the given property name.
* @param key The property name to get the matcher for.
* @param ignoreCase Whether to ignore the case of the property name.
*/
getPropertyMatcher(key, ignoreCase = false) {
if (ignoreCase) {
return get_i(this.propertyMatchers, key);
}
return this.propertyMatchers[key];
}
/**
* Adds a new property matcher for the given property name.
*/
addPropertyMatcher(propertyName, matcher) {
this.propertyMatchers[propertyName] = matcher;
}
/**
* Removes the property matcher for the given property name.
*/
removePropertyMatcher(propertyName) {
delete this.propertyMatchers[propertyName];
}
}
/**
* Standard JSON Schema keywords that start with $ but are not schema matcher keys.
* These should be treated as regular object properties.
* @see https://json-schema.org/understanding-json-schema/reference
*/
C8yDefaultPactMatcher.JSON_SCHEMA_KEYWORDS = new Set([
"$schema",
"$id",
"$ref",
"$comment",
"$defs",
"$vocabulary",
"$anchor",
"$dynamicRef",
"$dynamicAnchor",
"$recursiveRef",
"$recursiveAnchor",
]);
/**
* Extends C8yDefaultPactMatcher with default property matchers for Cumulocity
* response bodies. It has rules configured at least for the following properties:
* id, statistics, lastUpdated, creationTime, next, self, password, owner, tenantId
* and lastPasswordChange. It is registered for the properties body and requestBody.
*/
export class C8yPactBodyMatcher extends C8yDefaultPactMatcher {
constructor(propertyMatchers = {}) {
super(propertyMatchers);
this.addPropertyMatcher("id", new C8ySameTypeMatcher());
this.addPropertyMatcher("statistics", new C8yIgnoreMatcher());
this.addPropertyMatcher("lastUpdated", new C8yISODateStringMatcher());
this.addPropertyMatcher("creationTime", new C8yISODateStringMatcher());
this.addPropertyMatcher("next", new C8yIgnoreMatcher());
this.addPropertyMatcher("self", new C8yIgnoreMatcher());
this.addPropertyMatcher("password", new C8yIgnoreMatcher());
this.addPropertyMatcher("owner", new C8ySameTypeMatcher());
this.addPropertyMatcher("tenantId", new C8yIgnoreMatcher());
this.addPropertyMatcher("lastPasswordChange", new C8yISODateStringMatcher());
}
}
export class C8yIdentifierMatcher {
match(obj1, obj2) {
[obj1, obj2].forEach((id) => {
if (_.isString(id) === false || /^\d+$/.test(id) === false) {
throw new Error(`Value "${id}" is not a valid identifier.`);
}
});
return true;
}
}
export class C8yNumberMatcher {
match(obj1, obj2) {
[obj1, obj2].forEach((n) => {
if (!_.isNumber(n) || _.isNaN(n)) {
throw new Error(`Value "${obj1}" is not a number.`);
}
});
return true;
}
}
export class C8yStringMatcher {
match(obj1, obj2) {
[obj1, obj2].forEach((s) => {
if (!_.isString(s)) {
throw new Error(`Value "${s}" is not a string.`);
}
});
return true;
}
}
export class C8yIgnoreMatcher {
match() {
return true;
}
}
export class C8ySameTypeMatcher {
match(obj1, obj2) {
const result = typeof obj1 === typeof obj2;
if (!result) {
throw new Error(`Values are not of same type. Expected ${typeof obj1} but got ${typeof obj2}`);
}
return result;
}
}
export class C8yISODateStringMatcher {
match(obj1, obj2) {
// validate regex as parseISO does not throw an error for invalid dates
// and is not strict enough for our use case
// https://regex101.com/library/6gJsuQ?filterFlavors=javascript&page=9
const isoRegex = new RegExp(/^((\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|(02)-(0[1-9]|1\d|2[0-8])))T([01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}([+-]([01]\d|2[0-3]):[0-5]\d|Z)$/);
[obj1, obj2].forEach((obj) => {
if (!_.isString(obj)) {
throw new Error(`Value "${obj}" is not a string.`);
}
if (!isoRegex.test(obj)) {
throw new Error(`Value "${obj}" is not a valid ISO date string.`);
}
});
const d1 = datefns.parseISO(obj1);
const d2 = datefns.parseISO(obj2);
return datefns.isValid(d1) && datefns.isValid(d2);
}
}