Source: Assertion.js

const jsonpath = require("jsonpath-bespoken");
const ParserError = require("./ParserError");
const Util = require("../util/Util");

const OPERATORS = ["==", "=~", "!=", ">", ">=", "<", "<="];
const NUMERIC_OPERATORS = [">", ">=", "<", "<="];

/**
 * Represents an assertion to evaluate during an interaction
 */
class Assertions {
    /**
     *
     * @param {TestInteraction} interaction - the interaction that is being evaluated
     * @param {string} path - the Json path of the property in the response being evaluated
     * @param {string} operator - the operator used to evaluate the response property
     * @param {string} value - the value that is evaluated against the response property
     * @param {string[]} variables - variables inside the utterance that can be replaced
     * @param {string} originalOperator - the operator before parsing the interaction
     */
    constructor(interaction, path, operator, value, variables, originalOperator, lenientMode=false) {
        this._interaction = interaction;
        this._path = path;
        this._operator = operator;
        this._value = value;
        this._goto = undefined;
        this._variables = variables;
        this._originalOperator = originalOperator;
        this._lenientMode = lenientMode;
        this.parse();
    }

    /**
     * Parses the value to check and set the goto property if needed
     */
    parse() {
        if (this.exit) {
            return;
        }

        // Looks for a goto in the value statement
        if (Util.isString(this._value)
            && this._value.includes(" goto ")) {
            const gotoRegex = /(.*) goto (.*)/i;
            const matchArray = this._value.match(gotoRegex);
            if (matchArray.length === 2) {
                throw ParserError.interactionError(this.interaction,
                    "Invalid goto - does not have label: " + this._value,
                    this.lineNumber);
            } else if (matchArray.length === 3) {
                this._value = matchArray[1];
                this._goto = matchArray[2];
            }
        }
    }

    /**
     * Validate if the assertion syntax is correct if not throws a Parser Error
     * @throws {ParserError}
     */
    validate() {
        const result = Util.executeFilterSync(this.interaction.test.testSuite.filterArray(), "onValidate", this);
        if (typeof result !== "undefined") {
            return result;
        }
        const path = jsonpath.parse(this.path);
        if (!path) {
            throw ParserError.interactionError(this.interaction,
                "Invalid JSON path: " + this.path,
                this.lineNumber);
        }

        if (!OPERATORS.includes(this.operator)) {
            throw ParserError.interactionError(this.interaction,
                "Invalid operator: " + this.operator,
                this.lineNumber);
        }

        // Check to make sure the expected value is a number if this is a numeric operator
        if (NUMERIC_OPERATORS.includes(this.operator)) {
            if (isNaN(this.value)) {
                throw ParserError.interactionError(this.interaction,
                    "Invalid expected value - must be numeric: " + this.value,
                    this.lineNumber);
            }
        }
    }

    /**
     * Evaluates this assertion against the provided response and returns true if it succeeds
     * @param {object} response - the response to which we do the assertion
     * @return {boolean}
     */
    evaluate(response) {
        if (this.exit) {
            return true;
        }
        const json = response.json;
        let jsonValue = this.valueAtPath(json);
        const ignoreCase = response.ignoreCase(this.path);

        if (this.operator === "==" || this.operator === "=~") {
            if (this.value === undefined) {
                return jsonValue === undefined;
            }

            let match = false;
            if (jsonValue !== undefined) {
                if (Array.isArray(this.value)) {
                    for (const localizedValue of this.value) {
                        match = this.evaluateRegexOrString(this.operator, localizedValue, jsonValue, ignoreCase);
                        // Once matched, do not need to process further
                        if (match) {
                            break;
                        }
                    }
                } else {
                    const localizedValue = this.value;
                    match = this.evaluateRegexOrString(this.operator, localizedValue, jsonValue, ignoreCase);
                }
            }
            return match;
        } else if (NUMERIC_OPERATORS.includes(this.operator)) {
            if (isNaN(jsonValue)) {
                return false;
            }

            const expectedValue = parseInt(this.value, 10);
            const actualValue = parseInt(jsonValue, 10);

            if (this.operator === ">") {
                return actualValue > expectedValue;
            } else if (this.operator === ">=") {
                return actualValue >= expectedValue;
            } else  if (this.operator === "<") {
                return actualValue < expectedValue;
            } else  if (this.operator === "<=") {
                return actualValue <= expectedValue;
            }
        } else if (this.operator === "!=") {
            if (this.value === undefined) {
                return jsonValue !== undefined;
            }

            if (jsonValue === undefined) {
                return this.value !== undefined;
            }

            if (ignoreCase) {
                jsonValue = jsonValue.toLowerCase();
            }

            if (Array.isArray(this.value)) {
                let resultNotEqual = true;
                for (let localizedValue of this.value) {
                    if (ignoreCase) {
                        localizedValue = localizedValue && localizedValue.toLowerCase();
                    }
                    resultNotEqual = jsonValue.includes(localizedValue);
                    if (resultNotEqual) {
                        return false;
                    }
                }
                return true;
            } else {
                const valueToCompare = ignoreCase ? this.value.toLowerCase() : this.value;
                return !jsonValue || !jsonValue.includes(valueToCompare);
            }
            
        } else {
            throw "Operator not implemented yet: " + this.operator;
        }
    }

    /**
     * Evaluates if a regex or string complies with the assertion
     * @param {string} operator - Operator used to evaluate the expected value against the actual one.
     * @param {string} expectedValue - Value defined in the assertion
     * @param {string} actualValue - Actual value returning in the response
     * @param {boolean} ignoreCase - ignore case when evaluating the strings
     * @return {boolean}
     */
    evaluateRegexOrString(operator, expectedValue, actualValue, ignoreCase) {
        let operatorToCompare = operator;

        // localized values are loaded dynamically, so we need to double check if is a regex or not 
        if (this._originalOperator === ":") {
            // If the operator is the regex operator, or the value starts with /, we treat it as a regex
            if (this.isRegex(expectedValue)) {
                operatorToCompare = "=~";
            } else {
                operatorToCompare = "==";
            }
        }

        if (operatorToCompare === "=~") {
            return this.evaluateRegex(expectedValue, actualValue, ignoreCase);
        } else {
            return this.evaluateString(expectedValue, actualValue, ignoreCase);
        }
    }

    stringForLenientMode(value) {
        value = `${value}`;
        const toRemoveRegex = /[-\f\n\r\t\v.()"',;:!?*^$]/g;
        value = value.replace(toRemoveRegex, " ");
        value = value.replace(/  +/g, " ");
        value = value.trim();
        return value;
    }
    /**
     * Evaluates if the actual value contains the expected value
     * @param {string} expectedValue - Value defined in the assertion
     * @param {string} actualValue - Actual value returning in the response
     * @param {boolean} ignoreCase - ignore case when evaluating the strings
     * @return {boolean}
     */
    evaluateString(expectedValue, actualValue, ignoreCase) {
        // If the values are not strings, convert to a string for ease of comparison
        if (!Util.isString(expectedValue)) {
            expectedValue += "";
        }

        if (!Util.isString(actualValue)) {
            actualValue += "";
        }

        let regex = expectedValue;
        if (this._lenientMode) {
            regex = this.stringForLenientMode(regex);
            actualValue = this.stringForLenientMode(actualValue);
        } else {
            // We allow for a wild-card *
            regex = expectedValue.trim().split("*").join("(.|\\n)*");
            // Escape special values that we do NOT want to treat as a wild-card
            regex = regex.split("+").join("\\+");
            regex = regex.split("^").join("\\^");
            regex = regex.split("$").join("\\$");
            regex = regex.split("?").join("\\?");
        }

        let options = "";
        if (ignoreCase) {
            options = "i";
        }

        return new RegExp(regex, options).test(actualValue);
    }

    /**
     * Evaluates if the actual value matches the expected value regex
     * @param {string} expectedValue -  expected value regex defined in the assertion
     * @param {string} actualValue - Actual value returning in the response
     * @param {boolean} ignoreCase - ignore case when evaluating the strings
     * @return {boolean}
     */
    evaluateRegex(expectedValue, actualValue, ignoreCase) {
        let regexString = expectedValue;
        let options = "";
        if (regexString.startsWith("/")) {
            regexString = regexString.substr(1);
            // Now get the last /, and treat the part after as options
            const endIndex = regexString.lastIndexOf("/");
            if (endIndex + 1 < regexString.length) {
                options = regexString.substr(endIndex + 1);
            }
            regexString = regexString.substr(0, endIndex);
        }
        if (ignoreCase && options.indexOf("i") == -1) {
            options += "i";
        }
        try {
            const regex = new RegExp(regexString, options);
            return regex.test(actualValue);
        } catch (e) {
            return false;
        }
    }

    /**
     * Validates if the expected value is a regex
     * @param {string} expectedValue - a string expected value
     * @return {boolean}
     */
    isRegex(expectedValue) {
        return this.operator === "=~" ||
            (Util.isString(expectedValue) && expectedValue.startsWith("/"));
    }

    /**
     * Returns true if this assertion includes an exit
     * @return {boolean}
     */
    get exit() {
        return this.path === "exit";
    }

    /**
     * Returns true if this assertion includes a go to
     * @return {boolean}
     */
    get goto() {
        return Util.cleanString(this._goto);
    }

    /**
     * Returns the interaction that contains this assertion
     * @return {TestInteraction}
     */
    get interaction() {
        return this._interaction;
    }

    /**
     * Returns in which line number this assertion is located
     * @return {number}
     */
    get lineNumber() {
        return this._lineNumber;
    }

    /****
     * Set in which line number this assertion is located
     * @param {number} number - which line number this assertion is located
     */
    set lineNumber(number) {
        this._lineNumber = number;
    }

    /**
     * Returns what is the Json path that we are evaluating in the response
     * @return {string}
     */
    get path() {
        return Util.cleanString(this._path);
    }

    /**
     * Returns what is the operator that we are using to evaluate the response
     * @return {string}
     */
    get operator() {
        return Util.cleanString(this._operator);
    }

    /**
     * Returns what is the value against we are evaluating
     * @return {string | string[]}
     */
    get value() {
        if (this._localizedValue) {
            return Util.cleanValue(this._localizedValue);
        }
        return Util.cleanValue(this._value);
    }

    /**
     * Returns a list of variables to replace in the assertion
     * @return {string[]}
     */
    get variables() {
        return this._variables;
    }

    /**
     * Gets the list of variables to replace in the assertion
     * @param {string[]} variables
     */
    set variables(variables) {
        this._variables = variables;
    }

    /**
     * Returns the operator before parsing the interaction
     * @return {string}
     */
    get originalOperator() {
        return Util.cleanString(this._originalOperator);
    }

    /****
     * Set the localizedUtterance
     * @param {string} localizedUtterance
     */
    set localizedValue(localizedValue) {
        this._localizedValue = localizedValue;
    }

    /**
     * Returns what is in the value in the response for the Json path
     * @param {object} json - the response being evaluated
     * @return {string}
     */
    valueAtPath(json) {
        if (this.interaction && Util.filterExist(this.interaction.test.testSuite.filterArray(), "onValue")) {
            const result = Util.executeFilterSync(this.interaction.test.testSuite.filterArray(), "onValue", this, json);
            if (typeof result !== "undefined") {
                return result;
            }
        }
        try {
            return json ? jsonpath.value(json, this.path) : undefined;
        } catch (e) {
            console.error(e.stack); // eslint-disable-line
            return undefined;
        }
    }

    /**
     * Returns the assertion evaluation in a string with the error details if the assertion has failed
     * @param {object} json - the response being evaluated
     * @param {string} errorOnResponse - error that was generated, if present this is what we would return
     * @return {string}
     */
    toString(json, errorOnResponse) {
        if (errorOnResponse) {
            return errorOnResponse;
        }
        const testSuite = this.interaction && this.interaction.test && this.interaction.test.testSuite; 
        let jsonValue = this.valueAtPath(json);
        const localizedValue = (testSuite && testSuite.getLocalizedValue(this.value)) || this.value;
        const isLenientMode = this._lenientMode && !this.isRegex(localizedValue);
        if (isLenientMode) {
            jsonValue = this.stringForLenientMode(jsonValue);
        }
        let expectedValueString = "\t"
            + (isLenientMode ? this.stringForLenientMode(localizedValue) : localizedValue)
            + "\n";
        let operator = this.operator;
        if (Array.isArray(this.value)) {
            operator = "be one of:";
            expectedValueString = "";
            for (const value of this.value) {
                expectedValueString += "\t"
                    + (isLenientMode ? this.stringForLenientMode(value) :value)
                    + "\n";
            }
        } else if (NUMERIC_OPERATORS.includes(this.operator)) {
            operator = "be " + this.operator;
        }

        let message = "Expected value at [" + this.path + "] to " + operator + "\n"
            + expectedValueString
            + "\nReceived:\n"
            + "\t" + jsonValue + "\n";

        // If we have a line number, show it
        if (this.lineNumber) {
            message += "at " + this.interaction.test.testSuite.fileName + ":" + this.lineNumber + ":0";
        }
        return message;
    }

    /**
     * Returns the assertion part of a yaml object
     * { action: string, operator: string, value: string|string[]}
     * @return {object}
    */
    toYamlObject() {
        return {
            action: this.path,
            operator: this.originalOperator,
            value: this.value,
        };
    }
}

module.exports = Assertions;