n4s
Version:
typed schema validation version of enforce
765 lines (700 loc) • 19.9 kB
text/typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { enforce } from '../../../n4s';
describe('Schema Rules - Eager Notation', () => {
describe('enforce.shape() - eager', () => {
it('should pass with exact matching object', () => {
expect(() =>
enforce({
name: 'John',
age: 30,
}).shape({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).not.toThrow();
});
it('should fail with extra properties', () => {
expect(() =>
enforce({
name: 'John',
age: 30,
extra: 'property',
}).shape({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).toThrow();
});
it('should fail if a property is missing', () => {
expect(() =>
enforce({
name: 'John',
}).shape({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).toThrow();
});
it('should fail if a property has wrong type', () => {
expect(() =>
enforce({
name: 'John',
age: '30',
}).shape({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).toThrow();
});
it('should work with chained rules', () => {
expect(() =>
enforce({
email: 'test@example.com',
age: 25,
}).shape({
email: enforce.isString().matches(/@/),
age: enforce.isNumber().greaterThan(18),
}),
).not.toThrow();
});
it('should fail when chained rule fails', () => {
expect(() =>
enforce({
age: 15,
}).shape({
age: enforce.isNumber().greaterThan(18),
}),
).toThrow();
});
it('should work with nested shapes', () => {
expect(() =>
enforce({
user: {
name: {
first: 'Joseph',
last: 'Weil',
},
},
}).shape({
user: enforce.shape({
name: enforce.shape({
first: enforce.isString(),
last: enforce.isString(),
}),
}),
}),
).not.toThrow();
});
it('should fail with nested shape violation', () => {
expect(() =>
enforce({
user: {
name: {
first: 'Joseph',
last: 123,
},
},
}).shape({
user: enforce.shape({
name: enforce.shape({
first: enforce.isString(),
last: enforce.isString(),
}),
}),
}),
).toThrow();
});
it('should pass with empty schema and empty object', () => {
expect(() => enforce({}).shape({})).not.toThrow();
});
it('should fail with empty schema and non-empty object', () => {
expect(() => enforce({ any: 'value' }).shape({})).toThrow();
});
});
describe('enforce.loose() - eager', () => {
it('should pass with exact matching object', () => {
expect(() =>
enforce({
name: 'John',
age: 30,
}).loose({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).not.toThrow();
});
it('should pass with extra properties', () => {
expect(() =>
enforce({
name: 'Laura',
code: 'x23',
}).loose({
name: enforce.isString(),
}),
).not.toThrow();
});
it('should fail if a required property is missing', () => {
expect(() =>
enforce({
name: 'John',
}).loose({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).toThrow();
});
it('should fail if a property has wrong type', () => {
expect(() =>
enforce({
name: 'John',
age: '30',
}).loose({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).toThrow();
});
it('should pass with empty schema and any object', () => {
expect(() =>
enforce({ any: 'value', extra: 'fields' }).loose({}),
).not.toThrow();
});
it('should work with chained rules', () => {
expect(() =>
enforce({
name: 'John Doe',
age: 30,
extra: 'allowed',
}).loose({
name: enforce.isString().longerThan(5),
age: enforce.isNumber().isBetween(18, 100),
}),
).not.toThrow();
});
});
describe('enforce.isArrayOf() - eager', () => {
it('should pass for an array of matching type', () => {
expect(() =>
enforce([1, 2, 3]).isArrayOf(enforce.isNumber()),
).not.toThrow();
});
it('should fail for an array with mixed types', () => {
expect(() =>
enforce([1, '2', 3]).isArrayOf(enforce.isNumber()),
).toThrow();
});
it('should pass for an empty array', () => {
expect(() => enforce([]).isArrayOf(enforce.isNumber())).not.toThrow();
});
it('should fail if not an array', () => {
expect(() =>
// Type test: - intentionally testing invalid input
enforce({ not: 'an array' }).isArrayOf(enforce.isNumber()),
).toThrow();
});
it('should pass for mixed types when multiple rules are provided', () => {
expect(() =>
enforce([1, '2', 3]).isArrayOf(enforce.isNumber(), enforce.isString()),
).not.toThrow();
});
it('should fail when a type is not in the allowed rules', () => {
expect(() =>
enforce([1, '2', true]).isArrayOf(
enforce.isNumber(),
enforce.isString(),
),
).toThrow();
});
it('should work with chained rules', () => {
expect(() =>
enforce(['test@example.com', 'another@example.com']).isArrayOf(
enforce.isString().matches(/@/),
),
).not.toThrow();
});
it('should fail when chained rule fails for any element', () => {
expect(() =>
enforce(['test@example.com', 'invalid']).isArrayOf(
enforce.isString().matches(/@/),
),
).toThrow();
});
it('should combine with other array rules', () => {
expect(() =>
enforce([1, 2, 3])
.isArrayOf(enforce.isNumber().lessThan(10))
.longerThan(2),
).not.toThrow();
});
it('should work within shape', () => {
expect(() =>
enforce({
data: [1, 2, 3],
}).shape({
data: enforce.isArrayOf(enforce.isNumber()),
}),
).not.toThrow();
});
it('should fail within shape when array content is invalid', () => {
expect(() =>
enforce({
data: [1, '2', 3],
}).shape({
data: enforce.isArrayOf(enforce.isNumber()),
}),
).toThrow();
});
});
describe('enforce.optional() - eager', () => {
it('should pass with null value', () => {
expect(() =>
enforce({
firstName: 'Rick',
lastName: 'Sanchez',
middleName: null,
}).shape({
firstName: enforce.isString(),
middleName: enforce.optional(enforce.isString()),
lastName: enforce.isString(),
}),
).not.toThrow();
});
it('should pass with undefined value', () => {
expect(() =>
enforce({
firstName: 'Rick',
lastName: 'Sanchez',
middleName: undefined,
}).shape({
firstName: enforce.isString(),
middleName: enforce.optional(enforce.isString()),
lastName: enforce.isString(),
}),
).not.toThrow();
});
it('should pass with missing property', () => {
expect(() =>
enforce({
firstName: 'Rick',
lastName: 'Sanchez',
}).shape({
firstName: enforce.isString(),
middleName: enforce.optional(enforce.isString()),
lastName: enforce.isString(),
}),
).not.toThrow();
});
it('should pass with valid value', () => {
expect(() =>
enforce({
firstName: 'Rick',
middleName: 'C-137',
lastName: 'Sanchez',
}).shape({
firstName: enforce.isString(),
middleName: enforce.optional(enforce.isString()),
lastName: enforce.isString(),
}),
).not.toThrow();
});
it('should fail with invalid value type', () => {
expect(() =>
enforce({
firstName: 'Rick',
middleName: 123,
lastName: 'Sanchez',
}).shape({
firstName: enforce.isString(),
middleName: enforce.optional(enforce.isString()),
lastName: enforce.isString(),
}),
).toThrow();
});
it('should work with chained rules', () => {
expect(() =>
enforce({
name: 'John',
email: 'test@example.com',
}).shape({
name: enforce.isString(),
email: enforce.optional(enforce.isString().matches(/@/)),
}),
).not.toThrow();
});
it('should fail when optional chained rule fails', () => {
expect(() =>
enforce({
name: 'John',
email: 'invalid-email',
}).shape({
name: enforce.isString(),
email: enforce.optional(enforce.isString().matches(/@/)),
}),
).toThrow();
});
});
describe('enforce.partial() - eager', () => {
it('should pass with subset of properties', () => {
expect(() =>
enforce({
firstName: 'John',
}).partial({
firstName: enforce.isString(),
lastName: enforce.isString(),
}),
).not.toThrow();
});
it('should pass with empty object (Partial semantics)', () => {
expect(() =>
enforce({}).partial({
firstName: enforce.isString(),
lastName: enforce.isString(),
}),
).not.toThrow();
});
it('should pass with all properties', () => {
expect(() =>
enforce({
firstName: 'John',
lastName: 'Doe',
}).partial({
firstName: enforce.isString(),
lastName: enforce.isString(),
}),
).not.toThrow();
});
it('should fail with wrong type for provided property', () => {
expect(() =>
enforce({
// Type test:
firstName: 123,
}).partial({
firstName: enforce.isString(),
lastName: enforce.isString(),
}),
).toThrow();
});
it('should work with chained rules', () => {
expect(() =>
enforce({
age: 25,
})
.partial({
age: enforce.isNumber().greaterThan(18),
name: enforce.isString().longerThan(2),
})
.isNotEmpty(),
).not.toThrow();
});
it('should fail when chained rule fails on provided property', () => {
expect(() =>
enforce({
age: 15,
})
.partial({
age: enforce.isNumber().greaterThan(18),
name: enforce.isString(),
})
.isEmpty(),
).toThrow();
});
});
describe('Complex integration scenarios - eager', () => {
it('should validate deeply nested objects with shape', () => {
expect(() =>
enforce({
user: {
profile: {
contact: {
email: 'test@example.com',
},
age: 25,
},
},
}).shape({
user: enforce.shape({
profile: enforce.shape({
contact: enforce.shape({
email: enforce.isString().matches(/@/),
}),
age: enforce.isNumber().greaterThan(18),
}),
}),
}),
).not.toThrow();
});
it('should combine shape with isArrayOf', () => {
expect(() =>
enforce({
username: 'johndoe',
tags: ['javascript', 'typescript', 'node'],
}).shape({
username: enforce.isString(),
tags: enforce.isArrayOf(enforce.isString().longerThan(2)),
}),
).not.toThrow();
});
it('should combine loose with optional and isArrayOf', () => {
expect(() =>
enforce({
name: 'Product',
categories: ['tech', 'gadgets'],
extraField: 'allowed',
}).loose({
name: enforce.isString(),
categories: enforce.isArrayOf(enforce.isString()),
description: enforce.optional(enforce.isString()),
}),
).not.toThrow();
});
it('should work with allOf in shape', () => {
expect(() =>
enforce({
password: 'SecureP@ss123',
}).shape({
password: enforce.allOf(
enforce.isString(),
enforce.isString().longerThan(8),
enforce.isString().matches(/[A-Z]/),
enforce.isString().matches(/[0-9]/),
),
}),
).not.toThrow();
});
it('should work with anyOf in shape', () => {
expect(() =>
enforce({
identifier: 'user@example.com',
}).shape({
identifier: enforce.anyOf(
enforce.isString().matches(/@/),
enforce.isString().matches(/^\d+$/),
),
}),
).not.toThrow();
});
it('should validate array of objects with isArrayOf and shape', () => {
expect(() =>
enforce({
users: [
{ name: 'John', age: 30 },
{ name: 'Jane', age: 25 },
],
}).shape({
users: enforce.isArrayOf(
enforce.shape({
name: enforce.isString(),
age: enforce.isNumber(),
}),
),
}),
).not.toThrow();
});
it('should fail when array of objects has invalid nested property', () => {
expect(() =>
enforce({
users: [
{ name: 'John', age: 30 },
{ name: 'Jane', age: '25' },
],
}).shape({
users: enforce.isArrayOf(
enforce.shape({
name: enforce.isString(),
age: enforce.isNumber(),
}),
),
}),
).toThrow();
});
});
describe('Custom rules with schema rules - eager', () => {
beforeEach(() => {
enforce.extend({
isEmail: (value: string) =>
/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value),
isAdult: (value: number) => value >= 18,
isFriendTheSameAsUser: (value: string) => {
const context = enforce.context();
if (value === context?.parent()?.parent()?.value.username) {
return {
pass: false,
message: () => 'Friend cannot be the same as username',
};
}
return true;
},
});
});
it('should work with custom rules in shape', () => {
expect(() =>
enforce({
email: 'test@example.com',
age: 25,
}).shape({
email: enforce.isString().isEmail(),
age: enforce.isNumber().isAdult(),
}),
).not.toThrow();
});
it('should fail when custom rule fails in shape', () => {
expect(() =>
enforce({
email: 'invalid',
age: 16,
}).shape({
email: enforce.isString().isEmail(),
age: enforce.isNumber().isAdult(),
}),
).toThrow();
});
it('should work with custom rules in isArrayOf', () => {
expect(() =>
enforce({
emails: ['test@example.com', 'another@example.com'],
}).shape({
emails: enforce.isArrayOf(enforce.isString().isEmail()),
}),
).not.toThrow();
});
it('should work with context-aware custom rules', () => {
expect(() =>
enforce({
username: 'johndoe',
friends: ['Mike', 'Jim'],
}).shape({
username: enforce.isString(),
friends: enforce.isArrayOf(
enforce.isString().isFriendTheSameAsUser(),
),
}),
).not.toThrow();
});
// Note: Context traversal in nested schema rules (shape/isArrayOf) is not yet fully implemented
it('should fail when context-aware custom rule fails', () => {
expect(() =>
enforce({
username: 'johndoe',
friends: ['Mike', 'Jim', 'johndoe'],
}).shape({
username: enforce.isString(),
friends: enforce.isArrayOf(
enforce.isString().isFriendTheSameAsUser(),
),
}),
).toThrow('Friend cannot be the same as username');
});
});
describe('Error messages with schema rules - eager', () => {
it('should throw descriptive error for shape validation', () => {
expect(() =>
enforce({
name: 'John',
age: '30',
}).shape({
name: enforce.isString(),
age: enforce.isNumber(),
}),
).toThrow(/enforce/);
});
it('should support custom messages with .message()', () => {
expect(() =>
enforce({
age: 15,
})
.message('Age must be valid')
.shape({
age: enforce.isNumber().greaterThan(18),
}),
).toThrow('Age must be valid');
});
it('should support custom messages on nested rules', () => {
expect(() =>
enforce({
user: {
age: 15,
},
}).shape({
user: enforce.shape({
age: enforce.isNumber().greaterThan(18),
}),
}),
).toThrow(/enforce/);
});
});
describe('Edge cases - eager', () => {
it('should handle null and undefined values in shape', () => {
expect(() =>
enforce({
name: null,
}).shape({
name: enforce.optional(enforce.isString()),
}),
).not.toThrow();
});
it('should handle empty arrays in isArrayOf', () => {
expect(() =>
enforce({
items: [],
}).shape({
items: enforce.isArrayOf(enforce.isNumber()),
}),
).not.toThrow();
});
it('should handle deeply nested optional fields', () => {
expect(() =>
enforce({
user: {
profile: {},
},
}).shape({
user: enforce.shape({
profile: enforce.shape({
bio: enforce.optional(enforce.isString()),
}),
}),
}),
).not.toThrow();
});
it('should handle mixed optional and required fields', () => {
expect(() =>
enforce({
required: 'value',
}).shape({
required: enforce.isString(),
optional1: enforce.optional(enforce.isString()),
optional2: enforce.optional(enforce.isNumber()),
}),
).not.toThrow();
});
it('should handle arrays of different types with multiple isArrayOf rules', () => {
expect(() =>
enforce([1, '2', 3, 'four']).isArrayOf(
enforce.isNumber(),
enforce.isString(),
),
).not.toThrow();
});
});
describe('Chaining schema rules - eager', () => {
it('should allow chaining after shape validation', () => {
expect(() =>
enforce({
items: [1, 2, 3],
})
.shape({
items: enforce.isArray(),
})
.isNotEmpty(),
).not.toThrow();
});
it('should allow chaining multiple validations', () => {
expect(() =>
enforce([1, 2, 3])
.isArrayOf(enforce.isNumber())
.longerThan(2)
.shorterThan(10),
).not.toThrow();
});
});
});