n4s
Version:
typed schema validation version of enforce
585 lines (504 loc) • 15.8 kB
text/typescript
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);
});
});
});