From 44c8f28f4b8dbd701e09a8c2fc7f75ae11081594 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 23:48:40 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=2030=20validation=20dec?= =?UTF-8?q?orators,=20@ValidateIf=20and=20@Allow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out the validator set with the rules an application actually reaches for, all following the existing decorator style and honouring each/message options. - equality and presence: @Equals, @NotEquals, @IsEmpty, @IsEnum, @IsInstance - strings: @Length, @IsAlpha, @IsAlphanumeric, @IsNumberString, @IsLowercase, @IsUppercase, @Contains, @NotContains, @StartsWith, @EndsWith - formats: @IsUUID, @IsJSON, @IsDateString, @IsSemVer, @IsHexColor, @IsIP - numbers: @IsDivisibleBy, @IsPort, @IsLatitude, @IsLongitude, @IsBigInt - dates: @MinDate, @MaxDate - arrays: @ArrayUnique, @ArrayContains, @ArrayNotContains @ValidateIf(o => ...) makes a property's rules conditional on the rest of the object, and @Allow() declares a property that needs no rules of its own so it survives unknownKeys: 'strip'. Two details worth noting. @IsEnum filters the reverse mapping a numeric enum compiles to, so 'Low' is not accepted as a value of enum { Low, High }. @MinDate/@MaxDate accept a thunk, so "not in the past" is evaluated per validation instead of being frozen when the class was declared. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK --- src/decorators.ts | 478 +++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 6 + src/validators.test.ts | 323 ++++++++++++++++++++++++++++ 3 files changed, 807 insertions(+) create mode 100644 src/validators.test.ts diff --git a/src/decorators.ts b/src/decorators.ts index 2215226..74a841c 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -13,6 +13,7 @@ export const METADATA_KEYS = { NAME: 'cereale:name', ALIASES: 'cereale:aliases', ACCESS: 'cereale:access', + CONDITION: 'cereale:condition', }; /** @@ -565,6 +566,483 @@ export function IsDate(options?: ValidationOptions) { }; } +// --- Equality and presence --- + +/** + * @Equals(comparison: any) + */ +export function Equals(comparison: any, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'equals', + validate: (v) => v === comparison, + message: `${propertyKey} must be equal to ${JSON.stringify(comparison)}`, + constraints: [comparison] + }, options); + }; +} + +/** + * @NotEquals(comparison: any) + */ +export function NotEquals(comparison: any, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'notEquals', + validate: (v) => v !== comparison, + message: `${propertyKey} must not be equal to ${JSON.stringify(comparison)}`, + constraints: [comparison] + }, options); + }; +} + +/** + * @IsEmpty() + * Passes for null, undefined, '', [] and {} — the mirror of `@IsNotEmpty`. + */ +export function IsEmpty(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isEmpty', + validate: (v) => { + if (v === null || v === undefined || v === '') return true; + if (Array.isArray(v)) return v.length === 0; + if (typeof v === 'object') return Object.keys(v).length === 0; + return false; + }, + message: `${propertyKey} must be empty` + }, options); + }; +} + +/** + * @IsEnum(entity: object) + * Checks the value is a member of a TypeScript enum (string or numeric). + */ +export function IsEnum(entity: Record, options?: ValidationOptions) { + // A numeric enum compiles to a two-way map ({ A: 0, '0': 'A' }), so the reverse-mapped + // names have to be filtered out or 'A' would validate as a legal value. + const values = Object.keys(entity) + .filter(key => typeof entity[entity[key]] !== 'number') + .map(key => entity[key]); + + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isEnum', + validate: (v) => values.includes(v), + message: `${propertyKey} must be one of the following values: ${values.join(', ')}`, + constraints: [values] + }, options); + }; +} + +/** + * @IsInstance(target: ClassConstructor) + */ +export function IsInstance(clazz: ClassConstructor, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isInstance', + validate: (v) => v instanceof clazz, + message: `${propertyKey} must be an instance of ${clazz.name}`, + constraints: [clazz] + }, options); + }; +} + +// --- Strings --- + +/** + * @Length(min: number, max?: number) + */ +export function Length(min: number, max?: number, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'length', + validate: (v) => typeof v === 'string' && v.length >= min && (max === undefined || v.length <= max), + message: max === undefined + ? `${propertyKey} must be at least ${min} characters` + : `${propertyKey} must be between ${min} and ${max} characters`, + constraints: max === undefined ? [min] : [min, max] + }, options); + }; +} + +function stringPattern(name: string, regex: RegExp, describe: (property: string) => string) { + return (options?: ValidationOptions) => (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name, + validate: (v) => typeof v === 'string' && regex.test(v), + message: describe(propertyKey) + }, options); + }; +} + +/** @IsAlpha() — letters only. */ +export const IsAlpha = stringPattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`); + +/** @IsAlphanumeric() — letters and digits only. */ +export const IsAlphanumeric = stringPattern( + 'isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers` +); + +/** @IsNumberString() — a string that parses as a finite number. */ +export function IsNumberString(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isNumberString', + validate: (v) => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), + message: `${propertyKey} must be a number string` + }, options); + }; +} + +/** @IsLowercase() */ +export function IsLowercase(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isLowercase', + validate: (v) => typeof v === 'string' && v === v.toLowerCase(), + message: `${propertyKey} must be lowercase` + }, options); + }; +} + +/** @IsUppercase() */ +export function IsUppercase(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isUppercase', + validate: (v) => typeof v === 'string' && v === v.toUpperCase(), + message: `${propertyKey} must be uppercase` + }, options); + }; +} + +/** @Contains(seed: string) */ +export function Contains(seed: string, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'contains', + validate: (v) => typeof v === 'string' && v.includes(seed), + message: `${propertyKey} must contain ${JSON.stringify(seed)}`, + constraints: [seed] + }, options); + }; +} + +/** @NotContains(seed: string) */ +export function NotContains(seed: string, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'notContains', + validate: (v) => typeof v === 'string' && !v.includes(seed), + message: `${propertyKey} must not contain ${JSON.stringify(seed)}`, + constraints: [seed] + }, options); + }; +} + +/** @StartsWith(prefix: string) */ +export function StartsWith(prefix: string, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'startsWith', + validate: (v) => typeof v === 'string' && v.startsWith(prefix), + message: `${propertyKey} must start with ${JSON.stringify(prefix)}`, + constraints: [prefix] + }, options); + }; +} + +/** @EndsWith(suffix: string) */ +export function EndsWith(suffix: string, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'endsWith', + validate: (v) => typeof v === 'string' && v.endsWith(suffix), + message: `${propertyKey} must end with ${JSON.stringify(suffix)}`, + constraints: [suffix] + }, options); + }; +} + +const NIL_UUID = '00000000-0000-0000-0000-000000000000'; +const MAX_UUID = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; + +/** + * @IsUUID(version?: 1|2|3|4|5|6|7|8) + * Without a version, accepts any RFC 9562 UUID plus the nil and max UUIDs. + */ +export function IsUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, options?: ValidationOptions) { + const pattern = version + ? new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, 'i') + : /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isUuid', + validate: (v) => { + if (typeof v !== 'string') return false; + if (!version && (v.toLowerCase() === NIL_UUID || v.toLowerCase() === MAX_UUID)) return true; + return pattern.test(v); + }, + message: `${propertyKey} must be a valid UUID${version ? ` (version ${version})` : ''}`, + ...(version ? { constraints: [version] } : {}) + }, options); + }; +} + +/** @IsJSON() — a string that JSON.parse accepts. */ +export function IsJSON(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isJson', + validate: (v) => { + if (typeof v !== 'string') return false; + try { + JSON.parse(v); + return true; + } catch { + return false; + } + }, + message: `${propertyKey} must be a JSON string` + }, options); + }; +} + +/** @IsDateString() — an ISO-8601 string that parses to a real date. */ +export function IsDateString(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isDateString', + validate: (v) => typeof v === 'string' && !isNaN(Date.parse(v)), + message: `${propertyKey} must be a valid ISO 8601 date string` + }, options); + }; +} + +/** @IsSemVer() */ +export const IsSemVer = stringPattern( + 'isSemVer', + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, + p => `${p} must be a valid semantic version` +); + +/** @IsHexColor() — #rgb, #rrggbb or #rrggbbaa. */ +export const IsHexColor = stringPattern( + 'isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color` +); + +const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +/** + * @IsIP(version?: 4 | 6) + */ +export function IsIP(version?: 4 | 6, options?: ValidationOptions) { + const isV6 = (v: string) => { + // Node's URL parser is the most reliable IPv6 validator available without a dependency. + try { + return new URL(`http://[${v}]`).hostname === `[${v.toLowerCase()}]` || /^[0-9a-f:.]+$/i.test(v) && v.includes(':'); + } catch { + return false; + } + }; + + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isIp', + validate: (v) => { + if (typeof v !== 'string') return false; + if (version === 4) return IPV4.test(v); + if (version === 6) return isV6(v); + return IPV4.test(v) || isV6(v); + }, + message: `${propertyKey} must be a valid IP${version ? `v${version}` : ''} address`, + ...(version ? { constraints: [version] } : {}) + }, options); + }; +} + +// --- Numbers --- + +/** @IsDivisibleBy(divisor: number) */ +export function IsDivisibleBy(divisor: number, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isDivisibleBy', + validate: (v) => typeof v === 'number' && Number.isFinite(v) && divisor !== 0 && v % divisor === 0, + message: `${propertyKey} must be divisible by ${divisor}`, + constraints: [divisor] + }, options); + }; +} + +/** @IsPort() — an integer in 0..65535, as a number or a numeric string. */ +export function IsPort(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isPort', + validate: (v) => { + const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v; + return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535; + }, + message: `${propertyKey} must be a valid port number` + }, options); + }; +} + +/** @IsLatitude() */ +export function IsLatitude(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isLatitude', + validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, + message: `${propertyKey} must be a latitude between -90 and 90` + }, options); + }; +} + +/** @IsLongitude() */ +export function IsLongitude(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isLongitude', + validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, + message: `${propertyKey} must be a longitude between -180 and 180` + }, options); + }; +} + +/** @IsBigInt() */ +export function IsBigInt(options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'isBigInt', + validate: (v) => typeof v === 'bigint', + message: `${propertyKey} must be a bigint` + }, options); + }; +} + +// --- Dates --- + +type DateBound = Date | (() => Date); + +const boundOf = (bound: DateBound): Date => (typeof bound === 'function' ? bound() : bound); + +/** + * @MinDate(date: Date | (() => Date)) + * Accepts a thunk so a moving boundary — "not in the past" — is evaluated per validation + * rather than frozen when the class was declared. + */ +export function MinDate(min: DateBound, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'minDate', + validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() >= boundOf(min).getTime(), + message: (args) => `${args.property} must not be earlier than ${boundOf(min).toISOString()}`, + constraints: [min] + }, options); + }; +} + +/** + * @MaxDate(date: Date | (() => Date)) + */ +export function MaxDate(max: DateBound, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'maxDate', + validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() <= boundOf(max).getTime(), + message: (args) => `${args.property} must not be later than ${boundOf(max).toISOString()}`, + constraints: [max] + }, options); + }; +} + +// --- Arrays --- + +/** + * @ArrayUnique(identifier?: (item: any) => any) + * Pass an extractor to deduplicate objects by a key rather than by reference. + */ +export function ArrayUnique(identifier?: (item: any) => any, options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'arrayUnique', + validate: (v) => { + if (!Array.isArray(v)) return false; + const keys = identifier ? v.map(identifier) : v; + return new Set(keys).size === keys.length; + }, + message: `${propertyKey} must not contain duplicate values` + }, options); + }; +} + +/** @ArrayContains(values: any[]) — the array must contain every listed value. */ +export function ArrayContains(values: any[], options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'arrayContains', + validate: (v) => Array.isArray(v) && values.every(value => v.includes(value)), + message: `${propertyKey} must contain the following values: ${values.join(', ')}`, + constraints: [values] + }, options); + }; +} + +/** @ArrayNotContains(values: any[]) — the array must contain none of the listed values. */ +export function ArrayNotContains(values: any[], options?: ValidationOptions) { + return (target: any, propertyKey: string) => { + addValidation(target, propertyKey, { + name: 'arrayNotContains', + validate: (v) => Array.isArray(v) && values.every(value => !v.includes(value)), + message: `${propertyKey} must not contain any of the following values: ${values.join(', ')}`, + constraints: [values] + }, options); + }; +} + +// --- Control flow --- + +/** + * @ValidateIf(condition: (object: any) => boolean) + * Skips every constraint on this property when the condition returns false. + * + * ```ts + * class Payment { + * @IsIn(['card', 'invoice']) + * method: string; + * + * @ValidateIf(o => o.method === 'card') + * @IsString() + * cardNumber?: string; + * } + * ``` + */ +export function ValidateIf(condition: (object: any) => boolean) { + return (target: any, propertyKey: string) => { + registerProperty(target, propertyKey); + metadataStorage.defineMetadata(METADATA_KEYS.CONDITION, condition, target, propertyKey); + }; +} + +/** + * @Allow() + * Declares a property with no constraints of its own. + * + * Useful with `unknownKeys: 'strip'` or `'error'`, where a property has to be declared to + * survive the payload even though nothing about its value needs checking. + */ +export function Allow() { + return (target: any, propertyKey: string) => { + registerProperty(target, propertyKey); + }; +} + /** * @ValidateNested(options?: ValidationOptions) * Recursively validates the value of this property. diff --git a/src/utils.ts b/src/utils.ts index 911feac..04ccf7c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -365,6 +365,12 @@ async function validateInternal(obj: any, ancestors: Set): Promise void, value: any): Promise { + class Subject { + val: any; + } + decorate(Subject.prototype, 'val'); + + const subject = new Subject(); + subject.val = value; + + const errors = await validate(subject); + return errors.length ? Object.keys(errors[0]!.constraints) : []; +} + +const passes = async (decorator: any, value: any) => expect(await check(decorator, value)).toEqual([]); +const fails = async (decorator: any, value: any) => expect((await check(decorator, value)).length).toBeGreaterThan(0); + +describe('equality and presence', () => { + it('@Equals / @NotEquals', async () => { + await passes(Equals('x'), 'x'); + await fails(Equals('x'), 'y'); + await passes(NotEquals('x'), 'y'); + await fails(NotEquals('x'), 'x'); + }); + + it('@IsEmpty', async () => { + for (const empty of [null, undefined, '', [], {}]) await passes(IsEmpty(), empty); + for (const filled of ['a', [1], { a: 1 }, 0]) await fails(IsEmpty(), filled); + }); + + it('@IsInstance', async () => { + class Thing {} + await passes(IsInstance(Thing), new Thing()); + await fails(IsInstance(Thing), {}); + }); +}); + +describe('@IsEnum', () => { + enum StringRole { Admin = 'admin', User = 'user' } + enum NumericLevel { Low, High } + + it('accepts members of a string enum', async () => { + await passes(IsEnum(StringRole), 'admin'); + await fails(IsEnum(StringRole), 'root'); + }); + + it('accepts members of a numeric enum without accepting its reverse-mapped names', async () => { + await passes(IsEnum(NumericLevel), 0); + await passes(IsEnum(NumericLevel), 1); + await fails(IsEnum(NumericLevel), 2); + // 'Low' is the reverse mapping, not a legal value + await fails(IsEnum(NumericLevel), 'Low'); + }); +}); + +describe('strings', () => { + it('@Length with and without a maximum', async () => { + await passes(Length(2), 'ab'); + await fails(Length(3), 'ab'); + await passes(Length(2, 4), 'abc'); + await fails(Length(2, 4), 'abcde'); + }); + + it('@IsAlpha / @IsAlphanumeric', async () => { + await passes(IsAlpha(), 'abcDEF'); + await fails(IsAlpha(), 'abc1'); + await passes(IsAlphanumeric(), 'abc123'); + await fails(IsAlphanumeric(), 'abc-123'); + }); + + it('@IsNumberString', async () => { + await passes(IsNumberString(), '42'); + await passes(IsNumberString(), '-1.5'); + await fails(IsNumberString(), 'abc'); + await fails(IsNumberString(), ''); + await fails(IsNumberString(), 42); + }); + + it('@IsLowercase / @IsUppercase', async () => { + await passes(IsLowercase(), 'abc'); + await fails(IsLowercase(), 'Abc'); + await passes(IsUppercase(), 'ABC'); + await fails(IsUppercase(), 'Abc'); + }); + + it('@Contains / @NotContains / @StartsWith / @EndsWith', async () => { + await passes(Contains('ell'), 'hello'); + await fails(Contains('xyz'), 'hello'); + await passes(NotContains('xyz'), 'hello'); + await fails(NotContains('ell'), 'hello'); + await passes(StartsWith('he'), 'hello'); + await fails(StartsWith('lo'), 'hello'); + await passes(EndsWith('lo'), 'hello'); + await fails(EndsWith('he'), 'hello'); + }); +}); + +describe('@IsUUID', () => { + const v4 = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21'; + + it('accepts any version when unversioned', async () => { + await passes(IsUUID(), v4); + await passes(IsUUID(), '00000000-0000-0000-0000-000000000000'); // nil + await fails(IsUUID(), 'not-a-uuid'); + await fails(IsUUID(), 42); + }); + + it('enforces a requested version', async () => { + await passes(IsUUID(4), v4); + await fails(IsUUID(1), v4); + }); +}); + +describe('formats', () => { + it('@IsJSON', async () => { + await passes(IsJSON(), '{"a":1}'); + await passes(IsJSON(), '[1,2]'); + await fails(IsJSON(), '{a:1}'); + await fails(IsJSON(), { a: 1 }); + }); + + it('@IsDateString', async () => { + await passes(IsDateString(), '2026-08-03T00:00:00Z'); + await fails(IsDateString(), 'not a date'); + }); + + it('@IsSemVer', async () => { + await passes(IsSemVer(), '1.2.3'); + await passes(IsSemVer(), '1.0.0-alpha.1+build.5'); + await fails(IsSemVer(), '1.2'); + await fails(IsSemVer(), 'v1.2.3'); + }); + + it('@IsHexColor', async () => { + await passes(IsHexColor(), '#fff'); + await passes(IsHexColor(), '#A1B2C3'); + await passes(IsHexColor(), '#A1B2C3FF'); + await fails(IsHexColor(), 'fff'); + await fails(IsHexColor(), '#ggg'); + }); + + it('@IsIP', async () => { + await passes(IsIP(4), '192.168.0.1'); + await fails(IsIP(4), '256.0.0.1'); + await fails(IsIP(4), '::1'); + await passes(IsIP(6), '::1'); + await passes(IsIP(), '10.0.0.1'); + await fails(IsIP(), 'nope'); + }); +}); + +describe('numbers', () => { + it('@IsDivisibleBy', async () => { + await passes(IsDivisibleBy(5), 10); + await fails(IsDivisibleBy(5), 11); + await fails(IsDivisibleBy(5), '10'); + }); + + it('@IsPort', async () => { + await passes(IsPort(), 8080); + await passes(IsPort(), '443'); + await fails(IsPort(), 70000); + await fails(IsPort(), -1); + await fails(IsPort(), 1.5); + }); + + it('@IsLatitude / @IsLongitude', async () => { + await passes(IsLatitude(), 48.85); + await fails(IsLatitude(), 91); + await passes(IsLongitude(), 2.35); + await fails(IsLongitude(), 181); + }); + + it('@IsBigInt', async () => { + await passes(IsBigInt(), 10n); + await fails(IsBigInt(), 10); + }); +}); + +describe('dates', () => { + it('@MinDate / @MaxDate with a fixed bound', async () => { + const bound = new Date('2026-01-01T00:00:00Z'); + await passes(MinDate(bound), new Date('2026-06-01T00:00:00Z')); + await fails(MinDate(bound), new Date('2025-06-01T00:00:00Z')); + await passes(MaxDate(bound), new Date('2025-06-01T00:00:00Z')); + await fails(MaxDate(bound), new Date('2026-06-01T00:00:00Z')); + }); + + it('@MinDate accepts a thunk so the bound moves', async () => { + await passes(MinDate(() => new Date(Date.now() - 1000)), new Date()); + await fails(MinDate(() => new Date(Date.now() + 60_000)), new Date()); + }); + + it('rejects a non-date', async () => { + await fails(MinDate(new Date(0)), '2026-01-01'); + }); +}); + +describe('arrays', () => { + it('@ArrayUnique by value', async () => { + await passes(ArrayUnique(), [1, 2, 3]); + await fails(ArrayUnique(), [1, 2, 2]); + }); + + it('@ArrayUnique by extracted key', async () => { + const byId = (item: any) => item.id; + await passes(ArrayUnique(byId), [{ id: 1 }, { id: 2 }]); + await fails(ArrayUnique(byId), [{ id: 1 }, { id: 1 }]); + }); + + it('@ArrayContains / @ArrayNotContains', async () => { + await passes(ArrayContains(['a']), ['a', 'b']); + await fails(ArrayContains(['c']), ['a', 'b']); + await passes(ArrayNotContains(['c']), ['a', 'b']); + await fails(ArrayNotContains(['a']), ['a', 'b']); + }); +}); + +describe('@ValidateIf', () => { + class Payment { + @IsIn(['card', 'invoice']) + method: string; + + @ValidateIf(o => o.method === 'card') + @IsString() + cardNumber?: string; + } + + it('skips the constraint when the condition is false', async () => { + const p = new Payment(); + p.method = 'invoice'; + expect(await validate(p)).toEqual([]); + }); + + it('applies the constraint when the condition is true', async () => { + const p = new Payment(); + p.method = 'card'; + + const errors = await validate(p); + expect(errors).toHaveLength(1); + expect(errors[0]!.property).toBe('cardNumber'); + }); + + it('passes when the condition is true and the value is valid', async () => { + const p = new Payment(); + p.method = 'card'; + p.cardNumber = '4111111111111111'; + expect(await validate(p)).toEqual([]); + }); +}); + +describe('@Allow', () => { + it('declares a property so strict unknown-key policies keep it', async () => { + class Dto { + @IsString() + name: string; + + @Allow() + metadata: unknown; + } + + const d = await toInstance( + Dto, + { name: 'x', metadata: { anything: true }, stray: 1 }, + { unknownKeys: 'strip' } + ); + expect(d.metadata).toEqual({ anything: true }); + expect((d as any).stray).toBeUndefined(); + }); +}); + +describe('new validators cooperate with existing options', () => { + it('honours each: true', async () => { + class T { + @IsUUID(4, { each: true }) + ids: string[]; + } + const t = new T(); + t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21']; + expect(await validate(t)).toEqual([]); + + t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21', 'nope']; + expect(await validate(t)).toHaveLength(1); + }); + + it('honours @IsOptional', async () => { + class T { + @IsOptional() + @IsSemVer() + version?: string; + } + const t = new T(); + expect(await validate(t)).toEqual([]); + + t.version = 'bad'; + expect(await validate(t)).toHaveLength(1); + }); + + it('honours a custom message', async () => { + class T { + @IsPort({ message: 'give me a real port' }) + port: number; + } + const t = new T(); + t.port = -1; + + const errors = await validate(t); + expect(errors[0]!.constraints['isPort']).toBe('give me a real port'); + }); +});