diff --git a/src/validation/number.ts b/src/validation/number.ts index a05a2a2c..cad3f812 100644 --- a/src/validation/number.ts +++ b/src/validation/number.ts @@ -1,6 +1,6 @@ import type { ValidationError, ValidationErrorPath } from '../errors' import type { NonBooleanJsfSchema, SchemaValue } from '../types' -import { getSchemaType } from './schema' +import { schemaTypeIncludes } from './schema' interface DecimalParts { digits: bigint @@ -81,13 +81,12 @@ export function validateNumber( path: ValidationErrorPath = [], ): ValidationError[] { const errors: ValidationError[] = [] - const schemaType = getSchemaType(schema) if (typeof value !== 'number') { return [] } - if (schemaType !== undefined && !['number', 'integer'].includes(schemaType as string)) { + if (!schemaTypeIncludes(schema, 'number', 'integer')) { return [] } diff --git a/src/validation/schema.ts b/src/validation/schema.ts index acfdfc53..38f97e22 100644 --- a/src/validation/schema.ts +++ b/src/validation/schema.ts @@ -66,6 +66,21 @@ export function getSchemaType(schema: JsfSchema): JsfSchemaType | JsfSchemaType[ return undefined } +/** + * Whether the schema's type (a single type or a list of types) includes any of the given types. + * A schema without a type places no restriction, so it matches every type. + */ +export function schemaTypeIncludes(schema: JsfSchema, ...types: JsfSchemaType[]): boolean { + const schemaType = getSchemaType(schema) + + if (schemaType === undefined) { + return true + } + + const schemaTypes = Array.isArray(schemaType) ? schemaType : [schemaType] + return types.some(type => schemaTypes.includes(type)) +} + /** * Validate the type of a value against a schema * @param value - The value to validate diff --git a/src/validation/string.ts b/src/validation/string.ts index 9cb67482..1b752d9a 100644 --- a/src/validation/string.ts +++ b/src/validation/string.ts @@ -1,7 +1,7 @@ import type { ValidationError, ValidationErrorPath } from '../errors' import type { NonBooleanJsfSchema, SchemaValue } from '../types' import { validateFormat } from './format' -import { getSchemaType } from './schema' +import { schemaTypeIncludes } from './schema' /** * Validate a string against a schema @@ -21,13 +21,12 @@ export function validateString( path: ValidationErrorPath = [], ): ValidationError[] { const errors: ValidationError[] = [] - const schemaType = getSchemaType(schema) if (typeof value !== 'string') { return [] } - if (schemaType !== undefined && schemaType !== 'string') { + if (!schemaTypeIncludes(schema, 'string')) { return [] } diff --git a/test/validation/number.test.ts b/test/validation/number.test.ts index eedadaec..f10d53ed 100644 --- a/test/validation/number.test.ts +++ b/test/validation/number.test.ts @@ -96,6 +96,44 @@ describe('number validation', () => { ]) }) + it('validates numeric keywords when the type also allows null', () => { + const nullableNumber = { type: ['number', 'null'], minimum: 1, maximum: 39, multipleOf: 2 } + + expect(validateSchema(null, nullableNumber)).toEqual([]) + expect(validateSchema(20, nullableNumber)).toEqual([]) + expect(validateSchema(40, nullableNumber)).toEqual([ + errorLike({ + path: [], + validation: 'maximum', + }), + ]) + expect(validateSchema(0, nullableNumber)).toEqual([ + errorLike({ + path: [], + validation: 'minimum', + }), + ]) + expect(validateSchema(21, nullableNumber)).toEqual([ + errorLike({ + path: [], + validation: 'multipleOf', + }), + ]) + + expect(validateSchema(40, { type: ['integer', 'null'], maximum: 39 })).toEqual([ + errorLike({ + path: [], + validation: 'maximum', + }), + ]) + expect(validateSchema(40, { type: ['null', 'number'], exclusiveMaximum: 40 })).toEqual([ + errorLike({ + path: [], + validation: 'exclusiveMaximum', + }), + ]) + }) + it('validates the number against the exclusiveMinimum and exclusiveMaximum properties', () => { expect(validateSchema(11, { type: 'number', exclusiveMinimum: 10 })).toEqual([]) expect(validateSchema(10, { type: 'number', exclusiveMinimum: 10 })).toEqual([ diff --git a/test/validation/string.test.ts b/test/validation/string.test.ts index b5317f92..eae13423 100644 --- a/test/validation/string.test.ts +++ b/test/validation/string.test.ts @@ -113,4 +113,28 @@ describe('string validation', () => { // Error message now includes a random example, so we can't do an exact match expect(response.formErrors?.name).toMatch(/^Must have a valid format. E.g./) }) + + it('validates string keywords when the type also allows null', () => { + const { handleValidation } = createHeadlessForm({ + type: 'object', + properties: { + short: { type: ['string', 'null'], maxLength: 3 }, + long: { type: ['string', 'null'], minLength: 3 }, + code: { type: ['null', 'string'], pattern: '^[a-z]+$' }, + }, + }) + + expect(handleValidation({ short: null, long: null, code: null })).not.toHaveProperty('formErrors') + expect(handleValidation({ short: 'abc', long: 'abc', code: 'abc' })).not.toHaveProperty( + 'formErrors', + ) + + expect(handleValidation({ short: 'abcd', long: 'ab', code: '123' })).toMatchObject({ + formErrors: { + short: 'Please insert up to 3 characters', + long: 'Please insert at least 3 characters', + code: expect.stringMatching(/^Must have a valid format. E.g./), + }, + }) + }) })