You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
703 lines
17 KiB
703 lines
17 KiB
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
|
import { array, discriminated, object, union } from '../core/combinators';
|
|
import { optional } from '../core/modifiers';
|
|
import { pipe, refine } from '../core/pipe';
|
|
import { literal, number, string } from '../core/primitives';
|
|
import { createSchema } from '../core/schema';
|
|
import {
|
|
SiumAsyncSchemaError,
|
|
SiumEncodeExpectsArrayError,
|
|
SiumEncodeExpectsObjectError,
|
|
SiumEncodeNoMatchError,
|
|
SiumValidationError
|
|
} from '../errors';
|
|
|
|
function getIssues(error: unknown) {
|
|
if (!(error instanceof SiumValidationError)) {
|
|
throw error;
|
|
}
|
|
|
|
return error.issues;
|
|
}
|
|
|
|
describe('object()', () => {
|
|
it('decodes valid objects', () => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
expect(schema.decodeSync({ a: 'x', b: 1 })).toEqual({ a: 'x', b: 1 });
|
|
});
|
|
|
|
it.each([
|
|
['array', ['x']],
|
|
['null', null],
|
|
['primitive', 42]
|
|
])('rejects %s inputs with an object type issue', (_, input) => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
try {
|
|
schema.decodeSync(input as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'object',
|
|
actual: Array.isArray(input) ? 'array' : input === null ? 'null' : 'number'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected object() to reject non-object inputs');
|
|
});
|
|
|
|
it.each([
|
|
['Date', new Date('2026-01-01T00:00:00.000Z')],
|
|
['RegExp', /x/],
|
|
[
|
|
'class instance',
|
|
new (class Box {
|
|
a = 'x';
|
|
b = 1;
|
|
})()
|
|
]
|
|
])('rejects %s inputs as non-plain objects', (_, input) => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
try {
|
|
schema.decodeSync(input as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'object',
|
|
actual: 'object'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected object() to reject non-plain objects');
|
|
});
|
|
|
|
it('accepts null-prototype objects as plain records', () => {
|
|
const schema = object({ a: string(), b: number() });
|
|
const input = Object.assign(Object.create(null) as { a: string; b: number }, {
|
|
a: 'x',
|
|
b: 1
|
|
});
|
|
|
|
expect(schema.decodeSync(input)).toEqual({ a: 'x', b: 1 });
|
|
});
|
|
|
|
it('prefixes field issues with the field key', () => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
try {
|
|
schema.decodeSync({ a: 42 as never, b: 1 });
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: ['a'],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'string',
|
|
actual: 'number'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected object() to prefix child field issues');
|
|
});
|
|
|
|
it('passes the absolute field path to nested refinements', () => {
|
|
const seenPaths: ReadonlyArray<string | number>[] = [];
|
|
const schema = object({
|
|
user: object({
|
|
name: pipe(
|
|
string(),
|
|
refine(
|
|
(_value, ctx) => {
|
|
seenPaths.push(ctx.path);
|
|
return false;
|
|
},
|
|
{
|
|
code: 'custom',
|
|
message: '#?sium.errors.custom|Invalid value'
|
|
}
|
|
)
|
|
)
|
|
})
|
|
});
|
|
|
|
try {
|
|
schema.decodeSync({ user: { name: 'Ada' } });
|
|
} catch (error) {
|
|
expect(seenPaths).toEqual([['user', 'name']]);
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: ['user', 'name'],
|
|
code: 'custom',
|
|
message: '#?sium.errors.custom|Invalid value'
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected nested field refine to fail');
|
|
});
|
|
|
|
it('accumulates issues from multiple invalid fields', () => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
try {
|
|
schema.decodeSync({ a: 42 as never, b: 'x' as never });
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: ['a'],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'string',
|
|
actual: 'number'
|
|
}
|
|
},
|
|
{
|
|
path: ['b'],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'number',
|
|
actual: 'string'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected object() to accumulate all field issues');
|
|
});
|
|
|
|
it('strips unknown keys by default on decode', () => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
expect(schema.decodeSync({ a: 'x', b: 1, extra: 'e' } as never)).toEqual({
|
|
a: 'x',
|
|
b: 1
|
|
});
|
|
});
|
|
|
|
it('rejects unknown keys in strict mode', () => {
|
|
const schema = object({ a: string(), b: number() }, { unknownKeys: 'strict' });
|
|
|
|
try {
|
|
schema.decodeSync({ a: 'x', b: 1, extra: 'e' } as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'unknown_keys',
|
|
message: '#?sium.errors.unknown_keys|Unknown keys: {{keys}}',
|
|
params: {
|
|
keys: ['extra']
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected strict object() to reject unknown keys');
|
|
});
|
|
|
|
it('preserves unknown keys in passthrough mode', () => {
|
|
const schema = object({ a: string(), b: number() }, { unknownKeys: 'passthrough' });
|
|
|
|
expect(schema.decodeSync({ a: 'x', b: 1, extra: 'e' } as never)).toEqual({
|
|
a: 'x',
|
|
b: 1,
|
|
extra: 'e'
|
|
});
|
|
});
|
|
|
|
it('strips extras on encode when unknownKeys is strip', () => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
expect(schema.encode({ a: 'x', b: 1, extra: 'e' } as never)).toEqual({
|
|
a: 'x',
|
|
b: 1
|
|
});
|
|
});
|
|
|
|
it('throws SiumEncodeExpectsObjectError on non-plain encode input', () => {
|
|
const schema = object({ a: string(), b: number() });
|
|
|
|
expect(() => schema.encode(new Date('2026-01-01T00:00:00.000Z') as never)).toThrow(
|
|
SiumEncodeExpectsObjectError
|
|
);
|
|
});
|
|
|
|
it('rejects extras on encode when unknownKeys is strict', () => {
|
|
const schema = object({ a: string(), b: number() }, { unknownKeys: 'strict' });
|
|
|
|
try {
|
|
schema.encode({ a: 'x', b: 1, extra: 'e' } as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'unknown_keys',
|
|
message: '#?sium.errors.unknown_keys|Unknown keys: {{keys}}',
|
|
params: {
|
|
keys: ['extra']
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected strict object().encode to reject unknown keys');
|
|
});
|
|
|
|
it('exposes object introspection with fields and policy', () => {
|
|
const shape = { a: string(), b: number() };
|
|
const schema = object(shape, { unknownKeys: 'passthrough' });
|
|
|
|
expect(schema['~sium']).toEqual({
|
|
kind: 'object',
|
|
wrappers: [],
|
|
effects: [],
|
|
async: false,
|
|
meta: {},
|
|
shape: {
|
|
kind: 'object',
|
|
fields: shape,
|
|
unknownKeys: 'passthrough'
|
|
}
|
|
});
|
|
});
|
|
|
|
it('inherits async when any field schema is async', () => {
|
|
const asyncField = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
const schema = object({ a: asyncField, b: number() });
|
|
|
|
expect(() => schema.decodeSync({ a: 'x', b: 1 })).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
|
|
it('short-circuits async optional fields in sync object paths', () => {
|
|
const asyncField = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
const schema = object({ a: optional(asyncField), b: number() });
|
|
|
|
expect(schema.decodeSync({ a: undefined, b: 1 })).toEqual({ a: undefined, b: 1 });
|
|
expect(() => schema.decodeSync({ a: 'x', b: 1 })).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
});
|
|
|
|
describe('array()', () => {
|
|
it('decodes valid arrays', () => {
|
|
const schema = array(string());
|
|
|
|
expect(schema.decodeSync(['a', 'b'])).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('rejects non-array input with a type issue', () => {
|
|
const schema = array(string());
|
|
|
|
try {
|
|
schema.decodeSync('ab' as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'array',
|
|
actual: 'string'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected array() to reject non-array input');
|
|
});
|
|
|
|
it('prefixes item issues with the failing index', () => {
|
|
const schema = array(string());
|
|
|
|
try {
|
|
schema.decodeSync(['a', 2 as never, 'c']);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [1],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'string',
|
|
actual: 'number'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected array() to prefix item issues');
|
|
});
|
|
|
|
it('passes the absolute item path to item refinements', () => {
|
|
const seenPaths: ReadonlyArray<string | number>[] = [];
|
|
const schema = object({
|
|
tags: array(
|
|
pipe(
|
|
string(),
|
|
refine(
|
|
(_value, ctx) => {
|
|
seenPaths.push(ctx.path);
|
|
return false;
|
|
},
|
|
{
|
|
code: 'custom',
|
|
message: '#?sium.errors.custom|Invalid value'
|
|
}
|
|
)
|
|
)
|
|
)
|
|
});
|
|
|
|
try {
|
|
schema.decodeSync({ tags: ['a'] });
|
|
} catch (error) {
|
|
expect(seenPaths).toEqual([['tags', 0]]);
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: ['tags', 0],
|
|
code: 'custom',
|
|
message: '#?sium.errors.custom|Invalid value'
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected array item refine to fail');
|
|
});
|
|
|
|
it('encodes item-by-item', () => {
|
|
const schema = array(string());
|
|
|
|
expect(schema.encode(['a', 'b'])).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('throws SiumEncodeExpectsArrayError on non-array encode input', () => {
|
|
const schema = array(string());
|
|
|
|
expect(() => schema.encode('ab' as never)).toThrow(SiumEncodeExpectsArrayError);
|
|
});
|
|
|
|
it('exposes array introspection', () => {
|
|
const item = string();
|
|
const schema = array(item);
|
|
|
|
expect(schema['~sium']).toEqual({
|
|
kind: 'array',
|
|
wrappers: [],
|
|
effects: [],
|
|
async: false,
|
|
meta: {},
|
|
shape: {
|
|
kind: 'array',
|
|
item
|
|
}
|
|
});
|
|
});
|
|
|
|
it('inherits async from the item schema', () => {
|
|
const asyncItem = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
const schema = array(asyncItem);
|
|
|
|
expect(() => schema.decodeSync(['a'])).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
|
|
it('short-circuits sync array paths that do not need async item work', () => {
|
|
const asyncItem = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
|
|
expect(array(asyncItem).decodeSync([])).toEqual([]);
|
|
expect(array(optional(asyncItem)).decodeSync([undefined])).toEqual([undefined]);
|
|
expect(() => array(asyncItem).decodeSync(['a'])).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
});
|
|
|
|
describe('union()', () => {
|
|
it('returns the first matching member output', () => {
|
|
const schema = union(string(), number());
|
|
|
|
expect(schema.decodeSync('x')).toBe('x');
|
|
expect(schema.decodeSync(42)).toBe(42);
|
|
expectTypeOf(schema.decodeSync('x')).toEqualTypeOf<string | number>();
|
|
});
|
|
|
|
it('emits union_no_match when no member accepts the input', () => {
|
|
const schema = union(string(), number());
|
|
|
|
try {
|
|
schema.decodeSync(true as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'union_no_match',
|
|
message: '#?sium.errors.union_no_match|Does not match any variant',
|
|
params: {}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected union() to emit union_no_match');
|
|
});
|
|
|
|
it('uses identity encode in v1', () => {
|
|
const schema = union(string(), number());
|
|
|
|
expect(schema.encode('x')).toBe('x');
|
|
expect(schema.encode(42)).toBe(42);
|
|
});
|
|
|
|
it('exposes union introspection', () => {
|
|
const left = string();
|
|
const right = number();
|
|
const schema = union(left, right);
|
|
|
|
expect(schema['~sium']).toEqual({
|
|
kind: 'union',
|
|
wrappers: [],
|
|
effects: [],
|
|
async: false,
|
|
meta: {},
|
|
shape: {
|
|
kind: 'union',
|
|
members: [left, right]
|
|
}
|
|
});
|
|
});
|
|
|
|
it('inherits async when any member is async', () => {
|
|
const asyncMember = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
const schema = union(asyncMember, number());
|
|
|
|
expect(() => schema.decodeSync('x')).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
|
|
it('can decode sync union members before an async member is needed', () => {
|
|
const asyncMember = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
|
|
expect(union(number(), asyncMember).decodeSync(1)).toBe(1);
|
|
expect(() => union(asyncMember, number()).decodeSync(1 as never)).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
});
|
|
|
|
describe('discriminated()', () => {
|
|
it('delegates to the matching member on decode', () => {
|
|
const schema = discriminated('kind', [
|
|
object({ kind: literal('a'), val: string() }),
|
|
object({ kind: literal('b'), val: number() })
|
|
]);
|
|
|
|
expect(schema.decodeSync({ kind: 'a', val: 'x' })).toEqual({ kind: 'a', val: 'x' });
|
|
expect(schema.decodeSync({ kind: 'b', val: 1 })).toEqual({ kind: 'b', val: 1 });
|
|
});
|
|
|
|
it('rejects non-object inputs with a type issue', () => {
|
|
const schema = discriminated('kind', [
|
|
object({ kind: literal('a'), val: string() }),
|
|
object({ kind: literal('b'), val: number() })
|
|
]);
|
|
|
|
try {
|
|
schema.decodeSync('x' as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'type',
|
|
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
|
|
params: {
|
|
expected: 'object',
|
|
actual: 'string'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected discriminated() to reject non-object input');
|
|
});
|
|
|
|
it('emits discriminated_missing_key when the discriminator is absent', () => {
|
|
const schema = discriminated('kind', [
|
|
object({ kind: literal('a'), val: string() }),
|
|
object({ kind: literal('b'), val: number() })
|
|
]);
|
|
|
|
try {
|
|
schema.decodeSync({ val: 'x' } as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'discriminated_missing_key',
|
|
message: '#?sium.errors.discriminated_missing_key|Missing discriminator key "{{key}}"',
|
|
params: {
|
|
key: 'kind'
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected discriminated() to emit missing-key issue');
|
|
});
|
|
|
|
it('emits discriminated_unknown_value when the discriminator is not recognized', () => {
|
|
const schema = discriminated('kind', [
|
|
object({ kind: literal('a'), val: string() }),
|
|
object({ kind: literal('b'), val: number() })
|
|
]);
|
|
|
|
try {
|
|
schema.decodeSync({ kind: 'c', val: 'x' } as never);
|
|
} catch (error) {
|
|
expect(getIssues(error)).toEqual([
|
|
{
|
|
path: [],
|
|
code: 'discriminated_unknown_value',
|
|
message:
|
|
'#?sium.errors.discriminated_unknown_value|Unknown value "{{actual}}" for discriminator "{{key}}" (expected one of {{expected}})',
|
|
params: {
|
|
key: 'kind',
|
|
actual: 'c',
|
|
expected: ['a', 'b']
|
|
}
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Expected discriminated() to emit unknown-value issue');
|
|
});
|
|
|
|
it('fails at construction when a member lacks a literal discriminator', () => {
|
|
expect(() =>
|
|
discriminated('kind', [
|
|
object({ kind: literal('a'), val: string() }),
|
|
object({ kind: string(), val: number() })
|
|
])
|
|
).toThrow(Error);
|
|
});
|
|
|
|
it('encodes through the matching member', () => {
|
|
const schema = discriminated('kind', [
|
|
object({ kind: literal('a'), val: string() }),
|
|
object({ kind: literal('b'), val: number() })
|
|
]);
|
|
|
|
expect(schema.encode({ kind: 'a', val: 'x' })).toEqual({ kind: 'a', val: 'x' });
|
|
expect(schema.encode({ kind: 'b', val: 1 })).toEqual({ kind: 'b', val: 1 });
|
|
});
|
|
|
|
it('exposes discriminated introspection', () => {
|
|
const first = object({ kind: literal('a'), val: string() });
|
|
const second = object({ kind: literal('b'), val: number() });
|
|
const schema = discriminated('kind', [first, second]);
|
|
|
|
expect(schema['~sium']).toEqual({
|
|
kind: 'discriminated',
|
|
wrappers: [],
|
|
effects: [],
|
|
async: false,
|
|
meta: {},
|
|
shape: {
|
|
kind: 'discriminated',
|
|
key: 'kind',
|
|
members: [first, second]
|
|
}
|
|
});
|
|
});
|
|
|
|
it('inherits async when any member is async', () => {
|
|
const asyncValue = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
const schema = discriminated('kind', [
|
|
object({ kind: literal('a'), val: asyncValue }),
|
|
object({ kind: literal('b'), val: number() })
|
|
]);
|
|
|
|
expect(() => schema.decodeSync({ kind: 'a', val: 'x' })).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
|
|
it('short-circuits selected discriminated member paths when async work is not needed', () => {
|
|
const asyncValue = createSchema({
|
|
kind: 'string',
|
|
async: true,
|
|
decodeImpl: async (input: string) => input,
|
|
encode: (value: string) => value
|
|
});
|
|
const schema = discriminated('kind', [
|
|
object({ kind: literal('a'), val: optional(asyncValue) }),
|
|
object({ kind: literal('b'), val: number() })
|
|
]);
|
|
|
|
expect(schema.decodeSync({ kind: 'a', val: undefined })).toEqual({
|
|
kind: 'a',
|
|
val: undefined
|
|
});
|
|
expect(schema.decodeSync({ kind: 'b', val: 1 })).toEqual({ kind: 'b', val: 1 });
|
|
expect(() => schema.decodeSync({ kind: 'a', val: 'x' })).toThrow(SiumAsyncSchemaError);
|
|
});
|
|
});
|