Every change here answers one question: where does cereale currently fail
without saying so?
**Vite 8 / Vitest 4 drop decorators silently.** Both transform with oxc,
which does not implement the standard decorator transform and does not
report that. `vitest` prints "0 test" beside a bare SyntaxError, and
`vite build` reports success while emitting a bundle that throws on first
import. Ship the plugin that fixes it as `cereale/vite`, transforming with
esbuild and falling back to tsc — cereale depends on neither. The library's
own suite now runs through it, so it is exercised by every test.
**Legacy decorators died opaquely.** With `experimentalDecorators: true`,
still the default in most existing TypeScript projects, decorators are
invoked as (prototype, "name") and cereale raised "TypeError: Cannot convert
undefined or null to object". All decorators now resolve metadata through
one checkpoint that names the tsconfig setting instead, and reject
application to a method, getter or accessor field.
**Values JSON cannot carry were emptied.** A populated Map serialized to
{}, a Uint8Array to index-keyed noise, a bigint straight through so the
caller's own JSON.stringify threw somewhere unrelated. All now raise
JsonMappingError naming the property path and both ways out. Covers what a
@JsonSerialize serializer returns, sync or async. Circular-reference and
depth errors name the path too.
Also fixed: defineRule on a subclass with no decorators of its own wrote
the rule into its base class, because the base's metadata object is
inherited through the static prototype chain and `??=` found it non-nullish.
The README's toolchain table (tsc, esbuild, swc ✅, oxc ❌) is now executed
by a test rather than asserted, and the positioning leads with
class-validator + class-transformer, the stack cereale actually replaces,
rather than Zod, which it deliberately is not.
193 -> 249 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
263 lines
7.9 KiB
TypeScript
263 lines
7.9 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import {
|
|
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
|
JsonSerializer, JsonDeserializer, JsonMappingError,
|
|
defineRule, validate, toInstance, toPlain, configure, resetConfig,
|
|
} from './index.js';
|
|
|
|
afterEach(() => resetConfig());
|
|
|
|
describe('plan caching', () => {
|
|
// The validation plan for a class is memoized. It must not go stale when metadata is
|
|
// registered after the class has already been validated once.
|
|
it('picks up a decorator registered after the first validation', async () => {
|
|
class Late {
|
|
value: any;
|
|
}
|
|
|
|
const before = new Late();
|
|
before.value = 'anything';
|
|
expect(await validate(before)).toEqual([]);
|
|
|
|
// Register a rule after the plan has already been built and cached.
|
|
defineRule(Late, 'value', {
|
|
name: 'isEven',
|
|
validate: (v: any) => typeof v === 'number' && v % 2 === 0,
|
|
message: 'value must be even',
|
|
});
|
|
|
|
const after = new Late();
|
|
after.value = 'anything';
|
|
expect(await validate(after)).toHaveLength(1);
|
|
|
|
after.value = 4;
|
|
expect(await validate(after)).toEqual([]);
|
|
});
|
|
|
|
it('keeps per-class plans separate', async () => {
|
|
class A {
|
|
@IsString()
|
|
v: any;
|
|
}
|
|
class B {
|
|
@IsInt()
|
|
v: any;
|
|
}
|
|
|
|
const a = new A();
|
|
a.v = 'text';
|
|
const b = new B();
|
|
b.v = 'text';
|
|
|
|
expect(await validate(a)).toEqual([]);
|
|
expect(await validate(b)).toHaveLength(1);
|
|
});
|
|
|
|
it('reuses one serializer instance rather than constructing per property', async () => {
|
|
let constructed = 0;
|
|
class Counting implements JsonSerializer<string, string> {
|
|
constructor() { constructed++; }
|
|
serialize(value: string): string { return value.toUpperCase(); }
|
|
}
|
|
class Doc {
|
|
@JsonSerialize(Counting)
|
|
a: string;
|
|
|
|
@JsonSerialize(Counting)
|
|
b: string;
|
|
}
|
|
|
|
const doc = new Doc();
|
|
doc.a = 'x';
|
|
doc.b = 'y';
|
|
|
|
await toPlain(doc);
|
|
await toPlain(doc);
|
|
await toPlain(doc);
|
|
|
|
expect(await toPlain(doc)).toEqual({ a: 'X', b: 'Y' });
|
|
expect(constructed).toBe(1);
|
|
});
|
|
|
|
it('still honours a deserializer after caching', async () => {
|
|
class ToDate implements JsonDeserializer<string, Date> {
|
|
deserialize(value: string): Date { return new Date(value); }
|
|
}
|
|
class Event {
|
|
@JsonDeserialize(ToDate)
|
|
at: Date;
|
|
}
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const e = await toInstance(Event, { at: '2026-01-01T00:00:00Z' });
|
|
expect(e.at).toBeInstanceOf(Date);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('maxDepth guard', () => {
|
|
const nest = (depth: number): any => {
|
|
let node: any = { value: 'leaf' };
|
|
for (let i = 0; i < depth; i++) node = { child: node };
|
|
return node;
|
|
};
|
|
|
|
class Node {
|
|
@ValidateNested()
|
|
@JsonType(() => Node)
|
|
child?: Node;
|
|
|
|
value?: string;
|
|
}
|
|
|
|
it('rejects a payload nested past the limit instead of exhausting the stack', async () => {
|
|
await expect(toInstance(Node, nest(500), { validate: false }))
|
|
.rejects.toThrow(JsonMappingError);
|
|
await expect(toInstance(Node, nest(500), { validate: false }))
|
|
.rejects.toThrow(/Maximum nesting depth/);
|
|
});
|
|
|
|
it('accepts nesting within the limit', async () => {
|
|
const parsed = await toInstance(Node, nest(10), { validate: false });
|
|
expect(parsed).toBeInstanceOf(Node);
|
|
});
|
|
|
|
it('is configurable per call and globally', async () => {
|
|
await expect(toInstance(Node, nest(10), { validate: false, maxDepth: 3 }))
|
|
.rejects.toThrow(/Maximum nesting depth of 3/);
|
|
|
|
configure({ maxDepth: 2 });
|
|
await expect(toInstance(Node, nest(10), { validate: false }))
|
|
.rejects.toThrow(/Maximum nesting depth of 2/);
|
|
});
|
|
|
|
it('guards serialization too', async () => {
|
|
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
|
await expect(toPlain(deep, { validate: false, maxDepth: 5 }))
|
|
.rejects.toThrow(/Maximum nesting depth/);
|
|
});
|
|
|
|
it('guards validation too', async () => {
|
|
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
|
await expect(validate(deep, { maxDepth: 5 })).rejects.toThrow(/Maximum nesting depth/);
|
|
});
|
|
});
|
|
|
|
describe('each: true error reporting', () => {
|
|
it('names the index of the element that failed', async () => {
|
|
class Basket {
|
|
@IsIn(['a', 'b'], { each: true })
|
|
tags!: ('a' | 'b')[];
|
|
}
|
|
|
|
const basket = new Basket();
|
|
basket.tags = ['a', 'b', 'a', 'nope' as 'a', 'b'];
|
|
|
|
const errors = await validate(basket);
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0]!.constraints['isIn']).toContain('failed at index 3');
|
|
});
|
|
|
|
it('leaves a caller-supplied message untouched', async () => {
|
|
class Basket {
|
|
@IsIn(['a'], { each: true, message: 'bad tag' })
|
|
tags!: 'a'[];
|
|
}
|
|
const basket = new Basket();
|
|
basket.tags = ['a', 'zzz' as 'a'];
|
|
|
|
const errors = await validate(basket);
|
|
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
|
});
|
|
|
|
it('gives the failing element to a message function, not the whole array', async () => {
|
|
class Basket {
|
|
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
|
|
tags!: 'a'[];
|
|
}
|
|
const basket = new Basket();
|
|
basket.tags = ['a', 'zzz' as 'a'];
|
|
|
|
const errors = await validate(basket);
|
|
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
|
});
|
|
|
|
it('reports nothing when every element passes', async () => {
|
|
class Basket {
|
|
@IsIn(['a', 'b'], { each: true })
|
|
tags!: ('a' | 'b')[];
|
|
}
|
|
const basket = new Basket();
|
|
basket.tags = ['a', 'b'];
|
|
expect(await validate(basket)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('validate() accepts options', () => {
|
|
it('threads maxDepth through nested validation', async () => {
|
|
class Item {
|
|
@IsInt()
|
|
@Min(1)
|
|
qty: number;
|
|
}
|
|
class Order {
|
|
@IsString()
|
|
ref: string;
|
|
|
|
@ValidateNested()
|
|
@JsonType(() => Item)
|
|
items: Item[];
|
|
}
|
|
|
|
const bad = new Item();
|
|
bad.qty = -1;
|
|
const order = new Order();
|
|
order.ref = 'r';
|
|
order.items = [bad];
|
|
|
|
// Deep enough to be fine at the default, so behaviour is unchanged.
|
|
expect(await validate(order)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('decorator context guards', () => {
|
|
// Every decorator resolves its metadata through one checkpoint, so one representative
|
|
// decorator per shape is enough to cover the rule.
|
|
const shapes: [string, unknown][] = [
|
|
['a method', { kind: 'method', name: 'run', metadata: {} }],
|
|
['a getter', { kind: 'getter', name: 'total', metadata: {} }],
|
|
['an accessor', { kind: 'accessor', name: 'value', metadata: {} }],
|
|
['a class', { kind: 'class', name: 'Thing', metadata: {} }],
|
|
];
|
|
|
|
for (const [label, context] of shapes) {
|
|
it(`refuses being applied to ${label}`, () => {
|
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/apply to fields/);
|
|
});
|
|
}
|
|
|
|
it('explains why `accessor` in particular cannot work', () => {
|
|
const context = { kind: 'accessor', name: 'value', metadata: {} };
|
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/private slot/);
|
|
});
|
|
|
|
it('refuses a legacy decorator call shape', () => {
|
|
// What `experimentalDecorators: true` emits: (prototype, propertyKey).
|
|
expect(() => (IsString() as any)({}, 'name')).toThrow(/experimentalDecorators/);
|
|
});
|
|
|
|
it('refuses a standard context that carries no metadata', () => {
|
|
const context = { kind: 'field', name: 'value', metadata: undefined };
|
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/no metadata object/);
|
|
});
|
|
|
|
it('names the field it could not record', () => {
|
|
const context = { kind: 'field', name: 'nickname', metadata: null };
|
|
expect(() => (IsString() as any)(undefined, context)).toThrow(/"nickname"/);
|
|
});
|
|
|
|
it('guards the mapping decorators too, not just the rules', () => {
|
|
expect(() => (JsonSerialize(class {} as any) as any)({}, 'name')).toThrow(/experimentalDecorators/);
|
|
});
|
|
});
|