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.'); + }); + }); +});