UNPKG

n4s

Version:

typed schema validation version of enforce

585 lines (504 loc) 15.8 kB
import { describe, it, expect, beforeEach } from 'vitest'; import { compose, enforce } from '../n4s'; describe('compose() - Rule Composition', () => { describe('Basic composition', () => { it('Should create AND relationship between rules', () => { const NumberAboveTen = compose( enforce.isNumber(), enforce.isNumber().greaterThan(10), ); expect(() => NumberAboveTen(5)).toThrow(); expect(() => NumberAboveTen('11')).toThrow(); expect(() => NumberAboveTen(10)).toThrow(); expect(() => NumberAboveTen(11)).not.toThrow(); }); it('Should fail if any composed rule fails', () => { const StringLongerThanFive = compose( enforce.isString(), enforce.isString().longerThan(5), ); expect(() => StringLongerThanFive(123)).toThrow(); // Not a string expect(() => StringLongerThanFive('hi')).toThrow(); // Too short expect(() => StringLongerThanFive('hello world')).not.toThrow(); }); it('Should pass if all composed rules pass', () => { const ValidEmail = compose( enforce.isString(), enforce.isString().matches(/@/), enforce.isString().longerThan(5), ); expect(() => ValidEmail('user@example.com')).not.toThrow(); }); }); describe('Lazy evaluation', () => { it('Should support .run() method', () => { const NumericStringBetweenThreeAndFive = compose( enforce.isNumeric(), enforce.isString(), enforce.isNumeric().greaterThan(3), enforce.isNumeric().lessThan(5), ); expect(NumericStringBetweenThreeAndFive.run('4').pass).toBe(true); expect(NumericStringBetweenThreeAndFive.run('3').pass).toBe(false); expect(NumericStringBetweenThreeAndFive.run(5).pass).toBe(false); }); it('Should support .test() method', () => { const NumericStringBetweenThreeAndFive = compose( enforce.isNumeric(), enforce.isString(), enforce.isNumeric().greaterThan(3), enforce.isNumeric().lessThan(5), ); expect(NumericStringBetweenThreeAndFive.test('4')).toBe(true); expect(NumericStringBetweenThreeAndFive.test('3')).toBe(false); expect(NumericStringBetweenThreeAndFive.test(5)).toBe(false); }); it('Should return detailed results with .run()', () => { const PositiveNumber = compose( enforce.isNumber(), enforce.isNumber().greaterThan(0), ); const passingResult = PositiveNumber.run(5); expect(passingResult.pass).toBe(true); expect(passingResult.message).toBeUndefined(); const failingResult = PositiveNumber.run(-5); expect(failingResult.pass).toBe(false); // Message may or may not be defined depending on the rule }); }); describe('Composition with schema rules', () => { it('Should compose shape validations', () => { const Name = compose( enforce.shape({ first: enforce.isString().longerThan(0), last: enforce.isString().longerThan(0), middle: enforce.optional(enforce.isString().longerThan(0)), }), ); expect(() => Name({ first: 'John', last: 'Doe', }), ).not.toThrow(); expect(() => Name({ first: 'John', last: 'Doe', middle: '', }), ).toThrow(); }); it('Should work as part of larger shape', () => { const Name = compose( enforce.shape({ first: enforce.isString().longerThan(0), last: enforce.isString().longerThan(0), }), ); expect( enforce .shape({ name: Name, age: enforce.isNumber(), }) .run({ name: { first: 'John', last: 'Doe', }, age: 30, }).pass, ).toBe(true); expect( enforce .shape({ name: Name, age: enforce.isNumber(), }) .run({ name: { first: 'John', last: '', }, age: 30, }).pass, ).toBe(false); }); it('Should compose loose validations', () => { const Entity = compose( enforce.loose({ id: enforce.isNumeric(), }), ); expect(() => Entity({ id: '123', extra: 'field', }), ).not.toThrow(); expect(() => Entity({ id: 'not-numeric', }), ).toThrow(); }); }); describe('Composing compositions', () => { it('Should allow nested composition', () => { const Name = compose( enforce.loose({ name: enforce.shape({ first: enforce.isString().longerThan(0), last: enforce.isString().longerThan(0), middle: enforce.optional(enforce.isString().longerThan(0)), }), }), ); const Entity = compose( enforce.loose({ id: enforce.isNumeric(), }), ); const User = compose(Name, Entity); expect( User.run({ id: '1', name: { first: 'John', middle: 'M', last: 'Doe', }, }).pass, ).toBe(true); expect(() => User({ id: '1', name: { first: 'John', middle: 'M', last: 'Doe', }, }), ).not.toThrow(); expect( User.run({ id: '_', name: { first: 'John', }, }).pass, ).toBe(false); expect(() => User({ name: { first: 'John', }, id: '__', }), ).toThrow(); }); it('Should compose multiple composites', () => { const HasId = compose(enforce.loose({ id: enforce.isNumber() })); const HasName = compose(enforce.loose({ name: enforce.isString() })); const HasEmail = compose( enforce.loose({ email: enforce.isString().matches(/@/) }), ); const User = compose(HasId, HasName, HasEmail); expect( User.run({ id: 1, name: 'John', email: 'john@example.com', }).pass, ).toBe(true); expect( User.run({ id: 1, name: 'John', email: 'invalid', }).pass, ).toBe(false); }); }); describe('Composition with arrays', () => { it('Should compose array validations', () => { const NumberArray = compose( enforce.isArray(), enforce.isArrayOf(enforce.isNumber()), enforce.isArray<number>().longerThan(0), ); expect(() => NumberArray([1, 2, 3])).not.toThrow(); expect(() => NumberArray([])).toThrow(); // Empty array fails longerThan(0) expect(() => NumberArray([1, '2', 3])).toThrow(); // Not all numbers }); it('Should compose complex array of objects', () => { const User = compose( enforce.shape({ id: enforce.isNumber(), name: enforce.isString(), }), ); const Users = compose( enforce.isArray(), enforce.isArrayOf(User), enforce.isArray().longerThan(0), ); expect( Users.run([ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]).pass, ).toBe(true); expect( Users.run([ { id: 1, name: 'Alice' }, { id: '2', name: 'Bob' }, ]).pass, ).toBe(false); }); }); describe('Composition with compound rules', () => { it('Should compose with anyOf', () => { const StringOrNumber = compose( enforce.anyOf(enforce.isString(), enforce.isNumber()), enforce.isNotEmpty(), ); expect(StringOrNumber.test('hello')).toBe(true); expect(StringOrNumber.test(123)).toBe(true); expect(StringOrNumber.test('')).toBe(false); expect(StringOrNumber.test(true)).toBe(false); }); it('Should compose with allOf', () => { const ValidPassword = compose( enforce.allOf( enforce.isString(), enforce.isString().longerThan(7), enforce.isString().matches(/[A-Z]/), enforce.isString().matches(/[0-9]/), ), ); expect(ValidPassword.test('Password1')).toBe(true); expect(ValidPassword.test('password1')).toBe(false); // No uppercase expect(ValidPassword.test('Password')).toBe(false); // No number expect(ValidPassword.test('Pass1')).toBe(false); // Too short }); }); describe('Reusability', () => { it('Should allow reusing composed rules', () => { const PositiveInteger = compose( enforce.isNumber(), enforce.isNumber().greaterThan(0), ); expect(PositiveInteger.test(5)).toBe(true); expect(PositiveInteger.test(10)).toBe(true); expect(PositiveInteger.test(0)).toBe(false); expect(PositiveInteger.test(-5)).toBe(false); // Note: n4st have isInteger, so we can't test decimals }); it('Should allow building validators library', () => { const validators = { email: compose( enforce.isString(), enforce.isString().matches(/@/), enforce.isString().longerThan(5), ), phone: compose( enforce.isString(), enforce.isString().matches(/^\+?[\d\s-()]+$/), ), url: compose( enforce.isString(), enforce.isString().matches(/^https?:\/\//), ), }; expect(validators.email.test('user@example.com')).toBe(true); expect(validators.phone.test('+1-234-567-8900')).toBe(true); expect(validators.url.test('https://example.com')).toBe(true); }); }); describe('Custom rules in composition', () => { beforeEach(() => { enforce.extend({ isEven: (value: number) => value % 2 === 0, isPositive: (value: number) => value > 0, }); }); it('Should compose custom rules', () => { const PositiveEven = compose( enforce.isNumber(), enforce.isNumber().isPositive(), enforce.isNumber().isEven(), ); expect(PositiveEven.test(4)).toBe(true); expect(PositiveEven.test(2)).toBe(true); expect(PositiveEven.test(3)).toBe(false); // Not even expect(PositiveEven.test(-2)).toBe(false); // Not positive }); }); describe('Error handling', () => { it('Should throw on first failing rule in eager mode', () => { const Validator = compose( enforce.isString(), enforce.isString().longerThan(5), enforce.isString().matches(/test/), ); expect(() => Validator(123)).toThrow(); // Fails on isString expect(() => Validator('hi')).toThrow(); // Fails on longerThan expect(() => Validator('hello world')).toThrow(); // Fails on matches }); it('Should provide meaningful error in lazy mode', () => { const Validator = compose( enforce.isNumber(), enforce.isNumber().greaterThan(10), ); const result = Validator.run('not a number'); expect(result.pass).toBe(false); // Message may or may not be defined depending on the rule }); }); describe('Edge cases', () => { it('Should handle single rule composition', () => { const JustNumber = compose(enforce.isNumber()); expect(JustNumber.test(123)).toBe(true); expect(JustNumber.test('123')).toBe(false); }); it('Should handle empty composition gracefully', () => { const Empty = compose(); // Empty composition should always pass expect(Empty.run('anything').pass).toBe(true); expect(() => Empty('anything')).not.toThrow(); }); it('Should work with falsy values', () => { const AcceptsFalsy = compose(enforce.equals(false)); expect(AcceptsFalsy.test(false)).toBe(true); expect(AcceptsFalsy.test(0)).toBe(false); expect(AcceptsFalsy.test('')).toBe(false); }); }); describe('Real-world use cases', () => { it('Should validate user registration data', () => { const Username = compose( enforce.isString(), enforce.isString().longerThan(3), enforce.isString().shorterThan(20), enforce.isString().matches(/^[a-zA-Z0-9_]+$/), ); const Email = compose( enforce.isString(), enforce.isString().matches(/@/), enforce.isString().matches(/\./), ); const Password = compose( enforce.isString(), enforce.isString().longerThan(7), enforce.isString().matches(/[A-Z]/), enforce.isString().matches(/[a-z]/), enforce.isString().matches(/[0-9]/), ); const UserRegistration = compose( enforce.shape({ username: Username, email: Email, password: Password, }), ); expect( UserRegistration.test({ username: 'john_doe', email: 'john@example.com', password: 'SecurePass123', }), ).toBe(true); expect( UserRegistration.test({ username: 'ab', // Too short email: 'john@example.com', password: 'SecurePass123', }), ).toBe(false); }); it('Should validate API response structure', () => { const User = compose( enforce.shape({ id: enforce.isNumber(), name: enforce.isString(), email: enforce.isString().matches(/@/), }), ); const ApiResponse = compose( enforce.shape({ data: User, status: enforce.equals(200), timestamp: enforce.isNumber(), }), ); expect( ApiResponse.test({ data: { id: 1, name: 'John', email: 'john@example.com', }, status: 200, timestamp: Date.now(), }), ).toBe(true); }); it('Should create domain-specific validators', () => { const Money = compose( enforce.isNumber(), enforce.isNumber().greaterThanOrEquals(0), ); const Product = compose( enforce.shape({ id: enforce.isNumber(), name: enforce.isString().longerThan(0), price: Money, quantity: compose( enforce.isNumber(), enforce.isNumber().greaterThanOrEquals(0), ), }), ); expect( Product.test({ id: 1, name: 'Widget', price: 19.99, quantity: 100, }), ).toBe(true); expect( Product.test({ id: 1, name: 'Widget', price: -5, // Invalid: negative price quantity: 100, }), ).toBe(false); }); }); describe('Type inference compatibility', () => { it('Should work with inferred types from compositions', () => { const StringRule = enforce.isString(); const NumberRule = enforce.isNumber(); const StringOrNumberComposite = compose( enforce.anyOf(StringRule, NumberRule), ); expect(StringOrNumberComposite.test('hello')).toBe(true); expect(StringOrNumberComposite.test(123)).toBe(true); expect(StringOrNumberComposite.test(true)).toBe(false); }); it('Should preserve type information through compositions', () => { const PositiveNumber = compose( enforce.isNumber(), enforce.isNumber().greaterThan(0), ); // Type should be inferred as number type InferredType = typeof PositiveNumber.infer; const value: InferredType = 5; expect(PositiveNumber.test(value)).toBe(true); }); }); });