Skip to content

Commit a391177

Browse files
authored
Merge branch 'main' into feat/dual-auth-system
2 parents 4793d5e + cff40d4 commit a391177

1 file changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
/**
2+
* Tests for packages/core/src/utils/zod-utils.ts
3+
*
4+
* All eight exported helpers are pure type-inspection utilities with no I/O.
5+
* The helpers are designed to work across both Zod 3 and Zod 4 by inspecting
6+
* the raw `_def` / `_zod.def` structure, so the test suite exercises every
7+
* branch of the dual-version logic.
8+
*
9+
* The project ships `zod/v4` as the canonical import, so all schemas in these
10+
* tests are created with Zod 4. Where Zod 3 normalisation is tested (e.g.
11+
* `getZodTypeName` returning "ZodString" from a v4 lowercase type string) the
12+
* Zod 3 structure is simulated by constructing minimal plain objects that match
13+
* the expected shape.
14+
*/
15+
import { describe, expect, it } from 'vitest';
16+
import { z } from 'zod/v4';
17+
18+
import {
19+
getZodDef,
20+
getZodInnerType,
21+
getZodTypeName,
22+
isZodArray,
23+
isZodObject,
24+
isZodType,
25+
safeExtendZodObject,
26+
unwrapZodType,
27+
} from './zod-utils';
28+
29+
// ---------------------------------------------------------------------------
30+
// isZodType
31+
// ---------------------------------------------------------------------------
32+
33+
describe('isZodType', () => {
34+
it('returns true for a z.string() schema', () => {
35+
expect(isZodType(z.string())).toBe(true);
36+
});
37+
38+
it('returns true for a z.number() schema', () => {
39+
expect(isZodType(z.number())).toBe(true);
40+
});
41+
42+
it('returns true for a z.object() schema', () => {
43+
expect(isZodType(z.object({ a: z.string() }))).toBe(true);
44+
});
45+
46+
it('returns true for a z.array() schema', () => {
47+
expect(isZodType(z.array(z.string()))).toBe(true);
48+
});
49+
50+
it('returns true for a z.optional() schema', () => {
51+
expect(isZodType(z.string().optional())).toBe(true);
52+
});
53+
54+
it('returns false for null', () => {
55+
expect(isZodType(null)).toBe(false);
56+
});
57+
58+
it('returns false for undefined', () => {
59+
expect(isZodType(undefined)).toBe(false);
60+
});
61+
62+
it('returns false for a plain number', () => {
63+
expect(isZodType(42)).toBe(false);
64+
});
65+
66+
it('returns false for a plain string', () => {
67+
expect(isZodType('hello')).toBe(false);
68+
});
69+
70+
it('returns true for a plain object that duck-types Zod internals', () => {
71+
expect(isZodType({ _def: {}, parse: () => {}, safeParse: () => {} })).toBe(true);
72+
});
73+
74+
it('returns false for an object missing the parse method', () => {
75+
expect(isZodType({ _def: {}, safeParse: () => {} })).toBe(false);
76+
});
77+
78+
it('returns false for an object missing safeParse', () => {
79+
expect(isZodType({ _def: {}, parse: () => {} })).toBe(false);
80+
});
81+
82+
it('returns false for a function', () => {
83+
expect(isZodType(() => {})).toBe(false);
84+
});
85+
});
86+
87+
// ---------------------------------------------------------------------------
88+
// getZodTypeName
89+
// ---------------------------------------------------------------------------
90+
91+
describe('getZodTypeName', () => {
92+
it('returns "ZodString" for z.string()', () => {
93+
const name = getZodTypeName(z.string());
94+
expect(name).toBe('ZodString');
95+
});
96+
97+
it('returns "ZodNumber" for z.number()', () => {
98+
expect(getZodTypeName(z.number())).toBe('ZodNumber');
99+
});
100+
101+
it('returns "ZodBoolean" for z.boolean()', () => {
102+
expect(getZodTypeName(z.boolean())).toBe('ZodBoolean');
103+
});
104+
105+
it('returns "ZodObject" for z.object()', () => {
106+
expect(getZodTypeName(z.object({}))).toBe('ZodObject');
107+
});
108+
109+
it('returns "ZodArray" for z.array()', () => {
110+
expect(getZodTypeName(z.array(z.string()))).toBe('ZodArray');
111+
});
112+
113+
it('returns "ZodOptional" for z.string().optional()', () => {
114+
expect(getZodTypeName(z.string().optional())).toBe('ZodOptional');
115+
});
116+
117+
it('returns "ZodNullable" for z.string().nullable()', () => {
118+
expect(getZodTypeName(z.string().nullable())).toBe('ZodNullable');
119+
});
120+
121+
it('returns "ZodDefault" for z.string().default("x")', () => {
122+
expect(getZodTypeName(z.string().default('x'))).toBe('ZodDefault');
123+
});
124+
125+
it('normalises a Zod 3-style _def.typeName to the same string', () => {
126+
// Simulate a Zod 3 schema object
127+
const zod3Schema = { _def: { typeName: 'ZodString' }, parse: () => {}, safeParse: () => {} } as any;
128+
expect(getZodTypeName(zod3Schema)).toBe('ZodString');
129+
});
130+
131+
it('returns undefined for a schema with no recognisable type info', () => {
132+
const bare = { _def: {}, parse: () => {}, safeParse: () => {} } as any;
133+
expect(getZodTypeName(bare)).toBeUndefined();
134+
});
135+
});
136+
137+
// ---------------------------------------------------------------------------
138+
// isZodArray
139+
// ---------------------------------------------------------------------------
140+
141+
describe('isZodArray', () => {
142+
it('returns true for z.array(z.string())', () => {
143+
expect(isZodArray(z.array(z.string()))).toBe(true);
144+
});
145+
146+
it('returns true for z.array(z.number())', () => {
147+
expect(isZodArray(z.array(z.number()))).toBe(true);
148+
});
149+
150+
it('returns false for z.string()', () => {
151+
expect(isZodArray(z.string())).toBe(false);
152+
});
153+
154+
it('returns false for z.object()', () => {
155+
expect(isZodArray(z.object({ items: z.array(z.string()) }))).toBe(false);
156+
});
157+
158+
it('returns false for a non-Zod value', () => {
159+
expect(isZodArray([])).toBe(false);
160+
expect(isZodArray(null)).toBe(false);
161+
});
162+
});
163+
164+
// ---------------------------------------------------------------------------
165+
// isZodObject
166+
// ---------------------------------------------------------------------------
167+
168+
describe('isZodObject', () => {
169+
it('returns true for z.object({})', () => {
170+
expect(isZodObject(z.object({}))).toBe(true);
171+
});
172+
173+
it('returns true for a z.object() with nested fields', () => {
174+
expect(isZodObject(z.object({ name: z.string(), age: z.number() }))).toBe(true);
175+
});
176+
177+
it('returns false for z.array()', () => {
178+
expect(isZodObject(z.array(z.string()))).toBe(false);
179+
});
180+
181+
it('returns false for z.string()', () => {
182+
expect(isZodObject(z.string())).toBe(false);
183+
});
184+
185+
it('returns false for a plain JS object', () => {
186+
expect(isZodObject({ a: 1 })).toBe(false);
187+
});
188+
});
189+
190+
// ---------------------------------------------------------------------------
191+
// safeExtendZodObject
192+
// ---------------------------------------------------------------------------
193+
194+
describe('safeExtendZodObject', () => {
195+
it('adds a new field to a plain ZodObject', () => {
196+
const base = z.object({ name: z.string() });
197+
const extended = safeExtendZodObject(base, { age: z.number() });
198+
199+
expect(isZodObject(extended)).toBe(true);
200+
// The extended schema should accept an object with both fields
201+
const result = extended.safeParse({ name: 'Alice', age: 30 });
202+
expect(result.success).toBe(true);
203+
});
204+
205+
it('the base schema is not mutated', () => {
206+
const base = z.object({ name: z.string() });
207+
safeExtendZodObject(base, { age: z.number() });
208+
209+
// Original schema should reject the extra field in strict mode
210+
const result = base.safeParse({ name: 'Alice' });
211+
expect(result.success).toBe(true);
212+
});
213+
214+
it('can override an existing field type', () => {
215+
const base = z.object({ value: z.string() });
216+
const extended = safeExtendZodObject(base, { value: z.number() });
217+
218+
expect(extended.safeParse({ value: 42 }).success).toBe(true);
219+
expect(extended.safeParse({ value: 'hello' }).success).toBe(false);
220+
});
221+
222+
it('adds multiple fields at once', () => {
223+
const base = z.object({ id: z.string() });
224+
const extended = safeExtendZodObject(base, {
225+
name: z.string(),
226+
active: z.boolean(),
227+
});
228+
229+
expect(extended.safeParse({ id: '1', name: 'Bob', active: true }).success).toBe(true);
230+
});
231+
232+
it('uses safeExtend when available (Zod 4 path)', () => {
233+
const base = z.object({ name: z.string() });
234+
let safeExtendCalled = false;
235+
const patchedBase = Object.create(base);
236+
patchedBase.safeExtend = function (...args: any[]) {
237+
safeExtendCalled = true;
238+
return (base.extend as any)(...args);
239+
};
240+
241+
safeExtendZodObject(patchedBase, { age: z.number() });
242+
expect(safeExtendCalled).toBe(true);
243+
});
244+
});
245+
246+
// ---------------------------------------------------------------------------
247+
// getZodDef
248+
// ---------------------------------------------------------------------------
249+
250+
describe('getZodDef', () => {
251+
it('returns the _def object for a z.string() schema', () => {
252+
const def = getZodDef(z.string());
253+
expect(def).toBeDefined();
254+
expect(typeof def).toBe('object');
255+
});
256+
257+
it('returns a def with type info for z.number()', () => {
258+
const def = getZodDef(z.number());
259+
expect(def).toBeDefined();
260+
});
261+
262+
it('returns the _def for a z.object() schema', () => {
263+
const schema = z.object({ a: z.string() });
264+
const def = getZodDef(schema);
265+
expect(def).toBeDefined();
266+
});
267+
268+
it('prefers _zod.def over _def when both exist (Zod 4 internal path)', () => {
269+
const zod4Style = {
270+
_zod: { def: { type: 'string', marker: 'zod4' } },
271+
_def: { typeName: 'ZodString', marker: 'zod3' },
272+
parse: () => {},
273+
safeParse: () => {},
274+
} as any;
275+
const def = getZodDef(zod4Style);
276+
expect(def.marker).toBe('zod4');
277+
});
278+
279+
it('falls back to _def when _zod.def is absent', () => {
280+
const zod3Style = {
281+
_def: { typeName: 'ZodString', marker: 'zod3' },
282+
parse: () => {},
283+
safeParse: () => {},
284+
} as any;
285+
const def = getZodDef(zod3Style);
286+
expect(def.marker).toBe('zod3');
287+
});
288+
});
289+
290+
// ---------------------------------------------------------------------------
291+
// getZodInnerType
292+
// ---------------------------------------------------------------------------
293+
294+
describe('getZodInnerType', () => {
295+
it('returns the inner type for ZodOptional', () => {
296+
const schema = z.string().optional();
297+
const inner = getZodInnerType(schema, 'ZodOptional');
298+
expect(inner).toBeDefined();
299+
expect(getZodTypeName(inner!)).toBe('ZodString');
300+
});
301+
302+
it('returns the inner type for ZodNullable', () => {
303+
const schema = z.number().nullable();
304+
const inner = getZodInnerType(schema, 'ZodNullable');
305+
expect(inner).toBeDefined();
306+
expect(getZodTypeName(inner!)).toBe('ZodNumber');
307+
});
308+
309+
it('returns the inner type for ZodDefault', () => {
310+
const schema = z.string().default('hello');
311+
const inner = getZodInnerType(schema, 'ZodDefault');
312+
expect(inner).toBeDefined();
313+
expect(getZodTypeName(inner!)).toBe('ZodString');
314+
});
315+
316+
it('returns undefined for an unrecognised typeName', () => {
317+
const schema = z.string();
318+
expect(getZodInnerType(schema, 'ZodUnknownWrapper')).toBeUndefined();
319+
});
320+
321+
it('returns undefined when called on a non-wrapper type', () => {
322+
// z.string() has no innerType
323+
const schema = z.string();
324+
expect(getZodInnerType(schema, 'ZodString')).toBeUndefined();
325+
});
326+
});
327+
328+
// ---------------------------------------------------------------------------
329+
// unwrapZodType
330+
// ---------------------------------------------------------------------------
331+
332+
describe('unwrapZodType', () => {
333+
it('returns the schema unchanged when it has no wrapper', () => {
334+
const schema = z.string();
335+
expect(unwrapZodType(schema)).toBe(schema);
336+
});
337+
338+
it('unwraps a single ZodOptional to the base ZodString', () => {
339+
const schema = z.string().optional();
340+
const unwrapped = unwrapZodType(schema);
341+
expect(getZodTypeName(unwrapped)).toBe('ZodString');
342+
});
343+
344+
it('unwraps a single ZodNullable to the base ZodNumber', () => {
345+
const schema = z.number().nullable();
346+
const unwrapped = unwrapZodType(schema);
347+
expect(getZodTypeName(unwrapped)).toBe('ZodNumber');
348+
});
349+
350+
it('unwraps ZodDefault wrapping ZodBoolean', () => {
351+
const schema = z.boolean().default(false);
352+
const unwrapped = unwrapZodType(schema);
353+
expect(getZodTypeName(unwrapped)).toBe('ZodBoolean');
354+
});
355+
356+
it('unwraps multiple layers: optional(nullable(string)) → ZodString', () => {
357+
const schema = z.string().nullable().optional();
358+
const unwrapped = unwrapZodType(schema);
359+
expect(getZodTypeName(unwrapped)).toBe('ZodString');
360+
});
361+
362+
it('unwraps deeply: default(optional(nullable(array(string)))) → ZodArray', () => {
363+
const schema = z.array(z.string()).nullable().optional().default([]);
364+
const unwrapped = unwrapZodType(schema);
365+
expect(getZodTypeName(unwrapped)).toBe('ZodArray');
366+
});
367+
368+
it('stops at a ZodObject (non-wrapper type)', () => {
369+
const schema = z.object({ name: z.string() }).optional();
370+
const unwrapped = unwrapZodType(schema);
371+
expect(getZodTypeName(unwrapped)).toBe('ZodObject');
372+
});
373+
374+
it('stops at a ZodArray (non-wrapper type)', () => {
375+
const schema = z.array(z.number()).nullable();
376+
const unwrapped = unwrapZodType(schema);
377+
expect(getZodTypeName(unwrapped)).toBe('ZodArray');
378+
});
379+
});

0 commit comments

Comments
 (0)