From d5ec3d2ca0ece817a6da4aa4d39871843236c95b Mon Sep 17 00:00:00 2001 From: Drew Immerman Date: Sun, 16 Nov 2025 07:34:26 -0500 Subject: [PATCH 1/4] fix: required validation --- src/primitives/network/fqdn-valsan.ts | 2 +- src/primitives/network/ip-address-valsan.ts | 2 +- src/primitives/network/mac-address-valsan.ts | 2 +- src/primitives/network/url-valsan.ts | 2 +- src/primitives/number/is-numeric.ts | 7 +++--- src/valsan-composed.ts | 25 ++++++++++++++----- src/valsan.ts | 25 ++++++++++++++----- .../auth/bearer-token-valsan.spec.ts | 10 +++++++- .../date-time/iso8601-timestamp.spec.ts | 9 ++++++- .../primitives/network/fqdn-valsan.spec.ts | 7 ++++++ .../network/ip-address-valsan.spec.ts | 7 ++++++ .../network/mac-address-valsan.spec.ts | 7 ++++++ .../network/port-number-valsan.spec.ts | 5 ++++ .../primitives/network/url-valsan.spec.ts | 7 ++++++ .../number/integer-validator.spec.ts | 10 +++++++- .../spec/primitives/number/is-numeric.spec.ts | 16 ++++++------ .../primitives/number/max-validator.spec.ts | 11 +++++++- .../primitives/number/min-validator.spec.ts | 10 +++++++- .../primitives/number/range-validator.spec.ts | 10 +++++++- .../number/string-to-number.spec.ts | 2 +- .../primitives/person/email-validator.spec.ts | 10 +++++++- test/spec/primitives/string.spec.ts | 4 +-- .../string/alphanumeric-validator.spec.ts | 7 +++++- .../string/lowercase-sanitizer.spec.ts | 10 +++++++- .../string/max-length-validator.spec.ts | 10 +++++++- .../string/min-length-validator.spec.ts | 10 +++++++- .../primitives/string/trim-sanitizer.spec.ts | 10 +++++++- .../string/uppercase-sanitizer.spec.ts | 10 +++++++- 28 files changed, 204 insertions(+), 43 deletions(-) diff --git a/src/primitives/network/fqdn-valsan.ts b/src/primitives/network/fqdn-valsan.ts index 5c83822..91b1055 100644 --- a/src/primitives/network/fqdn-valsan.ts +++ b/src/primitives/network/fqdn-valsan.ts @@ -32,7 +32,7 @@ export class FqdnValSan extends ValSan { } protected override async normalize(input: string): Promise { - return input?.trim(); + return typeof input === 'string' ? input.trim() : input; } protected async validate(input: string): Promise { diff --git a/src/primitives/network/ip-address-valsan.ts b/src/primitives/network/ip-address-valsan.ts index 35f49e3..38f5eea 100644 --- a/src/primitives/network/ip-address-valsan.ts +++ b/src/primitives/network/ip-address-valsan.ts @@ -26,7 +26,7 @@ export class IpAddressValSan extends ValSan { } protected override async normalize(input: string): Promise { - return input?.trim(); + return typeof input === 'string' ? input.trim() : input; } protected async validate(input: string): Promise { diff --git a/src/primitives/network/mac-address-valsan.ts b/src/primitives/network/mac-address-valsan.ts index 0bf1f0f..20557a1 100644 --- a/src/primitives/network/mac-address-valsan.ts +++ b/src/primitives/network/mac-address-valsan.ts @@ -26,7 +26,7 @@ export class MacAddressValSan extends ValSan { } protected override async normalize(input: string): Promise { - return input?.trim(); + return typeof input === 'string' ? input.trim() : input; } protected async validate(input: string): Promise { diff --git a/src/primitives/network/url-valsan.ts b/src/primitives/network/url-valsan.ts index d872c09..f6563de 100644 --- a/src/primitives/network/url-valsan.ts +++ b/src/primitives/network/url-valsan.ts @@ -22,7 +22,7 @@ export class UrlValSan extends ValSan { } protected override async normalize(input: string): Promise { - return input?.trim(); + return typeof input === 'string' ? input.trim() : input; } protected async validate(input: string): Promise { diff --git a/src/primitives/number/is-numeric.ts b/src/primitives/number/is-numeric.ts index 3b3e95d..048f2cf 100644 --- a/src/primitives/number/is-numeric.ts +++ b/src/primitives/number/is-numeric.ts @@ -1,7 +1,8 @@ export function isNumeric(value: unknown): boolean { return ( - typeof value === 'number' || - typeof value === 'string' || - typeof value === 'bigint' + (typeof value === 'number' || + typeof value === 'bigint' || + (typeof value === 'string' && value.length > 0)) && + !Number.isNaN(Number(value)) ); } diff --git a/src/valsan-composed.ts b/src/valsan-composed.ts index 17b1818..0ab7dc8 100644 --- a/src/valsan-composed.ts +++ b/src/valsan-composed.ts @@ -90,12 +90,25 @@ implements RunsLikeAValSan { async run(input: TInput): Promise> { // Handle optional fields const isOptional = this.options.isOptional; - if (isOptional && (input === undefined || input === null)) { - return { - success: true, - data: input as unknown as TOutput, - errors: [], - }; + if (input === undefined || input === null) { + if (isOptional) { + return { + success: true, + data: input as unknown as TOutput, + errors: [], + }; + } + else { + return { + success: false, + errors: [ + { + code: 'required', + message: 'Value is required', + }, + ], + }; + } } let value: TInput | TOutput = input; diff --git a/src/valsan.ts b/src/valsan.ts index 99bbb32..578d572 100644 --- a/src/valsan.ts +++ b/src/valsan.ts @@ -68,12 +68,25 @@ export abstract class ValSan< public async run(input: TInput): Promise> { // Handle optional fields const isOptional = this.options.isOptional; - if (isOptional && (input === undefined || input === null)) { - return { - success: true, - data: input as unknown as TOutput, - errors: [], - }; + if (input === undefined || input === null) { + if (isOptional) { + return { + success: true, + data: input as unknown as TOutput, + errors: [], + }; + } + else { + return { + success: false, + errors: [ + { + code: 'required', + message: 'Value is required', + }, + ], + }; + } } // Apply normalization before validation diff --git a/test/spec/primitives/auth/bearer-token-valsan.spec.ts b/test/spec/primitives/auth/bearer-token-valsan.spec.ts index 382893a..1c0cb07 100644 --- a/test/spec/primitives/auth/bearer-token-valsan.spec.ts +++ b/test/spec/primitives/auth/bearer-token-valsan.spec.ts @@ -27,7 +27,7 @@ describe('BearerTokenValSan', () => { const validator = new BearerTokenValSan(); const result = await validator.run(undefined); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('rejects string without Bearer prefix', async () => { @@ -50,4 +50,12 @@ describe('BearerTokenValSan', () => { expect(result.success).toBe(true); expect(result.data).toBe('mF_9.B5f-4.1JqM'); }); + + it('rejects array input', async () => { + const validator = new BearerTokenValSan(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run(['Bearer token'] as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/date-time/iso8601-timestamp.spec.ts b/test/spec/primitives/date-time/iso8601-timestamp.spec.ts index b75823d..dae5b41 100644 --- a/test/spec/primitives/date-time/iso8601-timestamp.spec.ts +++ b/test/spec/primitives/date-time/iso8601-timestamp.spec.ts @@ -28,7 +28,7 @@ describe('Iso8601TimestampValSan', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await valSan.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string_or_date'); + expect(result.errors[0].code).toBe('required'); }); it('should sanitize a Date to Date', async () => { @@ -52,4 +52,11 @@ describe('Iso8601TimestampValSan', () => { expect(result.success).toBe(false); expect(result.errors[0].code).toBe('iso8601'); }); + + it('should reject invalid Date objects', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await valSan.run(2 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string_or_date'); + }); }); diff --git a/test/spec/primitives/network/fqdn-valsan.spec.ts b/test/spec/primitives/network/fqdn-valsan.spec.ts index e09a98e..7472196 100644 --- a/test/spec/primitives/network/fqdn-valsan.spec.ts +++ b/test/spec/primitives/network/fqdn-valsan.spec.ts @@ -63,4 +63,11 @@ describe('FqdnValSan', () => { const result = await valSan.run('label-.example.com'); expect(result.success).toBe(false); }); + + it('rejects non-string input', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await valSan.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/network/ip-address-valsan.spec.ts b/test/spec/primitives/network/ip-address-valsan.spec.ts index 6410681..f970530 100644 --- a/test/spec/primitives/network/ip-address-valsan.spec.ts +++ b/test/spec/primitives/network/ip-address-valsan.spec.ts @@ -59,4 +59,11 @@ describe('IpAddressValSan', () => { const result = await valSan.run('2001:db8::1'); expect(result.success).toBe(false); }); + + it('rejects non-string input', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await valSan.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/network/mac-address-valsan.spec.ts b/test/spec/primitives/network/mac-address-valsan.spec.ts index de89e4f..0ef1e65 100644 --- a/test/spec/primitives/network/mac-address-valsan.spec.ts +++ b/test/spec/primitives/network/mac-address-valsan.spec.ts @@ -59,6 +59,13 @@ describe('MacAddressValSan', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await valSan.run(undefined as any); expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('required'); + }); + + it('rejects non-string input', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await valSan.run(123 as any); + expect(result.success).toBe(false); expect(result.errors[0].code).toBe('string'); }); }); diff --git a/test/spec/primitives/network/port-number-valsan.spec.ts b/test/spec/primitives/network/port-number-valsan.spec.ts index 62c3139..38a1075 100644 --- a/test/spec/primitives/network/port-number-valsan.spec.ts +++ b/test/spec/primitives/network/port-number-valsan.spec.ts @@ -25,6 +25,11 @@ describe('PortNumberValSan', () => { expect(result.success).toBe(false); }); + it('rejects non-numeric string', async () => { + const result = await valSan.run('invalidPort'); + expect(result.success).toBe(false); + }); + it('rejects port > 65535', async () => { const result = await valSan.run(70000); expect(result.success).toBe(false); diff --git a/test/spec/primitives/network/url-valsan.spec.ts b/test/spec/primitives/network/url-valsan.spec.ts index 8afb9be..6c5c739 100644 --- a/test/spec/primitives/network/url-valsan.spec.ts +++ b/test/spec/primitives/network/url-valsan.spec.ts @@ -39,4 +39,11 @@ describe('UrlValSan', () => { const result = await valSan.run('https://exa mple.com'); expect(result.success).toBe(false); }); + + it('rejects non-string input', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await valSan.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/number/integer-validator.spec.ts b/test/spec/primitives/number/integer-validator.spec.ts index 7f4b6e1..1a14616 100644 --- a/test/spec/primitives/number/integer-validator.spec.ts +++ b/test/spec/primitives/number/integer-validator.spec.ts @@ -28,7 +28,7 @@ describe('IntegerValidator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('number'); + expect(result.errors[0].code).toBe('required'); }); it('should reject decimal numbers', async () => { @@ -51,4 +51,12 @@ describe('IntegerValidator', () => { expect(result.success).toBe(true); expect(result.data).toBe(1000000); }); + + it('should reject non-number input', async () => { + const validator = new IntegerValidator(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run('not a number' as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('number'); + }); }); diff --git a/test/spec/primitives/number/is-numeric.spec.ts b/test/spec/primitives/number/is-numeric.spec.ts index 1ad8c02..04b5f46 100644 --- a/test/spec/primitives/number/is-numeric.spec.ts +++ b/test/spec/primitives/number/is-numeric.spec.ts @@ -5,18 +5,18 @@ describe('isNumeric', () => { expect(isNumeric(0)).toBeTrue(); expect(isNumeric(123)).toBeTrue(); expect(isNumeric(-456)).toBeTrue(); - expect(isNumeric(NaN)).toBeTrue(); + expect(isNumeric(NaN)).toBeFalse(); expect(isNumeric(Infinity)).toBeTrue(); }); it('should return true for numeric strings', () => { - expect(isNumeric('123')).toBeTrue(); - expect(isNumeric('-456')).toBeTrue(); - expect(isNumeric('0')).toBeTrue(); - expect(isNumeric('1.23')).toBeTrue(); - expect(isNumeric('NaN')).toBeTrue(); - expect(isNumeric('Infinity')).toBeTrue(); - expect(isNumeric('')).toBeTrue(); + expect(isNumeric('123')).withContext('123').toBeTrue(); + expect(isNumeric('-456')).withContext('-456').toBeTrue(); + expect(isNumeric('0')).withContext('0').toBeTrue(); + expect(isNumeric('1.23')).withContext('1.23').toBeTrue(); + expect(isNumeric('NaN')).withContext('NaN').toBeFalse(); + expect(isNumeric('Infinity')).withContext('Infinity').toBeTrue(); + expect(isNumeric('')).withContext('empty string').toBeFalse(); }); it('should return true for bigint values', () => { diff --git a/test/spec/primitives/number/max-validator.spec.ts b/test/spec/primitives/number/max-validator.spec.ts index f8b27f6..9a310b9 100644 --- a/test/spec/primitives/number/max-validator.spec.ts +++ b/test/spec/primitives/number/max-validator.spec.ts @@ -21,7 +21,7 @@ describe('MaxValidator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('number'); + expect(result.errors[0].code).toBe('required'); }); it('should reject numbers exceeding maximum', async () => { @@ -47,4 +47,13 @@ describe('MaxValidator', () => { expect(result1.success).toBe(true); expect(result2.success).toBe(false); }); + + it('should reject non-number input', async () => { + const validator = new MaxValidator({ max: 100 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run('not a number' as any); + + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('number'); + }); }); diff --git a/test/spec/primitives/number/min-validator.spec.ts b/test/spec/primitives/number/min-validator.spec.ts index 96f7128..df53889 100644 --- a/test/spec/primitives/number/min-validator.spec.ts +++ b/test/spec/primitives/number/min-validator.spec.ts @@ -29,7 +29,7 @@ describe('MinValidator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('number'); + expect(result.errors[0].code).toBe('required'); }); it('should work with negative minimums', async () => { @@ -47,4 +47,12 @@ describe('MinValidator', () => { expect(result1.success).toBe(true); expect(result2.success).toBe(false); }); + + it('should reject non-number input', async () => { + const validator = new MinValidator({ min: 0 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run('not a number' as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('number'); + }); }); diff --git a/test/spec/primitives/number/range-validator.spec.ts b/test/spec/primitives/number/range-validator.spec.ts index c199d25..fc80e2d 100644 --- a/test/spec/primitives/number/range-validator.spec.ts +++ b/test/spec/primitives/number/range-validator.spec.ts @@ -37,7 +37,7 @@ describe('RangeValidator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('number'); + expect(result.errors[0].code).toBe('required'); }); it('should reject numbers above maximum', async () => { @@ -62,4 +62,12 @@ describe('RangeValidator', () => { expect(result1.success).toBe(true); expect(result2.success).toBe(false); }); + + it('should reject non-number input', async () => { + const validator = new RangeValidator({ min: 0, max: 100 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run('not a number' as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('number'); + }); }); diff --git a/test/spec/primitives/number/string-to-number.spec.ts b/test/spec/primitives/number/string-to-number.spec.ts index 9779862..b8d5487 100644 --- a/test/spec/primitives/number/string-to-number.spec.ts +++ b/test/spec/primitives/number/string-to-number.spec.ts @@ -43,7 +43,7 @@ describe('StringToNumberValSan', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('number'); + expect(result.errors[0].code).toBe('required'); }); it('should convert empty strings to 0', async () => { diff --git a/test/spec/primitives/person/email-validator.spec.ts b/test/spec/primitives/person/email-validator.spec.ts index f9285cd..bc63ec1 100644 --- a/test/spec/primitives/person/email-validator.spec.ts +++ b/test/spec/primitives/person/email-validator.spec.ts @@ -21,7 +21,7 @@ describe('EmailValidator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should reject plus addressing if not allowed', async () => { @@ -80,4 +80,12 @@ describe('EmailValidator', () => { expect(result.success).toBe(true); expect(result.data).toBe(undefined); }); + + it('should reject non-string input', async () => { + const validator = new EmailValidator(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/string.spec.ts b/test/spec/primitives/string.spec.ts index 7a4aa6c..7222d78 100644 --- a/test/spec/primitives/string.spec.ts +++ b/test/spec/primitives/string.spec.ts @@ -45,7 +45,7 @@ describe('String Primitives', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await sanitizer.run(null as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should allow empty strings with isOptional', async () => { @@ -76,7 +76,7 @@ describe('String Primitives', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await sanitizer.run(null as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should allow empty strings with isOptional', async () => { diff --git a/test/spec/primitives/string/alphanumeric-validator.spec.ts b/test/spec/primitives/string/alphanumeric-validator.spec.ts index 983c36b..f5acd77 100644 --- a/test/spec/primitives/string/alphanumeric-validator.spec.ts +++ b/test/spec/primitives/string/alphanumeric-validator.spec.ts @@ -42,7 +42,12 @@ describe('AlphanumericValidator', () => { const result = await validator.run(input); expect(result.success).toBe(false); if (!result.success) { - expect(result.errors[0].code).toBe('string'); + // null and undefined return 'required' + const expectedCode = + input === null || input === undefined + ? 'required' + : 'string'; + expect(result.errors[0].code).toBe(expectedCode); } } }); diff --git a/test/spec/primitives/string/lowercase-sanitizer.spec.ts b/test/spec/primitives/string/lowercase-sanitizer.spec.ts index a24bfb1..e5eae10 100644 --- a/test/spec/primitives/string/lowercase-sanitizer.spec.ts +++ b/test/spec/primitives/string/lowercase-sanitizer.spec.ts @@ -14,7 +14,7 @@ describe('LowercaseSanitizer', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await sanitizer.run(null as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should allow empty strings with isOptional', async () => { @@ -30,4 +30,12 @@ describe('LowercaseSanitizer', () => { expect(result.success).toBe(true); expect(result.data).toBe('hello world'); }); + + it('should reject non-string input', async () => { + const sanitizer = new LowercaseSanitizer(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await sanitizer.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/string/max-length-validator.spec.ts b/test/spec/primitives/string/max-length-validator.spec.ts index 8dd73af..47e5732 100644 --- a/test/spec/primitives/string/max-length-validator.spec.ts +++ b/test/spec/primitives/string/max-length-validator.spec.ts @@ -36,7 +36,7 @@ describe('MaxLengthValidator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should handle maxLength of 1', async () => { @@ -47,4 +47,12 @@ describe('MaxLengthValidator', () => { expect(result2.success).toBe(false); expect(result2.errors[0].message).toContain('1 character'); }); + + it('should reject non-string input', async () => { + const validator = new MaxLengthValidator({ maxLength: 10 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/string/min-length-validator.spec.ts b/test/spec/primitives/string/min-length-validator.spec.ts index 705dec7..3cbda6a 100644 --- a/test/spec/primitives/string/min-length-validator.spec.ts +++ b/test/spec/primitives/string/min-length-validator.spec.ts @@ -30,7 +30,7 @@ describe('MinLengthValidator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await validator.run(undefined as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should use default minLength of 1', async () => { @@ -45,4 +45,12 @@ describe('MinLengthValidator', () => { expect(result.success).toBe(false); expect(result.errors[0].code).toBe('string_min_len'); }); + + it('should reject non-string input', async () => { + const validator = new MinLengthValidator({ minLength: 3 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await validator.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/string/trim-sanitizer.spec.ts b/test/spec/primitives/string/trim-sanitizer.spec.ts index 21376c7..06647b8 100644 --- a/test/spec/primitives/string/trim-sanitizer.spec.ts +++ b/test/spec/primitives/string/trim-sanitizer.spec.ts @@ -22,7 +22,7 @@ describe('TrimSanitizer', () => { const result = await sanitizer.run(undefined as any); expect(result.success).toBe(false); expect(result.errors).toBeDefined(); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should handle empty strings', async () => { @@ -31,4 +31,12 @@ describe('TrimSanitizer', () => { expect(result.success).toBe(true); expect(result.data).toBe(''); }); + + it('should reject non-string input', async () => { + const sanitizer = new TrimSanitizer(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await sanitizer.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); diff --git a/test/spec/primitives/string/uppercase-sanitizer.spec.ts b/test/spec/primitives/string/uppercase-sanitizer.spec.ts index 1b636d5..e688155 100644 --- a/test/spec/primitives/string/uppercase-sanitizer.spec.ts +++ b/test/spec/primitives/string/uppercase-sanitizer.spec.ts @@ -14,7 +14,7 @@ describe('UppercaseSanitizer', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await sanitizer.run(null as any); expect(result.success).toBe(false); - expect(result.errors[0].code).toBe('string'); + expect(result.errors[0].code).toBe('required'); }); it('should allow empty strings with isOptional', async () => { @@ -30,4 +30,12 @@ describe('UppercaseSanitizer', () => { expect(result.success).toBe(true); expect(result.data).toBe('HELLO WORLD'); }); + + it('should reject non-string input', async () => { + const sanitizer = new UppercaseSanitizer(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await sanitizer.run(123 as any); + expect(result.success).toBe(false); + expect(result.errors[0].code).toBe('string'); + }); }); From fe83c88a19e7ae6eb95fd8641e2f6e96ecec3e00 Mon Sep 17 00:00:00 2001 From: Drew Immerman Date: Sun, 16 Nov 2025 07:54:43 -0500 Subject: [PATCH 2/4] feat: add .copy() to valsans --- src/valsan-composed.ts | 12 +++++++ src/valsan.ts | 8 +++++ test/spec/valsan/copy.spec.ts | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 test/spec/valsan/copy.spec.ts diff --git a/src/valsan-composed.ts b/src/valsan-composed.ts index 0ab7dc8..932acf6 100644 --- a/src/valsan-composed.ts +++ b/src/valsan-composed.ts @@ -71,6 +71,18 @@ implements RunsLikeAValSan { return [...this.steps]; } + public copy( + options: ComposedValSanOptions + ): ComposedValSan { + const constructor = this.constructor as new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + steps: RunsLikeAValSan[], + options: ComposedValSanOptions + ) => ComposedValSan; + + return new constructor(this.steps, { ...this.options, ...options }); + } + public rules(): RuleSet { const combinedRules: RuleSet = {}; diff --git a/src/valsan.ts b/src/valsan.ts index 578d572..cd3c4c3 100644 --- a/src/valsan.ts +++ b/src/valsan.ts @@ -55,6 +55,14 @@ export abstract class ValSan< return {}; } + public copy(options: ValSanOptions): ValSan { + const constructor = this.constructor as new ( + options: ValSanOptions + ) => ValSan; + + return new constructor({ ...this.options, ...options }); + } + /** * Optional normalization step applied before validation. */ diff --git a/test/spec/valsan/copy.spec.ts b/test/spec/valsan/copy.spec.ts new file mode 100644 index 0000000..aaebd50 --- /dev/null +++ b/test/spec/valsan/copy.spec.ts @@ -0,0 +1,60 @@ +import 'jasmine'; +import { ComposedValSan } from '../../../src'; +import { TestValSan, TypeTransformValSan } from './test-implementations'; + +describe('ValSan - Copy', () => { + it('should create a copy with modified options', () => { + const original = new TestValSan({ isOptional: false }); + const copy = original.copy({ isOptional: true }); + + expect(copy.options.isOptional).toBe(true); + expect(original.options.isOptional).toBe(false); + }); + + it('copied instance should function independently', async () => { + const original = new TestValSan({ isOptional: false }); + const copy = original.copy({ isOptional: true }); + + const originalResult = await original.run( + undefined as unknown as string + ); + expect(originalResult.success).toBe(false); + + const copyResult = await copy.run(undefined as unknown as string); + expect(copyResult.success).toBe(true); + expect(copyResult.data).toBeUndefined(); + }); +}); + +describe('ComposedValSan - Copy', () => { + it('should create a copy with modified options', () => { + const step1 = new TestValSan(); + const step2 = new TypeTransformValSan(); + + const original = new ComposedValSan([step1, step2], { + isOptional: false, + }); + const copy = original.copy({ isOptional: true }); + expect(copy.options.isOptional).toBe(true); + expect(original.options.isOptional).toBe(false); + }); + + it('copied instance should function independently', async () => { + const step1 = new TestValSan(); + const step2 = new TypeTransformValSan(); + + const original = new ComposedValSan([step1, step2], { + isOptional: false, + }); + + const copy = original.copy({ isOptional: true }); + const originalResult = await original.run( + undefined as unknown as string + ); + + expect(originalResult.success).toBe(false); + const copyResult = await copy.run(undefined as unknown as string); + expect(copyResult.success).toBe(true); + expect(copyResult.data).toBeUndefined(); + }); +}); From c69ea273b655dd0327026906754ab3e23f9000e3 Mon Sep 17 00:00:00 2001 From: Drew Immerman Date: Sun, 16 Nov 2025 08:11:53 -0500 Subject: [PATCH 3/4] refactor: add valsan base-class --- src/valsan-base.ts | 32 ++++++++++++++++++++++++++++++++ src/valsan-composed.ts | 32 +++++++------------------------- src/valsan.ts | 39 +++++++++++---------------------------- 3 files changed, 50 insertions(+), 53 deletions(-) create mode 100644 src/valsan-base.ts diff --git a/src/valsan-base.ts b/src/valsan-base.ts new file mode 100644 index 0000000..8c725ad --- /dev/null +++ b/src/valsan-base.ts @@ -0,0 +1,32 @@ +import { ValSanTypes } from './types/types'; +import { SanitizeResult, ValSanOptions } from './valsan'; + +export class BaseValSan { + public type: ValSanTypes = 'unknown'; + public example = ''; + public format?: string; + + public options: ValSanOptions; + + public checkRequired(input: unknown): SanitizeResult { + if (this.options.isOptional) { + return { + success: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: input as any, + errors: [], + }; + } + else { + return { + success: false, + errors: [ + { + code: 'required', + message: 'Value is required', + }, + ], + }; + } + } +} diff --git a/src/valsan-composed.ts b/src/valsan-composed.ts index 932acf6..20b1c4c 100644 --- a/src/valsan-composed.ts +++ b/src/valsan-composed.ts @@ -1,10 +1,10 @@ import { RuleSet } from './rules/rule'; -import { ValSanTypes } from './types/types'; import { RunsLikeAValSan as RunsLikeAValSan, SanitizeResult, ValSanOptions, } from './valsan'; +import { BaseValSan } from './valsan-base'; export interface ComposedValSanOptions extends ValSanOptions { /** @@ -41,10 +41,8 @@ export interface ComposedValSanOptions extends ValSanOptions { * ``` */ export class ComposedValSan -implements RunsLikeAValSan { - public type: ValSanTypes = 'unknown'; - public example = ''; - + extends BaseValSan + implements RunsLikeAValSan { /** * Creates a composed validator from an array of ValSan steps. * @@ -55,8 +53,10 @@ implements RunsLikeAValSan { constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any public readonly steps: RunsLikeAValSan[], - public readonly options: ComposedValSanOptions = {} + public override readonly options: ComposedValSanOptions = {} ) { + super(); + if (steps.length === 0) { throw new Error('ComposedValSan requires at least one step'); } @@ -101,26 +101,8 @@ implements RunsLikeAValSan { async run(input: TInput): Promise> { // Handle optional fields - const isOptional = this.options.isOptional; if (input === undefined || input === null) { - if (isOptional) { - return { - success: true, - data: input as unknown as TOutput, - errors: [], - }; - } - else { - return { - success: false, - errors: [ - { - code: 'required', - message: 'Value is required', - }, - ], - }; - } + return this.checkRequired(input); } let value: TInput | TOutput = input; diff --git a/src/valsan.ts b/src/valsan.ts index cd3c4c3..44a0de5 100644 --- a/src/valsan.ts +++ b/src/valsan.ts @@ -2,6 +2,7 @@ import { validationError } from './errors'; import { Rule } from './rules'; import { RuleSet } from './rules/rule'; import { ValSanTypes } from './types/types'; +import { BaseValSan } from './valsan-base'; export interface ValidationError { field?: string; @@ -41,15 +42,15 @@ export interface RunsLikeAValSan { } export abstract class ValSan< - TInput = unknown, - TOutput = TInput, - TNormalized = TInput | TOutput, -> implements RunsLikeAValSan { - public constructor(public readonly options: ValSanOptions = {}) {} - - public type: ValSanTypes = 'unknown'; - public example = ''; - public format?: string; + TInput = unknown, + TOutput = TInput, + TNormalized = TInput | TOutput, + > + extends BaseValSan + implements RunsLikeAValSan { + public constructor(public override readonly options: ValSanOptions = {}) { + super(); + } public rules(): RuleSet { return {}; @@ -75,26 +76,8 @@ export abstract class ValSan< public async run(input: TInput): Promise> { // Handle optional fields - const isOptional = this.options.isOptional; if (input === undefined || input === null) { - if (isOptional) { - return { - success: true, - data: input as unknown as TOutput, - errors: [], - }; - } - else { - return { - success: false, - errors: [ - { - code: 'required', - message: 'Value is required', - }, - ], - }; - } + return this.checkRequired(input); } // Apply normalization before validation From 5bfe5857dde1d3df58506ca6139cf312a631c0ba Mon Sep 17 00:00:00 2001 From: Drew Immerman Date: Sun, 16 Nov 2025 08:14:11 -0500 Subject: [PATCH 4/4] v2.2.0 --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index eaff58d..b08c111 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "valsan", - "version": "2.1.1", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "valsan", - "version": "2.1.1", + "version": "2.2.0", "license": "MIT", "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -1655,9 +1655,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001754", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", - "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", "dev": true, "funding": [ { @@ -2022,9 +2022,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.253", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.253.tgz", - "integrity": "sha512-O0tpQ/35rrgdiGQ0/OFWhy1itmd9A6TY9uQzlqj3hKSu/aYpe7UIn5d7CU2N9myH6biZiWF3VMZVuup8pw5U9w==", + "version": "1.5.254", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", + "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index 86c3044..599266e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "valsan", - "version": "2.1.1", + "version": "2.2.0", "description": "Validation and sanitization library for TypeScript", "private": "true", "typescript-template": {