UNPKG

cumulocity-cypress

Version:
266 lines (265 loc) 12 kB
import _ from "lodash"; import * as datefns from "date-fns"; import { get_i } from "../util"; /** * 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(), }) { this.propertyMatchers = {}; this.propertyMatchers = propertyMatchers; } match(obj1, obj2, options) { if (obj1 === obj2) return true; const parents = options?.parents ?? []; const strictMatching = options?.strictMatching ?? false; const schemaMatcher = options?.schemaMatcher || C8yDefaultPactMatcher.schemaMatcher; const throwPactError = (message, key) => { const errorMessage = `Pact validation failed! ${message}`; const newErr = new Error(errorMessage); newErr.name = "C8yPactError"; if (options?.loggerProps) { options.loggerProps.error = errorMessage; 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]; } throw newErr; }; const keyPath = (k) => { return `${[...parents, ...(k ? [k] : [])].join(" > ")}`; }; const isArrayOfPrimitives = (value) => { if (!_.isArray(value)) { return false; } const primitiveTypes = ["undefined", "boolean", "number", "string"]; return (value.filter((p) => primitiveTypes.includes(typeof p)).length === value.length); }; 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}".`); } // get keys of objects without schema keys and schema keys separately const objectKeys = Object.keys(obj1).filter((k) => !k.startsWith("$")); const schemaKeys = Object.keys(obj2).filter((k) => k.startsWith("$")); // normalize pact keys and remove keys that have a schema defined // we do not want for example body and $body const pactKeys = 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) => key.startsWith("$") ? key.slice(1) : key; // 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 ? pactKeys : objectKeys; for (const key of keys) { // schema is always defined on the pact object - needs special consideration const isSchema = key.startsWith("$") || schemaKeys.includes(`$${key}`); const value = _.get(strictMatching || isSchema ? obj1 : obj2, removeSchemaPrefix(key)); const pact = _.get(strictMatching || isSchema ? obj2 : obj1, isSchema && !key.startsWith("$") ? `$${key}` : key); if (!(strictMatching ? pactKeys : objectKeys).includes(key) && !isSchema) { throwPactError(`"${keyPath(key)}" not found in ${strictMatching ? "pact" : "response"} object.`); } if (isSchema) { const errorKey = removeSchemaPrefix(key); if (!schemaMatcher) { throwPactError(`No schema matcher registered to validate "${keyPath(errorKey)}".`, errorKey); } try { if (!schemaMatcher.match(value, pact, strictMatching)) { throwPactError(`Schema for "${keyPath(errorKey)}" does not match.`, errorKey); } } catch (error) { throwPactError(`Schema for "${keyPath(errorKey)}" does not match. (${error})`, errorKey); } } else 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") { throw error; } else { throwPactError(`Values for "${keyPath(key)}" do not match.${error != null ? " " + error : ""}`, key); } } } else if (isArrayOfPrimitives(value) && isArrayOfPrimitives(pact)) { const v = [value, pact].sort((a1, a2) => a2.length - a1.length); const diff = _.difference(v[0], v[1]); if (_.isEmpty(diff)) { continue; } else { throwPactError(`Array with key "${keyPath(key)}" has unexpected values "${diff}".`, key); } } else if (_.isArray(value) && _.isArray(pact)) { if (value.length !== pact.length) { throwPactError(`Array with key "${keyPath(key)}" has different lengths.`, key); } for (let i = 0; i < value.length; i++) { this.match(value[i], pact[i], _.extend(options, { parents: [...parents, key, `${i}`] })); } } else if (_.isObjectLike(value) && _.isObjectLike(pact)) { // if strictMatching is disabled, value1 and value2 have been swapped // swap back to ensure swapping in next iteration works as expected this.match(strictMatching ? value : pact, strictMatching ? pact : value, _.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; } /** * 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]; } } /** * 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); } }