@qavajs/validation
Version:
Lib that transform plain english definition to validation functions
204 lines (186 loc) • 7.12 kB
text/typescript
import { expect, Assertion, AssertionError} from 'chai';
import Ajv from 'ajv'
export class SoftAssertionError extends AssertionError {
name = 'SoftAssertionError';
}
Assertion.addMethod('notStrictEqual', function (ER) {
const obj = this._obj;
this.assert(
obj == ER,
'expected #{this} to equal #{exp}',
'expected #{this} to not equal #{exp}',
ER,
obj
);
});
Assertion.addMethod('caseInsensitiveEqual', function (ER) {
const obj = this._obj;
this.assert(
obj.toLowerCase() == ER.toLowerCase(),
'expected #{this} to equal #{exp}',
'expected #{this} to not equal #{exp}',
ER,
obj
);
});
Assertion.addMethod('matchSchema', function (schema) {
const obj = this._obj;
const ajv = new Ajv();
const validate = ajv.compile(schema);
const isValid = validate(obj);
const messages = validate.errors
? validate.errors?.map(err => `${err.instancePath} ${err.message} (${err.schemaPath})`)
: [];
const errors = [
'object does not match schema',
...messages
].join('\n');
this.assert(
isValid,
errors,
'expected #{this} to not match schema #{exp}',
'',
''
);
});
export const validations = {
EQUAL: 'equal',
DEEPLY_EQUAL: 'deeply equal',
STRICTLY_EQUAL: 'strictly equal',
HAVE_MEMBERS: 'have member',
MATCH: 'match',
CONTAIN: 'contain',
ABOVE: 'above',
BELOW: 'below',
GREATER: 'greater than',
LESS: 'less than',
HAVE_TYPE: 'have type',
INCLUDE_MEMBERS: 'include member',
HAVE_PROPERTY: 'have property',
MATCH_SCHEMA: 'match schema',
CASE_INSENSITIVE_EQUAL: 'case insensitive equal',
};
const isClause = '(?:is |do |does |to )?';
const notClause = '(?<reverse>not |to not )?';
const toBeClause = '(?:to )?(?:be )?';
const softlyClause = '(?<soft>softly )?';
const validationClause = `(?:(?<validation>${Object.values(validations).join('|')})(?:s|es| to)?)`;
export const validationExtractRegexp = new RegExp(`^${isClause}${notClause}${toBeClause}${softlyClause}${validationClause}$`);
export const validationRegexp = new RegExp(`(${isClause}${notClause}${toBeClause}${softlyClause}${validationClause})`);
type VerifyInput = {
AR: any;
ER: any;
validation: string;
reverse: boolean;
soft: boolean;
};
const aboveFn = (expectClause: any, ER: any) => expectClause.above(toNumber(ER));
const belowFn = (expectClause: any, ER: any) => expectClause.below(toNumber(ER));
const validationFns = {
[validations.EQUAL]: (expectClause: any, ER: any) => expectClause.notStrictEqual(ER),
[validations.STRICTLY_EQUAL]: (expectClause: any, ER: any) => expectClause.equal(ER),
[validations.DEEPLY_EQUAL]: (expectClause: any, ER: any) => expectClause.eql(ER),
[validations.HAVE_MEMBERS]: (expectClause: any, ER: any) => expectClause.have.members(ER),
[validations.MATCH]: (expectClause: any, ER: any) => expectClause.match(toRegexp(ER)),
[validations.CONTAIN]: (expectClause: any, ER: any) => expectClause.contain(ER),
[validations.ABOVE]: aboveFn,
[validations.BELOW]: belowFn,
[validations.GREATER]: aboveFn,
[validations.LESS]: belowFn,
[validations.HAVE_TYPE]: (expectClause: any, ER: string) => expectClause.a(ER),
[validations.INCLUDE_MEMBERS]: (expectClause: any, ER: string) => expectClause.include.members(ER),
[validations.HAVE_PROPERTY]: (expectClause: any, ER: string) => expectClause.have.property(ER),
[validations.MATCH_SCHEMA]: (expectClause: any, ER: string) => expectClause.matchSchema(ER),
[validations.CASE_INSENSITIVE_EQUAL]: (expectClause: any, ER: any) => expectClause.caseInsensitiveEqual(ER),
};
/**
* Basic verification function
* @param {VerifyInput} object with all needed data for validation
*/
export function verify({ AR, ER, validation, reverse, soft }: VerifyInput): void {
const prefix = 'Fail';
const expectClause = reverse ? expect(AR, prefix).to.not : expect(AR, prefix).to;
const validate = validationFns[validation];
try {
validate(expectClause, ER);
} catch (err) {
if (soft && err instanceof Error) throw new SoftAssertionError(err.message, { cause: err });
throw err;
}
}
export function getValidation(validationType: string, options?: { soft: boolean }): (AR: any, ER: any) => void {
const match = validationExtractRegexp.exec(validationType);
if (!match) throw new Error(`Validation '${validationType}' is not supported`);
const { reverse, validation, soft } = match.groups as {[p: string]: string};
const softProp = options?.soft || !!soft;
return function (AR: any, ER: any) {
verify({ AR, ER, validation, reverse: Boolean(reverse), soft: softProp });
};
}
export function getPollValidation(validationType: string, options?: { soft: boolean }): (AR: any, ER: any, options?: { timeout?: number, interval?: number }) => Promise<unknown> {
const match = validationExtractRegexp.exec(validationType);
if (!match) throw new Error(`Poll validation '${validationType}' is not supported`);
const { reverse, validation, soft } = match.groups as {[p: string]: string};
const softProp = options?.soft || !!soft;
return async function (AR: any, ER: any, options?: { timeout?: number, interval?: number }) {
const timeout = options?.timeout ?? 5000;
const interval = options?.interval ?? 500;
let lastError: Error = new Error(`Promise was not settled before timeout`);
let intervalId: NodeJS.Timeout;
const evaluatePromise = new Promise<void>(resolve => {
intervalId = setInterval(async () => {
try {
const actualValue = await AR();
verify({
AR: actualValue,
ER,
validation,
reverse: Boolean(reverse),
soft: softProp
});
clearInterval(intervalId);
resolve();
} catch (err: any) {
lastError = err;
}
}, interval);
});
const timeoutPromise = new Promise((_, reject) => setTimeout(() => {
clearInterval(intervalId);
reject(lastError)
}, timeout));
return Promise.race([evaluatePromise, timeoutPromise]);
};
}
export async function poll(fn: Function, options?: { timeout?: number, interval?: number }) {
const timeout = options?.timeout ?? 5000;
const interval = options?.interval ?? 500;
let lastError: Error = new Error('Unexpected error');
let intervalId: NodeJS.Timeout;
const evaluatePromise = new Promise<void>(resolve => {
intervalId = setInterval(async () => {
try {
await fn();
clearInterval(intervalId);
resolve();
} catch (err: any) {
lastError = err;
}
}, interval);
});
const timeoutPromise = new Promise((_, reject) => setTimeout(() => {
clearInterval(intervalId);
reject(lastError);
}, timeout));
return Promise.race([evaluatePromise, timeoutPromise]);
}
function toNumber(n: any): number {
const parsedNumber = parseFloat(n);
if (Number.isNaN(parsedNumber)) {
throw new Error(`${n} is not a number`);
}
return parsedNumber;
}
function toRegexp(r: string | RegExp): RegExp {
return r instanceof RegExp ? r : new RegExp(r);
}