@tldraw/validate 5.1.0 → 5.2.0-canary.05c017c18b15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS.md +888 -0
- package/README.md +9 -1
- package/dist-cjs/index.d.ts +5 -4
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/validation.js +126 -173
- package/dist-cjs/lib/validation.js.map +2 -2
- package/dist-esm/index.d.mts +5 -4
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/validation.mjs +126 -173
- package/dist-esm/lib/validation.mjs.map +2 -2
- package/package.json +4 -3
- package/src/lib/validation.ts +163 -184
- package/src/test/arrays.test.ts +103 -0
- package/src/test/dicts.test.ts +86 -0
- package/src/test/enums.test.ts +28 -0
- package/src/test/errors.test.ts +124 -0
- package/src/test/index-key.test.ts +15 -0
- package/src/test/json.test.ts +108 -0
- package/src/test/model.test.ts +34 -0
- package/src/test/nullable-optional.test.ts +75 -0
- package/src/test/numbers.test.ts +114 -0
- package/src/test/objects.test.ts +137 -0
- package/src/test/or.test.ts +13 -0
- package/src/test/primitives.test.ts +67 -0
- package/src/test/refine-check.test.ts +82 -0
- package/src/test/unions.test.ts +147 -0
- package/src/test/urls.test.ts +59 -0
- package/src/test/validator.test.ts +60 -0
- package/src/test/validation.test.ts +0 -230
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
|
|
3
|
+
describe('§10 Objects', () => {
|
|
4
|
+
it('[O1] object validates each configured property, prefixing the property name', () => {
|
|
5
|
+
const validator = T.object({ name: T.string, age: T.number })
|
|
6
|
+
const value = { name: 'Alice', age: 25 }
|
|
7
|
+
expect(validator.validate(value)).toBe(value)
|
|
8
|
+
|
|
9
|
+
expect(() => validator.validate({ name: 'Alice', age: 'old' })).toThrow(
|
|
10
|
+
'At age: Expected number, got a string'
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('[O1] a missing property is validated as undefined', () => {
|
|
15
|
+
const validator = T.object({ name: T.string, age: T.number })
|
|
16
|
+
expect(() => validator.validate({ name: 'Alice' })).toThrow(
|
|
17
|
+
'At age: Expected number, got undefined'
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('[O2] unknown properties are rejected', () => {
|
|
22
|
+
expect(() => T.object({ moo: T.literal('cow') }).validate({ moo: 'cow', cow: 'moo' })).toThrow(
|
|
23
|
+
'At cow: Unexpected property'
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('[O3] allowUnknownProperties returns a new validator accepting unvalidated extras', () => {
|
|
28
|
+
const strict = T.object({ name: T.string })
|
|
29
|
+
const loose = strict.allowUnknownProperties()
|
|
30
|
+
|
|
31
|
+
const value = { name: 'Alice', extra: () => {} }
|
|
32
|
+
expect(loose.validate(value)).toBe(value)
|
|
33
|
+
|
|
34
|
+
// the original validator is unchanged
|
|
35
|
+
expect(() => strict.validate({ name: 'Alice', extra: 1 })).toThrow(
|
|
36
|
+
'At extra: Unexpected property'
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('[O4] non-objects and null are rejected', () => {
|
|
41
|
+
const validator = T.object({ a: T.number })
|
|
42
|
+
expect(() => validator.validate(null)).toThrow('Expected object, got null')
|
|
43
|
+
expect(() => validator.validate('x')).toThrow('Expected object, got a string')
|
|
44
|
+
expect(() => validator.validate(undefined)).toThrow('Expected object, got undefined')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('[O4] arrays pass the object check', () => {
|
|
48
|
+
const empty: never[] = []
|
|
49
|
+
expect(T.object({}).validate(empty)).toBe(empty)
|
|
50
|
+
expect(() => T.object({}).validate([1])).toThrow('At 0: Unexpected property')
|
|
51
|
+
expect(() => T.object({ a: T.number }).validate([])).toThrow(
|
|
52
|
+
'At a: Expected number, got undefined'
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('[O5] extend merges configs, with extension keys overriding', () => {
|
|
57
|
+
const base = T.object({ a: T.number, b: T.number })
|
|
58
|
+
const extended = base.extend({ b: T.string as any as T.Validatable<number>, c: T.boolean })
|
|
59
|
+
|
|
60
|
+
const value = { a: 1, b: 'x', c: true }
|
|
61
|
+
expect(extended.validate(value as any)).toBe(value)
|
|
62
|
+
expect(() => extended.validate({ a: 1, b: 2, c: true } as any)).toThrow(
|
|
63
|
+
'At b: Expected string, got a number'
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// the base validator is unchanged
|
|
67
|
+
expect(base.validate({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('[O5] the extended validator rejects unknown properties even when the receiver allowed them', () => {
|
|
71
|
+
const validator = T.object({ a: T.number }).allowUnknownProperties().extend({ b: T.string })
|
|
72
|
+
expect(() => validator.validate({ a: 1, b: 'x', c: true })).toThrow('At c: Unexpected property')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('[O6] known-good validation returns the known-good object when nothing changed', () => {
|
|
76
|
+
const validator = T.object({ a: T.number, nested: T.object({ b: T.string }) })
|
|
77
|
+
const knownGood = validator.validate({ a: 1, nested: { b: 'x' } })
|
|
78
|
+
|
|
79
|
+
// new outer and inner objects, structurally equal
|
|
80
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, { a: 1, nested: { b: 'x' } })).toBe(
|
|
81
|
+
knownGood
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('[O6] known-good validation returns the new object when a property changed, revalidating only changes', () => {
|
|
86
|
+
let calls = 0
|
|
87
|
+
const counting = new T.Validator<number>((value) => {
|
|
88
|
+
calls++
|
|
89
|
+
return T.number.validate(value)
|
|
90
|
+
})
|
|
91
|
+
const validator = T.object({ a: counting, b: counting })
|
|
92
|
+
const knownGood = validator.validate({ a: 1, b: 2 })
|
|
93
|
+
calls = 0
|
|
94
|
+
|
|
95
|
+
const next = { a: 1, b: 3 }
|
|
96
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
|
97
|
+
expect(calls).toBe(1)
|
|
98
|
+
|
|
99
|
+
expect(() => validator.validateUsingKnownGoodVersion(knownGood, { a: 1, b: 'x' })).toThrow(
|
|
100
|
+
'At b: Expected number, got a string'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('[O6] known-good validation rejects non-objects and added unknown properties', () => {
|
|
105
|
+
const validator = T.object({ a: T.number })
|
|
106
|
+
const knownGood = validator.validate({ a: 1 })
|
|
107
|
+
expect(() => validator.validateUsingKnownGoodVersion(knownGood, 'x')).toThrow(
|
|
108
|
+
'Expected object, got a string'
|
|
109
|
+
)
|
|
110
|
+
expect(() => validator.validateUsingKnownGoodVersion(knownGood, { a: 1, b: 2 })).toThrow(
|
|
111
|
+
'At b: Unexpected property'
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('[O8] known-good validation detects changes to unknown properties', () => {
|
|
116
|
+
const validator = T.object({ a: T.number }).allowUnknownProperties()
|
|
117
|
+
|
|
118
|
+
const knownGood = validator.validate({ a: 1, extra: 1 })
|
|
119
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, { a: 1, extra: 1 })).toBe(knownGood)
|
|
120
|
+
|
|
121
|
+
const changed = { a: 1, extra: 2 }
|
|
122
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, changed)).toBe(changed)
|
|
123
|
+
|
|
124
|
+
const added = { a: 1, extra: 1, more: true }
|
|
125
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, added)).toBe(added)
|
|
126
|
+
|
|
127
|
+
const removed = { a: 1 }
|
|
128
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, removed)).toBe(removed)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('[O7] known-good validation detects removed keys', () => {
|
|
132
|
+
const validator = T.object({ a: T.number, b: T.number.optional() })
|
|
133
|
+
const knownGood = validator.validate({ a: 1, b: 2 })
|
|
134
|
+
const next = { a: 1 }
|
|
135
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
|
|
3
|
+
describe('§17 Either-of', () => {
|
|
4
|
+
it('[OR1] returns the first validator’s result when it passes, else the second’s', () => {
|
|
5
|
+
const stringOrNumber = T.or(T.string, T.number)
|
|
6
|
+
expect(stringOrNumber.validate('hello')).toBe('hello')
|
|
7
|
+
expect(stringOrNumber.validate(42)).toBe(42)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('[OR2] when both fail, the second validator’s error propagates', () => {
|
|
11
|
+
expect(() => T.or(T.string, T.number).validate(true)).toThrow('Expected number, got a boolean')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
|
|
3
|
+
describe('§6 Primitives', () => {
|
|
4
|
+
it('[P1] string, boolean, and bigint validate by typeof', () => {
|
|
5
|
+
expect(T.string.validate('hello')).toBe('hello')
|
|
6
|
+
expect(T.string.validate('')).toBe('')
|
|
7
|
+
expect(() => T.string.validate(123)).toThrow('Expected string, got a number')
|
|
8
|
+
|
|
9
|
+
expect(T.boolean.validate(true)).toBe(true)
|
|
10
|
+
expect(T.boolean.validate(false)).toBe(false)
|
|
11
|
+
expect(() => T.boolean.validate('true')).toThrow('Expected boolean, got a string')
|
|
12
|
+
|
|
13
|
+
expect(T.bigint.validate(123n)).toBe(123n)
|
|
14
|
+
expect(() => T.bigint.validate(123)).toThrow('Expected bigint, got a number')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('[P2] type-mismatch messages describe the actual value', () => {
|
|
18
|
+
expect(() => T.string.validate(null)).toThrow('Expected string, got null')
|
|
19
|
+
expect(() => T.string.validate(undefined)).toThrow('Expected string, got undefined')
|
|
20
|
+
expect(() => T.string.validate([1])).toThrow('Expected string, got an array')
|
|
21
|
+
expect(() => T.string.validate({})).toThrow('Expected string, got an object')
|
|
22
|
+
expect(() => T.string.validate(1)).toThrow('Expected string, got a number')
|
|
23
|
+
expect(() => T.string.validate(true)).toThrow('Expected string, got a boolean')
|
|
24
|
+
expect(() => T.string.validate(1n)).toThrow('Expected string, got a bigint')
|
|
25
|
+
expect(() => T.string.validate(() => {})).toThrow('Expected string, got a function')
|
|
26
|
+
expect(() => T.string.validate(Symbol('s'))).toThrow('Expected string, got a symbol')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('[P3] unknown and any accept every value as-is', () => {
|
|
30
|
+
const obj = { a: 1 }
|
|
31
|
+
expect(T.unknown.validate(obj)).toBe(obj)
|
|
32
|
+
expect(T.unknown.validate(undefined)).toBe(undefined)
|
|
33
|
+
expect(T.unknown.validate(null)).toBe(null)
|
|
34
|
+
expect(T.any.validate(obj)).toBe(obj)
|
|
35
|
+
expect(T.any.validate(undefined)).toBe(undefined)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('[P4] literal accepts exactly the expected value', () => {
|
|
39
|
+
expect(T.literal('active').validate('active')).toBe('active')
|
|
40
|
+
expect(T.literal(1).validate(1)).toBe(1)
|
|
41
|
+
expect(T.literal(true).validate(true)).toBe(true)
|
|
42
|
+
|
|
43
|
+
expect(() => T.literal('a').validate('b')).toThrow('Expected a, got "b"')
|
|
44
|
+
expect(() => T.literal(1).validate(2)).toThrow('Expected 1, got 2')
|
|
45
|
+
expect(() => T.literal(1).validate(undefined)).toThrow('Expected 1, got undefined')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('[P5] array accepts any array without validating items', () => {
|
|
49
|
+
const arr = [1, 'two', null, () => {}]
|
|
50
|
+
expect(T.array.validate(arr)).toBe(arr)
|
|
51
|
+
expect(() => T.array.validate('not array')).toThrow('Expected an array, got a string')
|
|
52
|
+
expect(() => T.array.validate({})).toThrow('Expected an array, got an object')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('[P6] unknownObject accepts any non-null object and rejects null and primitives', () => {
|
|
56
|
+
const obj = { any: 'properties' }
|
|
57
|
+
expect(T.unknownObject.validate(obj)).toBe(obj)
|
|
58
|
+
expect(() => T.unknownObject.validate(null)).toThrow('Expected object, got null')
|
|
59
|
+
expect(() => T.unknownObject.validate('x')).toThrow('Expected object, got a string')
|
|
60
|
+
expect(() => T.unknownObject.validate(5)).toThrow('Expected object, got a number')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('[P6] unknownObject accepts arrays', () => {
|
|
64
|
+
const arr = [1, 2, 3]
|
|
65
|
+
expect(T.unknownObject.validate(arr)).toBe(arr)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
import { ValidationError } from '../lib/validation'
|
|
3
|
+
|
|
4
|
+
describe('§4 Refinement: refine and check', () => {
|
|
5
|
+
it('[RC1] refine transforms the validated value, including to a new type', () => {
|
|
6
|
+
const stringToNumber = T.string.refine((str) => parseInt(str, 10))
|
|
7
|
+
expect(stringToNumber.validate('42')).toBe(42)
|
|
8
|
+
|
|
9
|
+
const prefixedString = T.string.refine((str) =>
|
|
10
|
+
str.startsWith('prefix:') ? str : `prefix:${str}`
|
|
11
|
+
)
|
|
12
|
+
expect(prefixedString.validate('test')).toBe('prefix:test')
|
|
13
|
+
expect(prefixedString.validate('prefix:existing')).toBe('prefix:existing')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('[RC1] a ValidationError thrown by the refinement propagates with path context', () => {
|
|
17
|
+
const stringToNumber = T.string.refine((str) => {
|
|
18
|
+
const num = parseInt(str, 10)
|
|
19
|
+
if (isNaN(num)) throw new ValidationError('Invalid number format')
|
|
20
|
+
return num
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
expect(() => stringToNumber.validate('not-a-number')).toThrow('Invalid number format')
|
|
24
|
+
|
|
25
|
+
const nested = T.object({ count: stringToNumber })
|
|
26
|
+
expect(() => nested.validate({ count: 'nope' })).toThrow('At count: Invalid number format')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('[RC2] known-good validation skips the refinement when the base reports no change', () => {
|
|
30
|
+
let calls = 0
|
|
31
|
+
const validator = T.arrayOf(T.number).refine((arr) => {
|
|
32
|
+
calls++
|
|
33
|
+
return arr
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const knownGood = validator.validate([1, 2])
|
|
37
|
+
expect(calls).toBe(1)
|
|
38
|
+
|
|
39
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, [1, 2])).toBe(knownGood)
|
|
40
|
+
expect(calls).toBe(1)
|
|
41
|
+
|
|
42
|
+
const next = [1, 3]
|
|
43
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
|
44
|
+
expect(calls).toBe(2)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('[RC3] known-good validation accepts a new value Object.is-equal to the previous output without input validation', () => {
|
|
48
|
+
const stringToNumber = T.string.refine((str) => parseInt(str, 10))
|
|
49
|
+
// 42 is not a valid input (not a string), but it equals the previous output.
|
|
50
|
+
expect(stringToNumber.validateUsingKnownGoodVersion(42, 42)).toBe(42)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('[RC4] an unnamed check passes the value through and adds no path segment', () => {
|
|
54
|
+
const evenNumber = T.number.check((value) => {
|
|
55
|
+
if (value % 2 !== 0) throw new ValidationError('Expected even number')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(evenNumber.validate(4)).toBe(4)
|
|
59
|
+
expect(evenNumber.validate(0)).toBe(0)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
evenNumber.validate(3)
|
|
63
|
+
throw new Error('should have thrown')
|
|
64
|
+
} catch (error) {
|
|
65
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
66
|
+
expect((error as ValidationError).rawMessage).toBe('Expected even number')
|
|
67
|
+
expect((error as ValidationError).path).toEqual([])
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('[RC5] a named check prefixes failures with a (check name) segment', () => {
|
|
72
|
+
const positive = T.number.check('positive', (value) => {
|
|
73
|
+
if (value <= 0) throw new ValidationError('Must be positive')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(positive.validate(5)).toBe(5)
|
|
77
|
+
expect(() => positive.validate(-1)).toThrow('At (check positive): Must be positive')
|
|
78
|
+
|
|
79
|
+
const nested = T.object({ x: positive })
|
|
80
|
+
expect(() => nested.validate({ x: -1 })).toThrow('At x(check positive): Must be positive')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
|
|
3
|
+
const cat = T.object({ type: T.literal('cat'), id: T.string, meow: T.boolean })
|
|
4
|
+
const dog = T.object({ type: T.literal('dog'), id: T.string, bark: T.boolean })
|
|
5
|
+
const animal = T.union('type', { cat, dog })
|
|
6
|
+
|
|
7
|
+
describe('§12 Discriminated unions', () => {
|
|
8
|
+
it('[U1] selects the variant by discriminator and returns the input on success', () => {
|
|
9
|
+
const value = { type: 'cat', id: 'abc123', meow: true }
|
|
10
|
+
expect(animal.validate(value)).toBe(value)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('[U2] rejects non-object inputs', () => {
|
|
14
|
+
expect(() => animal.validate('cat')).toThrow('Expected an object, got a string')
|
|
15
|
+
expect(() => animal.validate(null)).toThrow('Expected an object, got null')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('[U3] rejects a missing or non-string discriminator', () => {
|
|
19
|
+
expect(() => animal.validate({})).toThrow('Expected a string for key "type", got undefined')
|
|
20
|
+
expect(() => animal.validate({ type: 1 })).toThrow(
|
|
21
|
+
'Expected a string for key "type", got a number'
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('[U4] rejects unmatched variants, listing the known ones at the discriminator path', () => {
|
|
26
|
+
expect(() =>
|
|
27
|
+
T.object({ animal }).validate({ animal: { type: 'cow', moo: true, id: 'abc123' } })
|
|
28
|
+
).toThrow('At animal.type: Expected one of "cat" or "dog", got "cow"')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('[U5] prefixes variant validation failures with the (key = variant) segment', () => {
|
|
32
|
+
expect(() =>
|
|
33
|
+
T.object({ animal }).validate({ animal: { type: 'cat', meow: 'yes', id: 'abc123' } })
|
|
34
|
+
).toThrow('At animal(type = cat).meow: Expected boolean, got a string')
|
|
35
|
+
|
|
36
|
+
expect(() =>
|
|
37
|
+
T.model('animal', animal).validate({ type: 'cat', moo: true, id: 'abc123' })
|
|
38
|
+
).toThrow('At animal(type = cat).meow: Expected boolean, got undefined')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('[U6] validateUnknownVariants handles unmatched variants with the handler result', () => {
|
|
42
|
+
let seenVariant = ''
|
|
43
|
+
const lenient = animal.validateUnknownVariants((value, variant) => {
|
|
44
|
+
seenVariant = variant
|
|
45
|
+
return value as any
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const unknownValue = { type: 'cow', moo: true }
|
|
49
|
+
expect(lenient.validate(unknownValue)).toBe(unknownValue)
|
|
50
|
+
expect(seenVariant).toBe('cow')
|
|
51
|
+
|
|
52
|
+
// known variants still validate normally
|
|
53
|
+
expect(() => lenient.validate({ type: 'cat', id: 'x', meow: 'yes' })).toThrow(
|
|
54
|
+
'At (type = cat).meow: Expected boolean, got a string'
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('[U6] a handler returning a new object trips the dev same-value check on the validate path', () => {
|
|
59
|
+
const lenient = animal.validateUnknownVariants((value) => ({ ...value }) as any)
|
|
60
|
+
expect(() => lenient.validate({ type: 'cow' })).toThrow(
|
|
61
|
+
'Validator functions must return the same value they were passed'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// the known-good path does not run that check
|
|
65
|
+
const out = lenient.validateUsingKnownGoodVersion({ type: 'cow' } as any, { type: 'emu' })
|
|
66
|
+
expect(out).toEqual({ type: 'emu' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('[U7] known-good validation with an unchanged discriminator preserves identity', () => {
|
|
70
|
+
const knownGood = animal.validate({ type: 'cat', id: 'x', meow: true })
|
|
71
|
+
expect(
|
|
72
|
+
animal.validateUsingKnownGoodVersion(knownGood, { type: 'cat', id: 'x', meow: true })
|
|
73
|
+
).toBe(knownGood)
|
|
74
|
+
|
|
75
|
+
const next = { type: 'cat', id: 'x', meow: false }
|
|
76
|
+
expect(animal.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
|
77
|
+
expect(() =>
|
|
78
|
+
animal.validateUsingKnownGoodVersion(knownGood, { type: 'cat', id: 'x', meow: 'no' })
|
|
79
|
+
).toThrow('At (type = cat).meow: Expected boolean, got a string')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('[U7] a changed discriminator falls back to a full validation of the new variant', () => {
|
|
83
|
+
const knownGood = animal.validate({ type: 'cat', id: 'x', meow: true })
|
|
84
|
+
const next = { type: 'dog', id: 'x', bark: true }
|
|
85
|
+
expect(animal.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
|
86
|
+
expect(() => animal.validateUsingKnownGoodVersion(knownGood, { type: 'dog', id: 'x' })).toThrow(
|
|
87
|
+
'At (type = dog).bark: Expected boolean, got undefined'
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('[U7] known-good validation rejects a non-object on either side', () => {
|
|
92
|
+
const knownGood = animal.validate({ type: 'cat', id: 'x', meow: true })
|
|
93
|
+
expect(() => animal.validateUsingKnownGoodVersion(knownGood, 'x')).toThrow(
|
|
94
|
+
'Expected an object, got a string'
|
|
95
|
+
)
|
|
96
|
+
expect(() =>
|
|
97
|
+
animal.validateUsingKnownGoodVersion(null as any, { type: 'cat', id: 'x', meow: true })
|
|
98
|
+
).toThrow('Expected an object, got null')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('numberUnion', () => {
|
|
102
|
+
const versioned = T.numberUnion('version', {
|
|
103
|
+
1: T.object({ version: T.literal(1), data: T.string }),
|
|
104
|
+
2: T.object({ version: T.literal(2), data: T.string }),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('[U8] validates variants keyed by finite number discriminators', () => {
|
|
108
|
+
const v1 = { version: 1, data: 'hello' }
|
|
109
|
+
const v2 = { version: 2, data: 'world' }
|
|
110
|
+
expect(versioned.validate(v1)).toBe(v1)
|
|
111
|
+
expect(versioned.validate(v2)).toBe(v2)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('[U8] rejects Infinity, -Infinity, NaN, and non-numeric discriminators', () => {
|
|
115
|
+
expect(() => versioned.validate({ version: Infinity, data: 'x' })).toThrow(
|
|
116
|
+
'Expected a number for key "version", got "Infinity"'
|
|
117
|
+
)
|
|
118
|
+
expect(() => versioned.validate({ version: -Infinity, data: 'x' })).toThrow(
|
|
119
|
+
'Expected a number for key "version", got "-Infinity"'
|
|
120
|
+
)
|
|
121
|
+
expect(() => versioned.validate({ version: NaN, data: 'x' })).toThrow(
|
|
122
|
+
'Expected a number for key "version", got "NaN"'
|
|
123
|
+
)
|
|
124
|
+
expect(() => versioned.validate({ version: 'two', data: 'x' })).toThrow(
|
|
125
|
+
'Expected a number for key "version", got "two"'
|
|
126
|
+
)
|
|
127
|
+
expect(() => versioned.validate({ data: 'x' })).toThrow(
|
|
128
|
+
'Expected a number for key "version", got "undefined"'
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('[U9] looks the variant up by string coercion, so string-numeric discriminators select the variant', () => {
|
|
133
|
+
expect(() => versioned.validate({ version: '1', data: 'x' })).toThrow(
|
|
134
|
+
'At (version = 1).version: Expected 1, got "1"'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('[U9] unmatched finite numbers are rejected as unknown variants', () => {
|
|
139
|
+
expect(() => versioned.validate({ version: 1.5, data: 'x' })).toThrow(
|
|
140
|
+
'At version: Expected one of "1" or "2", got 1.5'
|
|
141
|
+
)
|
|
142
|
+
expect(() => versioned.validate({ version: 3, data: 'x' })).toThrow(
|
|
143
|
+
'At version: Expected one of "1" or "2", got 3'
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
|
|
3
|
+
describe('§15 URL validators', () => {
|
|
4
|
+
it('[UR1] linkUrl accepts the empty string and http/https/mailto urls', () => {
|
|
5
|
+
expect(T.linkUrl.validate('')).toBe('')
|
|
6
|
+
expect(T.linkUrl.validate('https://example.com')).toBe('https://example.com')
|
|
7
|
+
expect(T.linkUrl.validate('http://example.com')).toBe('http://example.com')
|
|
8
|
+
expect(T.linkUrl.validate('mailto:user@example.com')).toBe('mailto:user@example.com')
|
|
9
|
+
|
|
10
|
+
expect(() => T.linkUrl.validate('javascript:alert(1)')).toThrow(
|
|
11
|
+
'Expected a valid url, got "javascript:alert(1)" (invalid protocol)'
|
|
12
|
+
)
|
|
13
|
+
expect(() => T.linkUrl.validate('asset:abc')).toThrow('(invalid protocol)')
|
|
14
|
+
expect(() => T.linkUrl.validate('not a url')).toThrow('Expected a valid url, got "not a url"')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('[UR2] srcUrl accepts the empty string and http/https/data/asset urls', () => {
|
|
18
|
+
expect(T.srcUrl.validate('')).toBe('')
|
|
19
|
+
expect(T.srcUrl.validate('https://example.com/image.png')).toBe('https://example.com/image.png')
|
|
20
|
+
expect(T.srcUrl.validate('data:image/png;base64,iVBORw0')).toBe('data:image/png;base64,iVBORw0')
|
|
21
|
+
expect(T.srcUrl.validate('asset:abc123')).toBe('asset:abc123')
|
|
22
|
+
|
|
23
|
+
expect(() => T.srcUrl.validate('mailto:user@example.com')).toThrow('(invalid protocol)')
|
|
24
|
+
expect(() => T.srcUrl.validate('javascript:alert(1)')).toThrow('(invalid protocol)')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('[UR3] httpUrl accepts the empty string and only http/https urls', () => {
|
|
28
|
+
expect(T.httpUrl.validate('')).toBe('')
|
|
29
|
+
expect(T.httpUrl.validate('https://api.example.com')).toBe('https://api.example.com')
|
|
30
|
+
expect(T.httpUrl.validate('http://localhost:3000')).toBe('http://localhost:3000')
|
|
31
|
+
|
|
32
|
+
expect(() => T.httpUrl.validate('ftp://files.example.com')).toThrow('(invalid protocol)')
|
|
33
|
+
expect(() => T.httpUrl.validate('mailto:user@example.com')).toThrow('(invalid protocol)')
|
|
34
|
+
expect(() => T.httpUrl.validate('data:text/plain,hi')).toThrow('(invalid protocol)')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('[UR4] strings starting with / or ./ validate; other relative forms do not', () => {
|
|
38
|
+
expect(T.linkUrl.validate('/foo')).toBe('/foo')
|
|
39
|
+
expect(T.linkUrl.validate('./foo')).toBe('./foo')
|
|
40
|
+
expect(T.srcUrl.validate('/foo')).toBe('/foo')
|
|
41
|
+
expect(T.srcUrl.validate('./foo')).toBe('./foo')
|
|
42
|
+
expect(T.httpUrl.validate('/foo')).toBe('/foo')
|
|
43
|
+
expect(T.httpUrl.validate('./foo')).toBe('./foo')
|
|
44
|
+
|
|
45
|
+
expect(() => T.linkUrl.validate('../foo')).toThrow('Expected a valid url, got "../foo"')
|
|
46
|
+
expect(() => T.linkUrl.validate('foo')).toThrow('Expected a valid url, got "foo"')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('[UR5] protocol matching is case-insensitive', () => {
|
|
50
|
+
expect(T.linkUrl.validate('HTTP://example.com')).toBe('HTTP://example.com')
|
|
51
|
+
expect(() => T.linkUrl.validate('JAVASCRIPT:alert(1)')).toThrow('(invalid protocol)')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('[UR6] non-strings are rejected as strings', () => {
|
|
55
|
+
expect(() => T.linkUrl.validate(5)).toThrow('Expected string, got a number')
|
|
56
|
+
expect(() => T.srcUrl.validate(null)).toThrow('Expected string, got null')
|
|
57
|
+
expect(() => T.httpUrl.validate(undefined)).toThrow('Expected string, got undefined')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
import { ValidationError } from '../lib/validation'
|
|
3
|
+
|
|
4
|
+
describe('§3 The validator core', () => {
|
|
5
|
+
it('[V1] validate returns exactly the value it was passed', () => {
|
|
6
|
+
const validator = T.object({
|
|
7
|
+
name: T.string,
|
|
8
|
+
items: T.arrayOf(T.string.nullable()),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const value = {
|
|
12
|
+
name: 'toad',
|
|
13
|
+
items: ['toad', 'berd', null, 'bot'],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
expect(validator.validate(value)).toBe(value)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('[V1] validate does not mutate or freeze the value', () => {
|
|
20
|
+
const value = { name: 'toad', items: [1, 2] }
|
|
21
|
+
T.object({ name: T.string, items: T.arrayOf(T.number) }).validate(value)
|
|
22
|
+
|
|
23
|
+
expect(value).toEqual({ name: 'toad', items: [1, 2] })
|
|
24
|
+
expect(Object.isFrozen(value)).toBe(false)
|
|
25
|
+
expect(Object.isFrozen(value.items)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('[V2] a non-transforming validator returning a different value throws in dev', () => {
|
|
29
|
+
const validator = new T.Validator((value) => ({ ...(value as object) }))
|
|
30
|
+
|
|
31
|
+
expect(() => validator.validate({ a: 1 })).toThrow(
|
|
32
|
+
'Validator functions must return the same value they were passed'
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('[V3] isValid returns a boolean and never throws', () => {
|
|
37
|
+
expect(T.number.isValid(1)).toBe(true)
|
|
38
|
+
expect(T.number.isValid('one')).toBe(false)
|
|
39
|
+
expect(T.object({ a: T.string }).isValid(null)).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('[V4] validateUsingKnownGoodVersion returns the known-good value without validating when Object.is-equal', () => {
|
|
43
|
+
let calls = 0
|
|
44
|
+
const counting = new T.Validator<number>((value) => {
|
|
45
|
+
calls++
|
|
46
|
+
if (typeof value !== 'number') throw new ValidationError('Expected number')
|
|
47
|
+
return value
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(counting.validateUsingKnownGoodVersion(42, 42)).toBe(42)
|
|
51
|
+
expect(calls).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('[V5] validateUsingKnownGoodVersion falls back to a full validate without a known-good implementation', () => {
|
|
55
|
+
expect(T.string.validateUsingKnownGoodVersion('a', 'b')).toBe('b')
|
|
56
|
+
expect(() => T.string.validateUsingKnownGoodVersion('a', 5)).toThrow(
|
|
57
|
+
'Expected string, got a number'
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
})
|