From 30373db84e1c1b3fa292bd8256f392be7be8b5ea Mon Sep 17 00:00:00 2001 From: mahdi Date: Tue, 21 Jan 2025 17:37:21 +0330 Subject: [PATCH] test(valitor): Added tests for validator process to ensure correct functionality under various conditions such as schema validation passing with no errors when all rules are met handling errors when fields fail validation skipping validation for schemas already validated by conditions throwing errors when conflicting schema options are used like pick and omit applied validation on fields with schema options respecting pick option skipping validation for omitted fields handling nested schema validation correctly validating fields with conditional dependencies between schemas testing handling of empty or undefined data and validating complex patterns in rules these tests cover different scenarios of validation like required rules field validation with pick and omit options skipping validated schemas validating nested schemas and ensuring error messages are correctly returned for failing rules --- test/unit/validator/FieldValidator.spec.ts | 201 +++++++++++++++++++++ test/unit/validator/RuleAction.spec.ts | 140 ++++++++++++++ test/unit/validator/validator.spec.ts | 87 +++++++++ 3 files changed, 428 insertions(+) create mode 100644 test/unit/validator/FieldValidator.spec.ts create mode 100644 test/unit/validator/RuleAction.spec.ts create mode 100644 test/unit/validator/validator.spec.ts diff --git a/test/unit/validator/FieldValidator.spec.ts b/test/unit/validator/FieldValidator.spec.ts new file mode 100644 index 0000000..c8ffa85 --- /dev/null +++ b/test/unit/validator/FieldValidator.spec.ts @@ -0,0 +1,201 @@ +import { describe, it, beforeEach, afterEach, after } from 'mocha'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { RuleAction } from '../../../dist/validator/RuleAction'; +import { FieldValidator } from '../../../dist/validator/FieldValidator'; +import { DefaultMessages } from '../../../dist/validator/config'; +import { ValidationField } from '../../../dist/common/interfaces'; + +describe('FieldValidator.validateField', () => { + let ruleActionStub: sinon.SinonStub; + + beforeEach(() => { + ruleActionStub = sinon.stub(RuleAction, 'applyRule'); + }); + + afterEach(() => { + ruleActionStub.restore(); + ruleActionStub.reset(); + }); + after(() => { + sinon.restore(); + }); + + // General use case: Validates a simple required field + it('should return no errors when all rules pass', async () => { + ruleActionStub.returns(true); + + const result = await FieldValidator.validateField('validValue', { rules: ['required'], options: {} }, 'testField', false, {}); + + expect(result).to.be.empty; + }); + + // General use case: Fails on a single rule + it('should return an error when a rule fails', async () => { + ruleActionStub.withArgs({ ruleName: 'required' }, undefined).returns(false); + + const result = await FieldValidator.validateField(undefined, { rules: ['required'], options: {} }, 'testField', false, {}); + + expect(result).to.deep.equal(['The testField field is required and cannot be left blank.']); + }); + + // Edge case: Inherits default rules + it('should apply inherited default rules', async () => { + ruleActionStub.withArgs({ ruleName: 'min', param: '5' }, '123').returns(false); + + const result = await FieldValidator.validateField( + '123', + { rules: ['inherit'], options: {} }, + 'testField', + false, + {}, + ['min:5'], // Default rules + ); + + expect(result).to.deep.equal([DefaultMessages.min?.replace('{value}', '5').replace('{field}', 'testField')]); + }); + + // Unique case: Validates custom function + it('should return an error when custom validation fails', async () => { + const customStub = sinon.stub().resolves(false); + + const result = await FieldValidator.validateField('value', { rules: [], options: { custom: customStub }, messages: { custom: 'Custom error message' } }, 'testField', false, {}); + + expect(result).to.deep.equal(['Custom error message']); + sinon.assert.calledOnce(customStub); + }); + + // General use case: Validates multiple rules + it('should validate multiple rules and return all errors when `returnFirstError` is false', async () => { + ruleActionStub.withArgs({ ruleName: 'required' }, '').returns(false); + ruleActionStub.withArgs({ ruleName: 'min', param: '5' }, '').returns(false); + + const result = await FieldValidator.validateField('', { rules: ['required', 'min:5'], options: {} }, 'testField', false, {}); + + expect(result).to.deep.equal([DefaultMessages.required?.replace('{field}', 'testField'), DefaultMessages.min?.replace('{value}', '5')?.replace('{field}', 'testField')]); + }); + + // Edge case: Stops validation on the first error + it('should stop on the first error when `returnFirstError` is true', async () => { + ruleActionStub.withArgs({ ruleName: 'required' }, '').returns(false); + + const result = await FieldValidator.validateField('', { rules: ['required', 'min:5'], options: {} }, 'testField', true, {}); + + expect(result).to.deep.equal([DefaultMessages.required?.replace('{field}', 'testField')]); + }); + + describe('FieldValidator', () => { + describe('General Use Cases', () => { + it('should return an error for missing required value', async () => { + const value = ''; + const fieldRules: ValidationField = { rules: ['required'] }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, {}); + expect(result).to.have.lengthOf(1); + expect(result[0]).to.equal(DefaultMessages.required?.replace('{field}', 'testField')); + }); + + it('should return an error for values outside min or max range', async () => { + const value = 3; + const fieldRules: ValidationField = { rules: ['min:5'] }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, {}); + expect(result).to.have.lengthOf(1); + expect(result[0]).to.equal(DefaultMessages.min?.replace('{field}', 'testField').replace('{value}', '5')); + }); + }); + + it('should handle empty rules gracefully', async () => { + const value = 'test'; + const fieldRules: ValidationField = { rules: [] }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, {}); + expect(result).to.be.empty; + }); + + it('should validate custom rules asynchronously', async () => { + const value = 'test'; + const fieldRules: ValidationField = { + rules: [], + options: { + custom: async (val) => val === 'test', + }, + }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, {}); + expect(result).to.be.empty; + }); + + it('should return an error for failed custom validation', async () => { + const value = 'invalid'; + const fieldRules: ValidationField = { + rules: [], + options: { + custom: async (val) => val === 'test', + }, + messages: { + custom: 'Custom validation failed for {field}.', + }, + }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, {}); + expect(result).to.have.lengthOf(1); + expect(result[0]).to.equal('Custom validation failed for testField.'); + }); + + it('should handle dependent field validation', async () => { + const value = 'child'; + const fieldRules: ValidationField = { + rules: [], + options: { + dependsOn: { + field: 'parentField', + operator: 'equal', + value: 'parent', + }, + }, + messages: { + dependsOn: '{field} requires {dependentField} to have a specific value.', + }, + }; + const allData = { parentField: 'parent' }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, allData); + expect(result).to.be.empty; + }); + + it('should return an error for failed dependent field validation', async () => { + const value = 'child'; + const fieldRules: ValidationField = { + rules: [], + options: { + dependsOn: { + field: 'parentField', + operator: 'equal', + value: 'parent', + }, + }, + messages: { + dependsOn: '{field} requires {dependentField} to have a specific value.', + }, + }; + const allData = { parentField: 'differentValue' }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, allData); + expect(result).to.have.lengthOf(1); + expect(result[0]).to.equal('testField requires parentField to have a specific value.'); + }); + + describe('Return First Error Mode', () => { + it('should return only the first error when returnFirstError is true', async () => { + const value = ''; + const fieldRules: ValidationField = { rules: ['required', 'string'] }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', true, {}); + expect(result).to.have.lengthOf(1); + expect(result[0]).to.equal(DefaultMessages.required?.replace('{field}', 'testField')); + }); + + it('should return all errors when returnFirstError is false', async () => { + const value = ''; + const fieldRules: ValidationField = { rules: ['required', 'string'] }; + const result = await FieldValidator.validateField(value, fieldRules, 'testField', false, {}); + expect(result).to.have.lengthOf(2); + expect(result[0]).to.equal(DefaultMessages.required?.replace('{field}', 'testField')); + expect(result[1]).to.equal(DefaultMessages.string?.replace('{field}', 'testField')); + }); + }); + }); +}); diff --git a/test/unit/validator/RuleAction.spec.ts b/test/unit/validator/RuleAction.spec.ts new file mode 100644 index 0000000..c4989c8 --- /dev/null +++ b/test/unit/validator/RuleAction.spec.ts @@ -0,0 +1,140 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import 'mocha'; +import { RuleAction } from '../../../dist/validator/RuleAction'; +import { describe, it } from 'mocha'; +import Reflector from '../../../dist/metadata/'; +import { createMockReflector } from '../../mocks/reflector.mock'; + +describe('RuleAction', () => { + describe('applyRule', () => { + it('should validate "required" rule correctly', () => { + expect(RuleAction.applyRule({ ruleName: 'required', param: '' }, 'value')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'required', param: '' }, null)).to.be.false; + expect(RuleAction.applyRule({ ruleName: 'required', param: '' }, undefined)).to.be.false; + expect(RuleAction.applyRule({ ruleName: 'required', param: '' }, '')).to.be.false; + }); + + it('should validate "string" rule correctly', () => { + expect(RuleAction.applyRule({ ruleName: 'string', param: '' }, 'test')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'string', param: '' }, 123)).to.be.false; + expect(RuleAction.applyRule({ ruleName: 'string', param: '' }, null)).to.be.false; + }); + + it('should validate "integer" rule correctly', () => { + expect(RuleAction.applyRule({ ruleName: 'integer', param: '' }, 42)).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'integer', param: '' }, 42.5)).to.be.false; + expect(RuleAction.applyRule({ ruleName: 'integer', param: '' }, '42')).to.be.false; + }); + + it('should validate "email" rule correctly', () => { + expect(RuleAction.applyRule({ ruleName: 'email', param: '' }, 'test@example.com')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'email', param: '' }, 'test.com')).to.be.false; + expect(RuleAction.applyRule({ ruleName: 'email', param: '' }, '')).to.be.false; + }); + + it('should validate "min" and "max" rules correctly', () => { + expect(RuleAction.applyRule({ ruleName: 'min', param: '5' }, 'hello')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'min', param: '5' }, 'hi')).to.be.false; + expect(RuleAction.applyRule({ ruleName: 'max', param: '5' }, 'hello')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'max', param: '5' }, 'hello world')).to.be.false; + }); + + it('should validate "regex" rule correctly', () => { + expect(RuleAction.applyRule({ ruleName: 'regex', param: '^test$' }, 'test')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'regex', param: '^test$' }, 'fail')).to.be.false; + }); + + it('should validate "url" rule correctly', () => { + expect(RuleAction.applyRule({ ruleName: 'url', param: '' }, 'http://example.com')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'url', param: '' }, 'invalid-url')).to.be.false; + }); + }); + + describe('applyDependencyRule', () => { + it('should validate "equal" dependency correctly', () => { + expect(RuleAction.applyDependencyRule({ operator: 'equal', dependencyValue: 'test' }, 'test')).to.be.true; + expect(RuleAction.applyDependencyRule({ operator: 'equal', dependencyValue: 'test' }, 'fail')).to.be.false; + }); + + it('should validate "greaterThan" dependency correctly', () => { + expect(RuleAction.applyDependencyRule({ operator: 'greaterThan', dependencyValue: 10 }, 15)).to.be.true; + expect(RuleAction.applyDependencyRule({ operator: 'greaterThan', dependencyValue: 10 }, 5)).to.be.false; + }); + + it('should validate "exists" dependency correctly', () => { + expect(RuleAction.applyDependencyRule({ operator: 'exists' }, 'value')).to.be.true; + expect(RuleAction.applyDependencyRule({ operator: 'exists' }, undefined)).to.be.false; + }); + }); + + describe('filter', () => { + let mockReflector: ReturnType['mockReflector']; + let restoreReflector: ReturnType['restore']; + + beforeEach(() => { + const mock = createMockReflector(); + mockReflector = mock.mockReflector; + restoreReflector = mock.restore; + }); + afterEach(() => { + sinon.restore(); + restoreReflector(); + }); + it('should filter rules by pick option', () => { + const mockRules = { + username: { rules: 'string|min:5|max:20' }, + email: { rules: 'string|email' }, + age: { rules: 'integer|min:18' }, + }; + + (Reflector.get as any).withArgs('validation:rules', sinon.match.any).returns(mockRules); + + class MockSchema {} + + const result = RuleAction.filter(MockSchema, ['username', 'email']); + + expect(result).to.have.keys(['username', 'email']); + expect(result).to.not.have.key('age'); + }); + + it('should filter rules by omit option', () => { + const mockRules = { + username: { rules: 'string|min:5|max:20' }, + email: { rules: 'string|email' }, + age: { rules: 'integer|min:18' }, + }; + (Reflector.get as any).withArgs('validation:rules', sinon.match.any).returns(mockRules); + const result = RuleAction.filter(class MockSchema {}, undefined, ['age']); + expect(result).to.have.keys(['username', 'email']); + }); + + it('should return all rules if no filter options are provided', () => { + const mockRules = { + username: { rules: 'string|min:5|max:20' }, + email: { rules: 'string|email' }, + age: { rules: 'integer|min:18' }, + }; + (Reflector.get as any).withArgs('validation:rules', sinon.match.any).returns(mockRules); + const result = RuleAction.filter(class MockSchema {}, undefined, undefined); + expect(result).to.have.keys(['username', 'email', 'age']); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty string with "min" rule', () => { + expect(RuleAction.applyRule({ ruleName: 'min', param: '0' }, '')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'min', param: '1' }, '')).to.be.false; + }); + + it('should handle null or undefined for rules', () => { + expect(RuleAction.applyRule({ ruleName: 'required', param: '' }, null)).to.be.false; + expect(RuleAction.applyRule({ ruleName: 'required', param: '' }, undefined)).to.be.false; + }); + + it('should validate "regex" rule with complex patterns', () => { + expect(RuleAction.applyRule({ ruleName: 'regex', param: '^[a-z]{3,5}$' }, 'test')).to.be.true; + expect(RuleAction.applyRule({ ruleName: 'regex', param: '^[a-z]{3,5}$' }, 'toolong')).to.be.false; + }); + }); +}); diff --git a/test/unit/validator/validator.spec.ts b/test/unit/validator/validator.spec.ts new file mode 100644 index 0000000..5a64bb4 --- /dev/null +++ b/test/unit/validator/validator.spec.ts @@ -0,0 +1,87 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { Constructor } from '../../../dist/common/interfaces'; +import { ValidationMetadataKey } from '../../../dist/common/enums'; +import { ValidatorProcess } from '../../../dist/validator/Validator'; +import { createMockReflector } from '../../mocks/reflector.mock'; +import { FieldValidator } from '../../../lib/validator/FieldValidator'; +describe('ValidatorProcess', () => { + let schemaMock: Constructor; + let nestedSchemasMock: Record; + let mockReflector: ReturnType['mockReflector']; + let restoreReflector: ReturnType['restore']; + let dataMock: Record>; + beforeEach(() => { + schemaMock = class {}; + dataMock = { body: { field1: 'value1', field2: 'value2' } }; + + nestedSchemasMock = { + schema1: { + schemaClass: schemaMock, + options: { conditions: [] }, + }, + }; + const mock = createMockReflector(); + mockReflector = mock.mockReflector; + restoreReflector = mock.restore; + mockReflector.get.callsFake((key: any, target: any) => { + if (key === ValidationMetadataKey.NESTED) return nestedSchemasMock; + if (key === ValidationMetadataKey.RULES) return { field1: { rules: ['required'] } }; + if (key === ValidationMetadataKey.SCHEMA) return { section: 'body', defaultRules: ['required'] }; + return undefined; + }); + sinon.stub(FieldValidator, 'validateField').callsFake((fieldName, value, rules) => { + if (fieldName === 'field1' && rules.includes('required') && typeof value === 'string' && value !== '') { + return Promise.resolve([]); + } + return Promise.resolve([`${fieldName} is invalid`]); + }); + }); + afterEach(() => { + sinon.restore(); + restoreReflector(); + }); + + describe('validate', () => { + it('should validate a schema and return no errors if all validations pass', async () => { + const errors = await ValidatorProcess.validate(schemaMock, dataMock); + expect(errors).to.be.empty; + }); + + it('should return errors if a field fails validation', async () => { + const errors = await ValidatorProcess.validate(schemaMock, { field1: '' }); + expect(errors).to.have.property('body').that.includes.keys('field1'); + expect(errors.body.field1[0]).to.equal('The field1 field is required and cannot be left blank.'); + }); + it('should skip validation for schemas already validated by conditions', async () => { + sinon.stub(ValidatorProcess as any, 'validateConditions').resolves(new Set([schemaMock])); + const validateFieldsForSchemasSpy = sinon.spy(ValidatorProcess as any, 'validateFieldsForSchemas'); + + const errors = await ValidatorProcess.validate(schemaMock, dataMock); + + expect(validateFieldsForSchemasSpy.called).to.be.true; + expect(errors).to.be.empty; + }); + it('should throw an error if schema options contain both pick and omit', async () => { + nestedSchemasMock.schema1.options = { pick: ['field1'], omit: ['field2'] }; + const mock = createMockReflector(); + const mockReflector = mock.mockReflector; + mockReflector.get.withArgs(ValidationMetadataKey.NESTED, schemaMock).returns(nestedSchemasMock); + try { + await ValidatorProcess.validate(schemaMock, dataMock); + expect.fail('Expected error was not thrown'); + } catch (error: any) { + mock.restore(); + expect(error.message).to.equal("Cannot use both 'pick' and 'omit' options together. Please choose one or the other."); + } + }); + + it('should validate fields with schema options applied', async () => { + nestedSchemasMock.schema1.options = { pick: ['field1'] }; + + const errors = await ValidatorProcess.validate(schemaMock, { field1: '', field2: 'value2' }); + expect(errors).to.have.property('body').that.includes.keys('field1'); + expect(errors.body.field1[0]).to.equal('The field1 field is required and cannot be left blank.'); + }); + }); +});