From 6731ef8a6e87c1815ad184e48ca16916f8c58c36 Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Sun, 23 May 2021 20:18:30 -0400 Subject: [PATCH] feat(mango): synchronous validator api --- __tests__/__fixtures__/error.fixture.ts | 6 ++ jest.config.base.ts | 8 +- src/interfaces/mango-validator.interface.ts | 11 +- .../__fixtures__/validation-errors.fixture.ts | 23 ++++ .../__tests__/mango-validator.mixin.spec.ts | 102 ++++++++++++++++-- src/mixins/mango-validator.mixin.ts | 102 +++++++++++++----- 6 files changed, 216 insertions(+), 36 deletions(-) create mode 100644 __tests__/__fixtures__/error.fixture.ts create mode 100644 src/mixins/__tests__/__fixtures__/validation-errors.fixture.ts diff --git a/__tests__/__fixtures__/error.fixture.ts b/__tests__/__fixtures__/error.fixture.ts new file mode 100644 index 0000000..b7c1676 --- /dev/null +++ b/__tests__/__fixtures__/error.fixture.ts @@ -0,0 +1,6 @@ +/** + * @file Global Test Fixture - Error + * @module tests/fixtures/error + */ + +export default new Error('Test error') diff --git a/jest.config.base.ts b/jest.config.base.ts index 7b5c557..54b4301 100644 --- a/jest.config.base.ts +++ b/jest.config.base.ts @@ -24,10 +24,12 @@ const config: Config.InitialOptions = { moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix }), prettierPath: `${prefix}node_modules/prettier`, setupFilesAfterEnv: [`${prefix}__tests__/setup.ts`], - testPathIgnorePatterns: [ - `${prefix}__tests__`, - `${prefix}src/*/*/__fixtures__` + testMatch: [ + '**/__tests__/**/*.ts?(x)', + '**/?(*.)+spec.ts?(x)', + '!**/__fixtures__/**' ], + testPathIgnorePatterns: [`${prefix}__tests__`], verbose: true } diff --git a/src/interfaces/mango-validator.interface.ts b/src/interfaces/mango-validator.interface.ts index 788a4b9..24292c7 100644 --- a/src/interfaces/mango-validator.interface.ts +++ b/src/interfaces/mango-validator.interface.ts @@ -1,6 +1,11 @@ +import Exception from '@flex-development/exceptions/exceptions/base.exception' import type { PlainObject } from '@flex-development/tutils' import type { ClassType } from 'class-transformer-validator' -import { transformAndValidate } from 'class-transformer-validator' +import { + transformAndValidate, + transformAndValidateSync +} from 'class-transformer-validator' +import type { ValidationError } from 'class-validator' import type { MangoValidatorOptions } from './mango-validator-options.interface' /** @@ -16,8 +21,12 @@ import type { MangoValidatorOptions } from './mango-validator-options.interface' export interface IMangoValidator { readonly enabled: boolean readonly model: ClassType + readonly model_name: string readonly tvo: Omit readonly validator: typeof transformAndValidate + readonly validatorSync: typeof transformAndValidateSync check(value?: V): Promise + checkSync(value?: V): E | V + handleError(error: Error | ValidationError[]): Exception } diff --git a/src/mixins/__tests__/__fixtures__/validation-errors.fixture.ts b/src/mixins/__tests__/__fixtures__/validation-errors.fixture.ts new file mode 100644 index 0000000..87e2bea --- /dev/null +++ b/src/mixins/__tests__/__fixtures__/validation-errors.fixture.ts @@ -0,0 +1,23 @@ +import type { ValidationError } from 'class-validator' + +/** + * @file Test Fixture - ValidationError[] + * @module mixins/tests/fixtures/validation-errors.fixture + */ + +export default [ + { + constraints: { + length: '$property must be longer than or equal to 10 characters' + }, + property: 'title', + value: 'Hello' + }, + { + constraints: { + contains: 'text must contain a hello string' + }, + property: 'text', + value: 'this is a great post about hell world' + } +] as ValidationError[] diff --git a/src/mixins/__tests__/mango-validator.mixin.spec.ts b/src/mixins/__tests__/mango-validator.mixin.spec.ts index 122a9d0..96167c3 100644 --- a/src/mixins/__tests__/mango-validator.mixin.spec.ts +++ b/src/mixins/__tests__/mango-validator.mixin.spec.ts @@ -1,8 +1,9 @@ import { ExceptionStatusCode } from '@flex-development/exceptions/enums' -import Exception from '@flex-development/exceptions/exceptions/base.exception' import type { ICar } from '@tests/fixtures/cars.fixture' import { Car, CARS_MOCK_CACHE } from '@tests/fixtures/cars.fixture' +import ERROR from '@tests/fixtures/error.fixture' import TestSubject from '../mango-validator.mixin' +import VALIDATION_ERRORS from './__fixtures__/validation-errors.fixture' /** * @file Unit Tests - MangoValidator @@ -16,41 +17,130 @@ describe('unit:mixins/MangoValidator', () => { describe('#check', () => { it('should validate value if validation is enabled', async () => { + // Arrange const spy_validator = jest.spyOn(Subject, 'validator') + // Act await Subject.check(ENTITY) expect(spy_validator).toBeCalledTimes(1) }) it('should not validate value if validation is disabled', async () => { + // Arrange const Subject = new TestSubject(Car, { enabled: false }) const spy_validator = jest.spyOn(Subject, 'validator') + // Act await Subject.check(ENTITY) + // Expect expect(spy_validator).toBeCalledTimes(0) }) it('should return value if validation passes', async () => { + // Arrange + Act const value = await Subject.check(ENTITY) + // Expect expect(value).toMatchObject(ENTITY) }) - it('should throw Exception if validation fails', async () => { - let exception = {} as Exception + it('should call #handleError if validation fails', async () => { + // Arrange + const spy_handleError = jest.spyOn(Subject, 'handleError') + // Act try { await Subject.check({}) } catch (error) { - exception = error + // let error fall through } + // Expect + expect(spy_handleError).toBeCalledTimes(1) + }) + }) + + describe('#checkSync', () => { + it('should validate value if validation is enabled', () => { + // Arrange + const spy_validatorSync = jest.spyOn(Subject, 'validatorSync') + + // Act + Subject.checkSync(ENTITY) + + // Expect + expect(spy_validatorSync).toBeCalledTimes(1) + }) + + it('should not validate value if validation is disabled', () => { + // Arrange + const Subject = new TestSubject(Car, { enabled: false }) + const spy_validatorSync = jest.spyOn(Subject, 'validatorSync') + + // Act + Subject.checkSync(ENTITY) + + // Expect + expect(spy_validatorSync).toBeCalledTimes(0) + }) + + it('should return value if validation passes', () => { + // Arrange + Act + const value = Subject.checkSync(ENTITY) + + // Expect + expect(value).toMatchObject(ENTITY) + }) + + it('should call #handleError if validation fails', () => { + // Arrange + const spy_handleError = jest.spyOn(Subject, 'handleError') + + // Act + try { + Subject.checkSync({}) + } catch (error) { + // let error fall through + } + + // Expect + expect(spy_handleError).toBeCalledTimes(1) + }) + }) + + describe('#handleError', () => { + it('should convert Error into Exception', () => { + // Act + const exception = Subject.handleError(ERROR) + + // Expect + expect(exception.code).toBe(ExceptionStatusCode.INTERNAL_SERVER_ERROR) + expect(exception.message).toBe(ERROR.message) + expect(exception.data).toMatchObject({ + model_name: Subject.model_name, + options: Subject.tvo + }) + expect(exception.errors).toBeNull() + }) + + it('should convert ValidationError[] into Exception', () => { + // Arrange + const mpattern = `${Subject.model_name} entity validation failure:` + + // Act + const exception = Subject.handleError(VALIDATION_ERRORS) + + // Expect expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) - expect(exception.data).toMatchObject({ options: Subject.tvo }) + expect(exception.message).toMatch(new RegExp(mpattern)) + expect(exception.data).toMatchObject({ + model_name: Subject.model_name, + options: Subject.tvo + }) expect(exception.errors).toBeArray() - expect(exception.message).toMatch(/entity validation failure/) + expect(exception.errors).toIncludeSameMembers(VALIDATION_ERRORS) }) }) }) diff --git a/src/mixins/mango-validator.mixin.ts b/src/mixins/mango-validator.mixin.ts index 149cedd..f5bc9e7 100644 --- a/src/mixins/mango-validator.mixin.ts +++ b/src/mixins/mango-validator.mixin.ts @@ -4,9 +4,11 @@ import { ExceptionStatusCode } from '@flex-development/exceptions/enums' import Exception from '@flex-development/exceptions/exceptions/base.exception' import type { PlainObject } from '@flex-development/tutils' import type { ClassTransformOptions as TransformOpts } from 'class-transformer' -import { plainToClass } from 'class-transformer' import type { ClassType } from 'class-transformer-validator' -import { transformAndValidate } from 'class-transformer-validator' +import { + transformAndValidate as tv, + transformAndValidateSync as tvSync +} from 'class-transformer-validator' import type { ValidationError, ValidatorOptions } from 'class-validator' import merge from 'lodash.merge' @@ -39,6 +41,13 @@ export default class MangoValidator */ readonly model: ClassType + /** + * @readonly + * @instance + * @property {string} model_name - Name of entity model + */ + readonly model_name: string + /** * @readonly * @instance @@ -49,9 +58,16 @@ export default class MangoValidator /** * @readonly * @instance - * @property {typeof transformAndValidate} tvo - Validation function + * @property {typeof tv} validator - Async validation function */ - readonly validator: typeof transformAndValidate = transformAndValidate + readonly validator: typeof tv = tv + + /** + * @readonly + * @instance + * @property {typeof tvSync} validatorSync - Synchronous validation function + */ + readonly validatorSync: typeof tvSync = tvSync /** * Creates a new repository validator. @@ -73,6 +89,7 @@ export default class MangoValidator this.enabled = enabled this.model = model + this.model_name = new this.model().constructor.name this.tvo = merge(TVO_DEFAULTS, { transformer, validator }) } @@ -87,7 +104,7 @@ export default class MangoValidator * @template Value - Type of value being validated * * @param {Value} value - Data to validate - * @return {Promise} - Promise containing value + * @return {Promise} - Promise containing entity or original value * @throws {Exception} */ async check( @@ -98,27 +115,60 @@ export default class MangoValidator try { return (await this.validator(this.model, value as any, this.tvo)) as E } catch (error) { - let code = ExceptionStatusCode.INTERNAL_SERVER_ERROR - let data = {} - let message = error.message - - if (Array.isArray(error)) { - const errors = error as ValidationError[] - const properties = errors.map(e => e.property) - const target = plainToClass(this.model, value, this.tvo.transformer) - const target_name = target.constructor.name - - code = ExceptionStatusCode.BAD_REQUEST - data = { errors, target } - message = `${target_name} entity validation failure: [${properties}]` - } - - throw new Exception( - code, - message, - merge(data, { options: this.tvo }), - error.stack - ) + throw this.handleError(error) + } + } + + /** + * Synchronous version of {@see MangoValidator#check}. + * + * @template Value - Type of value being validated + * + * @param {Value} value - Data to validate + * @return {E | Value} - Entity or original value + * @throws {Exception} + */ + checkSync( + value: Value = {} as Value + ): E | Value { + if (!this.enabled) return value + + try { + return this.validatorSync(this.model, value as any, this.tvo) as E + } catch (error) { + throw this.handleError(error) } } + + /** + * Converts an error into an `Exception`. + * + * @param {Error | ValidationError[]} error - Error to convert + * @return {Exception} New exception + */ + handleError(error: Error | ValidationError[]): Exception { + let code = ExceptionStatusCode.INTERNAL_SERVER_ERROR + let data = { model_name: this.model_name } + let message = '' + let stack: string | undefined = undefined + + if (Array.isArray(error)) { + const errors = error as ValidationError[] + const properties = errors.map(e => e.property) + + code = ExceptionStatusCode.BAD_REQUEST + data = merge(data, { errors }) + message = `${this.model_name} entity validation failure: [${properties}]` + } else { + message = error.message + stack = error.stack + } + + return new Exception( + code, + message, + merge(data, { options: this.tvo }), + stack + ) + } }