refactor: Migrate config schema from Zod v3 to Zod v4 (#2357)
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z, ZodError } from 'zod';
|
||||
import {
|
||||
deepRemoveDefaults,
|
||||
getParseErrorKeys,
|
||||
getParseErrorPaths,
|
||||
} from '../../src/utils/zod';
|
||||
|
||||
describe('deepRemoveDefaults', () => {
|
||||
it('should remove string defaults', () => {
|
||||
const schema = z.object({
|
||||
string: z.string().default('foo'),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({});
|
||||
expect(result.string).toBeUndefined();
|
||||
});
|
||||
it('should remove array defaults', () => {
|
||||
const schema = z.object({
|
||||
array: z.string().array().default(['foo']),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({});
|
||||
expect(result.array).toBeUndefined();
|
||||
});
|
||||
it('should remove optional defaults', () => {
|
||||
const schema = z.object({
|
||||
string: z.string().default('foo').optional(),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({});
|
||||
expect(result.string).toBeUndefined();
|
||||
});
|
||||
it('should remove null defaults', () => {
|
||||
const schema = z.object({
|
||||
null: z.string().default('foo').nullable(),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({});
|
||||
expect(result.null).toBeUndefined();
|
||||
});
|
||||
it('should remove null defaults', () => {
|
||||
const schema = z.object({
|
||||
tuple: z.tuple([z.string()]).default(['foo']),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({});
|
||||
expect(result.tuple).toBeUndefined();
|
||||
});
|
||||
it('should not interfere with parsing', () => {
|
||||
const schema = z.object({
|
||||
string: z.string().default('foo'),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({ string: 'moo' });
|
||||
expect(result.string).toBe('moo');
|
||||
});
|
||||
describe('should still enforce array length', () => {
|
||||
it('min', () => {
|
||||
const schema = z.number().array().min(1);
|
||||
const result = deepRemoveDefaults(schema).safeParse([]);
|
||||
expect(result.success).toBeFalsy();
|
||||
});
|
||||
it('max', () => {
|
||||
const schema = z.number().array().max(1);
|
||||
const result = deepRemoveDefaults(schema).safeParse([1, 2]);
|
||||
expect(result.success).toBeFalsy();
|
||||
});
|
||||
it('exact', () => {
|
||||
const schema = z.number().array().length(1);
|
||||
const result = deepRemoveDefaults(schema).safeParse([]);
|
||||
expect(result.success).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParseErrorKeys', () => {
|
||||
it('should get error keys', () => {
|
||||
const result = z.object({ required: z.string() }).safeParse({});
|
||||
expect(result.success).toBeFalsy();
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(getParseErrorKeys(result.error)).toEqual(['required']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParseErrorPaths', () => {
|
||||
it('should get simple error paths', () => {
|
||||
const result = z.object({ required: z.string() }).safeParse({});
|
||||
expect(result.success).toBeFalsy();
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['required']));
|
||||
});
|
||||
it('should get union error paths', () => {
|
||||
const type_one = z.object({ type: z.string(), data: z.string() });
|
||||
const type_two = z.object({ type: z.literal('two'), data: z.string() });
|
||||
|
||||
const schema = z.object({
|
||||
array: type_one.or(type_two).array(),
|
||||
});
|
||||
|
||||
const result = schema.safeParse({ array: [{}] });
|
||||
expect(result.success).toBeFalsy();
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(getParseErrorPaths(result.error)).toEqual(
|
||||
new Set(['array[0] -> type', 'array[0] -> data']),
|
||||
);
|
||||
});
|
||||
it('should get no paths for empty error', () => {
|
||||
expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { deepRemoveDefaults } from '../../../src/utils/zod/deep-remove-defaults';
|
||||
|
||||
describe('deepNoDefaults', () => {
|
||||
describe('object field behavior', () => {
|
||||
it('strips defaulted object fields', () => {
|
||||
const schema = z.object({
|
||||
string: z.string().default('foo'),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({});
|
||||
expect(result.string).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips prefaulted object fields', () => {
|
||||
const schema = z.object({
|
||||
string: z.string().prefault('foo'),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).parse({});
|
||||
expect(result.string).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps non-defaulted object fields required', () => {
|
||||
const schema = z.object({
|
||||
required: z.string(),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps explicitly optional fields optional', () => {
|
||||
const schema = z.object({
|
||||
maybe: z.string().optional(),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.maybe).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not double-wrap an already optional field when stripping', () => {
|
||||
const schema = z.object({
|
||||
value: z.string().optional().default('x'),
|
||||
});
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
const field = stripped.shape.value;
|
||||
expect(field).toBeInstanceOf(z.ZodOptional);
|
||||
expect(field.unwrap()).toBeInstanceOf(z.ZodString);
|
||||
expect(stripped.parse({}).value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('makes union-defaulted fields optional', () => {
|
||||
const schema = z.object({
|
||||
x: z.union([z.string().default(''), z.number().default(0)]),
|
||||
});
|
||||
const parsed = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.x).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps union fields required when no union option has defaults', () => {
|
||||
const schema = z.object({
|
||||
x: z.union([z.string(), z.number()]),
|
||||
});
|
||||
const parsed = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it('detects defaults under nullable wrappers for optionalization', () => {
|
||||
const schema = z.object({
|
||||
maybe: z.string().default('x').nullable(),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.maybe).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('detects defaults under readonly wrappers for optionalization', () => {
|
||||
const schema = z.object({
|
||||
readOnlyValue: z.string().default('x').readonly(),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.readOnlyValue).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('detects defaults under nonoptional wrappers for optionalization', () => {
|
||||
const schema = z.object({
|
||||
strictValue: z.string().default('x').nonoptional(),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.strictValue).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('detects defaults under pipe wrappers for optionalization', () => {
|
||||
const schema = z.object({
|
||||
piped: z.string().default('x').pipe(z.string().min(1)),
|
||||
});
|
||||
const result = deepRemoveDefaults(schema).safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.piped).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('root wrapper stripping', () => {
|
||||
it('strips a root default wrapper', () => {
|
||||
const schema = z.string().default('foo');
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
expect(stripped.parse('bar')).toBe('bar');
|
||||
});
|
||||
|
||||
it('strips a root prefault wrapper', () => {
|
||||
const schema = z.string().prefault('foo');
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
expect(stripped.parse('bar')).toBe('bar');
|
||||
});
|
||||
|
||||
it('returns leaf schemas as-is when no changes are needed', () => {
|
||||
const schema = z.string().min(1);
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped).toBe(schema);
|
||||
expect(stripped.safeParse('').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('container schemas', () => {
|
||||
it('strips defaults from array element schemas', () => {
|
||||
const schema = z.array(z.string().default('foo'));
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse([undefined]).success).toBe(false);
|
||||
expect(stripped.safeParse(['ok']).success).toBe(true);
|
||||
});
|
||||
|
||||
it('still enforces array length constraints', () => {
|
||||
expect(deepRemoveDefaults(z.number().array().min(1)).safeParse([]).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
deepRemoveDefaults(z.number().array().max(1)).safeParse([1, 2]).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
deepRemoveDefaults(z.number().array().length(1)).safeParse([]).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('strips defaults from tuple items when tuple has no rest', () => {
|
||||
const schema = z.tuple([z.string().default('foo')]);
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse([]).success).toBe(false);
|
||||
expect(stripped.safeParse([undefined]).success).toBe(false);
|
||||
expect(stripped.safeParse(['ok']).success).toBe(true);
|
||||
});
|
||||
|
||||
it('strips defaults from tuple items and tuple rest', () => {
|
||||
const schema = z.tuple([z.string().default('foo')], z.number().default(1));
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse([undefined]).success).toBe(false);
|
||||
expect(stripped.safeParse(['ok', undefined]).success).toBe(false);
|
||||
expect(stripped.safeParse(['ok', 2]).success).toBe(true);
|
||||
});
|
||||
|
||||
it('strips defaults from all union options', () => {
|
||||
const schema = z.union([z.string().default('foo'), z.number().default(1)]);
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
expect(stripped.safeParse('ok').success).toBe(true);
|
||||
expect(stripped.safeParse(2).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recursive and cache behavior', () => {
|
||||
it('handles lazy schemas while stripping nested defaults', () => {
|
||||
const schema = z.lazy(() =>
|
||||
z.object({
|
||||
value: z.string().default('foo'),
|
||||
}),
|
||||
);
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
const parsed = stripped.safeParse({});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.value).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles getter-based recursive objects without stack overflow', () => {
|
||||
const categorySchema = z.object({
|
||||
name: z.string().default('root'),
|
||||
get children() {
|
||||
return z.array(categorySchema).default([]);
|
||||
},
|
||||
});
|
||||
|
||||
const stripped = deepRemoveDefaults(categorySchema);
|
||||
const result = stripped.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBeUndefined();
|
||||
expect(result.data.children).toBeUndefined();
|
||||
}
|
||||
|
||||
// Force recursive traversal so the forward-reference lazy callback is executed.
|
||||
const nested = stripped.safeParse({
|
||||
children: [
|
||||
{
|
||||
children: [{}],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(nested.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles self-referential lazy schemas in default detection', () => {
|
||||
const self: z.ZodType = z.lazy(() => self);
|
||||
const schema = z.object({
|
||||
node: self,
|
||||
});
|
||||
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.shape.node).toBeInstanceOf(z.ZodLazy);
|
||||
expect(stripped.shape.node).not.toBeInstanceOf(z.ZodOptional);
|
||||
});
|
||||
|
||||
it('reuses cached child transforms for shared schema instances', () => {
|
||||
const shared = z.string().default('foo');
|
||||
const schema = z.object({
|
||||
a: shared,
|
||||
b: shared,
|
||||
});
|
||||
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
const a = stripped.shape.a;
|
||||
const b = stripped.shape.b;
|
||||
expect(a).toBeInstanceOf(z.ZodOptional);
|
||||
expect(b).toBeInstanceOf(z.ZodOptional);
|
||||
expect(a.unwrap()).toBe(b.unwrap());
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipe and single-child wrappers', () => {
|
||||
it('strips defaults from pipe output schemas', () => {
|
||||
const schema = z.string().optional().pipe(z.string().default('bar'));
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
expect(stripped.safeParse('ok').success).toBe(true);
|
||||
});
|
||||
|
||||
it('strips defaults inside optional wrappers', () => {
|
||||
const schema = z.string().default('foo').optional();
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(undefined).success).toBe(true);
|
||||
expect(stripped.safeParse('ok').success).toBe(true);
|
||||
expect(stripped.safeParse(1).success).toBe(false);
|
||||
});
|
||||
|
||||
it('strips defaults inside nullable wrappers', () => {
|
||||
const schema = z.string().default('foo').nullable();
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(null).success).toBe(true);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
});
|
||||
|
||||
it('strips defaults inside readonly wrappers', () => {
|
||||
const schema = z.string().default('foo').readonly();
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse('ok').success).toBe(true);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
});
|
||||
|
||||
it('strips defaults inside nonoptional wrappers', () => {
|
||||
const schema = z.string().optional().default('foo').nonoptional();
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
expect(stripped.safeParse('ok').success).toBe(true);
|
||||
});
|
||||
|
||||
it('strips defaults inside catch wrappers', () => {
|
||||
const schema = z.string().default('foo').catch('fallback');
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
const parsed = stripped.safeParse(undefined);
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data).toBe('fallback');
|
||||
}
|
||||
});
|
||||
|
||||
it('strips defaults inside success wrappers', () => {
|
||||
const schema = z.success(z.string().default('foo'));
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
expect(stripped.safeParse(undefined).success).toBe(false);
|
||||
expect(stripped.parse('ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('strips defaults inside promise wrappers', async () => {
|
||||
const schema = z.promise(z.string().default('foo'));
|
||||
const stripped = deepRemoveDefaults(schema);
|
||||
|
||||
const missing = await stripped.safeParseAsync(Promise.resolve(undefined));
|
||||
expect(missing.success).toBe(false);
|
||||
|
||||
const present = await stripped.safeParseAsync(Promise.resolve('ok'));
|
||||
expect(present.success).toBe(true);
|
||||
if (present.success) {
|
||||
expect(present.data).toBe('ok');
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when a wrapper unwraps to a non-classic schema value', () => {
|
||||
const schema = z.string().nullable();
|
||||
Object.defineProperty(schema, 'unwrap', {
|
||||
value: () => ({}) as unknown as z.core.$ZodType,
|
||||
});
|
||||
|
||||
expect(() => deepRemoveDefaults(schema)).toThrowError(
|
||||
'deepRemoveDefaults supports full zod schemas only',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { getParseError, getParseErrorPaths } from '../../../src/utils/zod/parse-errors';
|
||||
|
||||
describe('getParseErrorPaths', () => {
|
||||
it('should get error paths', () => {
|
||||
const result = z
|
||||
.object({ a: z.string(), b: z.number() })
|
||||
.safeParse({ a: 1, b: 'a' });
|
||||
if (result.success) return;
|
||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a', 'b']));
|
||||
});
|
||||
|
||||
it('should get nested error paths', () => {
|
||||
const result = z
|
||||
.object({ a: z.object({ b: z.string() }) })
|
||||
.safeParse({ a: { b: 1 } });
|
||||
if (result.success) return;
|
||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a.b']));
|
||||
});
|
||||
|
||||
it('should get array error paths', () => {
|
||||
const result = z.array(z.string()).safeParse([1, 'a', 2]);
|
||||
if (result.success) return;
|
||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['[0]', '[2]']));
|
||||
});
|
||||
|
||||
it('should get complex nested error paths', () => {
|
||||
const result = z
|
||||
.object({ a: z.array(z.object({ b: z.string() })) })
|
||||
.safeParse({ a: [{ b: 1 }, { b: 'a' }, { b: 2 }] });
|
||||
if (result.success) return;
|
||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a[0].b', 'a[2].b']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParseError', () => {
|
||||
it('should get simple error paths', () => {
|
||||
const result = z.object({ required: z.string() }).safeParse({});
|
||||
expect(result.success).toBeFalsy();
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(getParseError(result.error)).toBe('[\n "required"\n]');
|
||||
});
|
||||
|
||||
it('should get union error paths', () => {
|
||||
const type_one = z.object({ type: z.string(), data: z.string() });
|
||||
const type_two = z.object({ type: z.literal('two'), data: z.string() });
|
||||
|
||||
const schema = z.object({
|
||||
array: type_one.or(type_two).array(),
|
||||
});
|
||||
|
||||
const result = schema.safeParse({ array: [{}] });
|
||||
expect(result.success).toBeFalsy();
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(getParseError(result.error)).toBe(
|
||||
'[\n "array[0].type",\n "array[0].data"\n]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get root union error paths', () => {
|
||||
const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]);
|
||||
|
||||
const result = schema.safeParse({});
|
||||
expect(result.success).toBeFalsy();
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(getParseError(result.error)).toBe('[\n "a",\n "b"\n]');
|
||||
});
|
||||
|
||||
it('should get no paths for empty error', () => {
|
||||
expect(getParseError(new ZodError([]))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user