7310b9704d
* Fix inability to assign (object containing) open union results to `LexMap` type * properly support and type default values * strong cohesion * changeset * tidy * Add validation error as cause when handling invalid records * tidy * Update `cborg` dependency, fixing encoding of strings with invalid surrogate pairs, and ignoring `undefined` object properties * tidy * Simplify encoding logic of `number` * Improvements around usage of `noUndefinedVals` * Memoize array schemas (without options) * Memoize empty params schemas * tidy * tidy
457 lines
11 KiB
TypeScript
457 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { parseCid } from '@atproto/lex-data'
|
|
import { l } from '../src/index.js'
|
|
|
|
// await cidForRawBytes(Buffer.from('Hello, World!'))
|
|
const blobCid = parseCid(
|
|
'bafkreig77vqcdozl2wyk6z3cscaj5q5fggi53aoh64fewkdiri3cdauyn4',
|
|
)
|
|
|
|
describe('simple schemas', () => {
|
|
describe('l.integer', () => {
|
|
const schema = l.integer()
|
|
|
|
it('validates integers', () => {
|
|
expect(schema.matches(42)).toBe(true)
|
|
})
|
|
|
|
it('rejects floats', () => {
|
|
expect(schema.matches(3.14)).toBe(false)
|
|
})
|
|
|
|
it('rejects strings', () => {
|
|
expect(schema.matches('42')).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.integer()).toBe(schema)
|
|
})
|
|
|
|
it('does not memoize with options', () => {
|
|
// @ts-expect-error
|
|
expect(l.integer({ unknownOption: 43 })).not.toBe(schema)
|
|
expect(l.integer({ minimum: 0 })).not.toBe(schema)
|
|
expect(l.integer({ minimum: 0 })).not.toBe(l.integer({ minimum: 0 }))
|
|
expect(l.integer({ maximum: 100 })).not.toBe(l.integer({ maximum: 100 }))
|
|
})
|
|
})
|
|
|
|
describe('l.string', () => {
|
|
const schema = l.string()
|
|
|
|
it('validates strings', () => {
|
|
expect(schema.matches('hello')).toBe(true)
|
|
})
|
|
|
|
it('rejects numbers', () => {
|
|
expect(schema.matches(123)).toBe(false)
|
|
})
|
|
|
|
it('rejects null', () => {
|
|
expect(schema.matches(null)).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.string()).toBe(schema)
|
|
})
|
|
})
|
|
|
|
describe('l.boolean', () => {
|
|
const schema = l.boolean()
|
|
|
|
it('validates true', () => {
|
|
expect(schema.matches(true)).toBe(true)
|
|
})
|
|
|
|
it('validates false', () => {
|
|
expect(schema.matches(false)).toBe(true)
|
|
})
|
|
|
|
it('rejects strings', () => {
|
|
expect(schema.matches('true')).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.boolean()).toBe(schema)
|
|
|
|
expect(l.optional(l.boolean())).toBe(l.optional(l.boolean()))
|
|
expect(l.nullable(l.boolean())).toBe(l.nullable(l.boolean()))
|
|
})
|
|
})
|
|
|
|
describe('l.blob', () => {
|
|
const schema = l.blob()
|
|
|
|
it('validates valid blob references', () => {
|
|
expect(
|
|
schema.matches({
|
|
$type: 'blob',
|
|
ref: blobCid,
|
|
mimeType: 'image/jpeg',
|
|
size: 10000,
|
|
}),
|
|
).toBe(true)
|
|
})
|
|
|
|
it('rejects blob without $type', () => {
|
|
expect(
|
|
schema.matches({
|
|
ref: blobCid,
|
|
mimeType: 'image/jpeg',
|
|
size: 10000,
|
|
}),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('rejects non-objects', () => {
|
|
expect(schema.matches('not a blob')).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.blob()).toBe(schema)
|
|
})
|
|
})
|
|
|
|
describe('l.null', () => {
|
|
const schema = l.null()
|
|
|
|
it('validates null', () => {
|
|
expect(schema.matches(null)).toBe(true)
|
|
})
|
|
|
|
it('rejects undefined', () => {
|
|
expect(schema.matches(undefined)).toBe(false)
|
|
})
|
|
|
|
it('rejects strings', () => {
|
|
expect(schema.matches('null')).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.null()).toBe(schema)
|
|
})
|
|
})
|
|
|
|
describe('l.literal', () => {
|
|
const schema = l.literal('active')
|
|
|
|
it('validates matching literal', () => {
|
|
expect(schema.matches('active')).toBe(true)
|
|
})
|
|
|
|
it('rejects non-matching value', () => {
|
|
expect(schema.matches('inactive')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('l.enum', () => {
|
|
const schema = l.enum(['red', 'green', 'blue'])
|
|
|
|
it('validates enum values', () => {
|
|
expect(schema.matches('red')).toBe(true)
|
|
expect(schema.matches('green')).toBe(true)
|
|
expect(schema.matches('blue')).toBe(true)
|
|
})
|
|
|
|
it('rejects non-enum values', () => {
|
|
expect(schema.matches('yellow')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('l.array', () => {
|
|
const schema = l.array(l.string())
|
|
|
|
it('validates arrays of strings', () => {
|
|
expect(schema.matches(['hello', 'world'])).toBe(true)
|
|
})
|
|
|
|
it('validates empty arrays', () => {
|
|
expect(schema.matches([])).toBe(true)
|
|
})
|
|
|
|
it('rejects arrays with invalid items', () => {
|
|
expect(schema.matches(['hello', 123])).toBe(false)
|
|
})
|
|
|
|
it('rejects non-arrays', () => {
|
|
expect(schema.matches('not an array')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('l.object', () => {
|
|
const schema = l.object({
|
|
name: l.string(),
|
|
age: l.integer(),
|
|
})
|
|
|
|
it('validates valid objects', () => {
|
|
expect(schema.matches({ name: 'Alice', age: 30 })).toBe(true)
|
|
})
|
|
|
|
it('rejects objects with missing properties', () => {
|
|
expect(schema.matches({ name: 'Alice' })).toBe(false)
|
|
})
|
|
|
|
it('rejects objects with invalid property types', () => {
|
|
expect(schema.matches({ name: 'Alice', age: 'thirty' })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('l.nullable', () => {
|
|
const schema = l.nullable(l.string())
|
|
|
|
it('validates null', () => {
|
|
expect(schema.matches(null)).toBe(true)
|
|
})
|
|
|
|
it('validates wrapped type', () => {
|
|
expect(schema.matches('hello')).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid types', () => {
|
|
expect(schema.matches(123)).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.nullable(l.string())).toBe(schema)
|
|
})
|
|
})
|
|
|
|
describe('l.optional', () => {
|
|
const schema = l.optional(l.string())
|
|
|
|
it('validates undefined', () => {
|
|
expect(schema.matches(undefined)).toBe(true)
|
|
})
|
|
|
|
it('validates wrapped type', () => {
|
|
expect(schema.matches('hello')).toBe(true)
|
|
})
|
|
|
|
it('rejects null', () => {
|
|
expect(schema.matches(null)).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.optional(l.string())).toBe(schema)
|
|
})
|
|
})
|
|
|
|
describe('l.unknown', () => {
|
|
const schema = l.unknown()
|
|
|
|
it('validates any value', () => {
|
|
expect(schema.matches('string')).toBe(true)
|
|
expect(schema.matches(123)).toBe(true)
|
|
expect(schema.matches(null)).toBe(true)
|
|
expect(schema.matches({ key: 'value' })).toBe(true)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.unknown()).toBe(schema)
|
|
})
|
|
})
|
|
|
|
describe('l.never', () => {
|
|
const schema = l.never()
|
|
|
|
it('rejects all values', () => {
|
|
expect(schema.matches('string')).toBe(false)
|
|
expect(schema.matches(123)).toBe(false)
|
|
expect(schema.matches(null)).toBe(false)
|
|
expect(schema.matches(undefined)).toBe(false)
|
|
})
|
|
|
|
it('memoizes instances', () => {
|
|
expect(l.never()).toBe(schema)
|
|
expect(l.never()).toBe(schema)
|
|
expect(l.never()).toBe(schema)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('complex schemas', () => {
|
|
const addressSchema = l.object({
|
|
street: l.string(),
|
|
city: l.string(),
|
|
zipCode: l.integer(),
|
|
})
|
|
|
|
const mobilityPreferenceSchema = l.discriminatedUnion('type', [
|
|
l.object({
|
|
type: l.literal('car'),
|
|
carModel: l.string(),
|
|
}),
|
|
l.object({
|
|
type: l.literal('bike'),
|
|
bikeType: l.string(),
|
|
}),
|
|
l.object({
|
|
type: l.literal('public_transport'),
|
|
preferredLines: l.array(l.string()),
|
|
}),
|
|
])
|
|
|
|
const userSchema = l.object({
|
|
id: l.integer(),
|
|
name: l.string(),
|
|
gender: l.optional(l.nullable(l.enum(['male', 'female']))),
|
|
address: addressSchema,
|
|
mobilityPreferences: l.optional(l.array(mobilityPreferenceSchema)),
|
|
parent: l.optional(l.ref((() => userSchema) as any)),
|
|
})
|
|
|
|
describe('addressSchema', () => {
|
|
it('validates valid address', () => {
|
|
const result = addressSchema.safeParse({
|
|
street: '123 Main St',
|
|
city: 'Springfield',
|
|
zipCode: 12345,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing required field', () => {
|
|
const result = addressSchema.safeParse({
|
|
street: '123 Main St',
|
|
city: 'Springfield',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid field type', () => {
|
|
const result = addressSchema.safeParse({
|
|
street: '123 Main St',
|
|
city: 'Springfield',
|
|
zipCode: '12345',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('mobilityPreferenceSchema (discriminatedUnion)', () => {
|
|
it('validates car preference', () => {
|
|
const result = mobilityPreferenceSchema.safeParse({
|
|
type: 'car',
|
|
carModel: 'Tesla Model 3',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('validates bike preference', () => {
|
|
const result = mobilityPreferenceSchema.safeParse({
|
|
type: 'bike',
|
|
bikeType: 'mountain',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('validates public transport preference', () => {
|
|
const result = mobilityPreferenceSchema.safeParse({
|
|
type: 'public_transport',
|
|
preferredLines: ['Line 1', 'Line 2'],
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid discriminator value', () => {
|
|
const result = mobilityPreferenceSchema.safeParse({
|
|
type: 'helicopter',
|
|
model: 'Apache',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects missing discriminator', () => {
|
|
const result = mobilityPreferenceSchema.safeParse({
|
|
carModel: 'Tesla Model 3',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('userSchema', () => {
|
|
const validUser = {
|
|
id: 1,
|
|
name: 'Alice',
|
|
address: {
|
|
street: '123 Main St',
|
|
city: 'Springfield',
|
|
zipCode: 12345,
|
|
},
|
|
}
|
|
|
|
it('validates minimal valid user', () => {
|
|
const result = userSchema.safeParse(validUser)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('validates user with optional gender', () => {
|
|
const result = userSchema.safeParse({
|
|
...validUser,
|
|
gender: 'female',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('validates user with null gender', () => {
|
|
const result = userSchema.safeParse({
|
|
...validUser,
|
|
gender: null,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('validates user with mobility preferences', () => {
|
|
const result = userSchema.safeParse({
|
|
...validUser,
|
|
mobilityPreferences: [
|
|
{ type: 'car', carModel: 'Tesla' },
|
|
{ type: 'bike', bikeType: 'road' },
|
|
],
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('validates user with parent reference', () => {
|
|
const result = userSchema.safeParse({
|
|
...validUser,
|
|
parent: {
|
|
id: 2,
|
|
name: 'Bob',
|
|
address: {
|
|
street: '456 Oak Ave',
|
|
city: 'Springfield',
|
|
zipCode: 12346,
|
|
},
|
|
},
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects user with invalid gender', () => {
|
|
const result = userSchema.safeParse({
|
|
...validUser,
|
|
gender: 'other',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects user with missing required field', () => {
|
|
const result = userSchema.safeParse({
|
|
id: 1,
|
|
name: 'Alice',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects user with invalid mobility preference', () => {
|
|
const result = userSchema.safeParse({
|
|
...validUser,
|
|
mobilityPreferences: [{ type: 'invalid', data: 'test' }],
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
})
|