Skip to content

Commit

Permalink
feat(mango): synchronous validator api
Browse files Browse the repository at this point in the history
  • Loading branch information
unicornware committed May 24, 2021
1 parent e74cfbb commit 6731ef8
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 36 deletions.
6 changes: 6 additions & 0 deletions __tests__/__fixtures__/error.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @file Global Test Fixture - Error
* @module tests/fixtures/error
*/

export default new Error('Test error')
8 changes: 5 additions & 3 deletions jest.config.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
11 changes: 10 additions & 1 deletion src/interfaces/mango-validator.interface.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -16,8 +21,12 @@ import type { MangoValidatorOptions } from './mango-validator-options.interface'
export interface IMangoValidator<E extends PlainObject = PlainObject> {
readonly enabled: boolean
readonly model: ClassType<E>
readonly model_name: string
readonly tvo: Omit<MangoValidatorOptions, 'enabled'>
readonly validator: typeof transformAndValidate
readonly validatorSync: typeof transformAndValidateSync

check<V extends unknown = PlainObject>(value?: V): Promise<E | V>
checkSync<V extends unknown = PlainObject>(value?: V): E | V
handleError(error: Error | ValidationError[]): Exception
}
23 changes: 23 additions & 0 deletions src/mixins/__tests__/__fixtures__/validation-errors.fixture.ts
Original file line number Diff line number Diff line change
@@ -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[]
102 changes: 96 additions & 6 deletions src/mixins/__tests__/mango-validator.mixin.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<ICar>(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<ICar>(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)
})
})
})
102 changes: 76 additions & 26 deletions src/mixins/mango-validator.mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -39,6 +41,13 @@ export default class MangoValidator<E extends PlainObject>
*/
readonly model: ClassType<E>

/**
* @readonly
* @instance
* @property {string} model_name - Name of entity model
*/
readonly model_name: string

/**
* @readonly
* @instance
Expand All @@ -49,9 +58,16 @@ export default class MangoValidator<E extends PlainObject>
/**
* @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.
Expand All @@ -73,6 +89,7 @@ export default class MangoValidator<E extends PlainObject>

this.enabled = enabled
this.model = model
this.model_name = new this.model().constructor.name
this.tvo = merge(TVO_DEFAULTS, { transformer, validator })
}

Expand All @@ -87,7 +104,7 @@ export default class MangoValidator<E extends PlainObject>
* @template Value - Type of value being validated
*
* @param {Value} value - Data to validate
* @return {Promise<E | Value>} - Promise containing value
* @return {Promise<E | Value>} - Promise containing entity or original value
* @throws {Exception}
*/
async check<Value extends unknown = PlainObject>(
Expand All @@ -98,27 +115,60 @@ export default class MangoValidator<E extends PlainObject>
try {
return (await this.validator<E>(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 extends unknown = PlainObject>(
value: Value = {} as Value
): E | Value {
if (!this.enabled) return value

try {
return this.validatorSync<E>(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
)
}
}

0 comments on commit 6731ef8

Please sign in to comment.