Skip to content

Commit 9ca4e40

Browse files
committed
test(schema): full coverage for validateSchema and parseSchema
Bring src/schema/validate.ts and src/schema/parse.ts from 0% to 100% statement coverage: - validate.test.mts (17 tests): Zod v3, Zod v4, TypeBox, and a duck-typed `.safeParse` schema — verifies ok/errors paths, path normalization (dotted + JSON-Pointer -> array segments), plus the safeParse-error-shape fallbacks (non-array issues, non-string messages, non-object error values). Exercises the unsupported- schema TypeError branch for null, primitives, and plain objects. - parse.test.mts (5 tests): happy path returns typed value; throwing path wraps all issues into a single `Validation failed: path: msg, ...` Error; root-level (empty path) issues render as `(root)`; nested paths join with `.`; TypeError from unsupported schemas propagates (not wrapped).
1 parent f5d70db commit 9ca4e40

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

test/unit/schema/parse.test.mts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* @fileoverview Unit tests for `parseSchema` — the throwing twin of
3+
* `validateSchema`, used for fail-fast trust boundaries.
4+
*/
5+
6+
import * as zodV3 from 'zod/v3'
7+
import { describe, expect, it } from 'vitest'
8+
9+
import { parseSchema } from '@socketsecurity/lib/schema/parse'
10+
11+
describe('schema/parse', () => {
12+
it('returns the typed value for valid input', () => {
13+
const Config = zodV3.object({
14+
host: zodV3.string(),
15+
port: zodV3.number(),
16+
})
17+
const value = parseSchema(Config, { host: 'localhost', port: 3000 })
18+
expect(value).toEqual({ host: 'localhost', port: 3000 })
19+
})
20+
21+
it('throws an Error whose message summarizes all issues', () => {
22+
const Config = zodV3.object({
23+
host: zodV3.string(),
24+
port: zodV3.number(),
25+
})
26+
expect(() => parseSchema(Config, { host: 42, port: 'oops' })).toThrow(Error)
27+
try {
28+
parseSchema(Config, { host: 42, port: 'oops' })
29+
} catch (err) {
30+
expect(err).toBeInstanceOf(Error)
31+
const msg = (err as Error).message
32+
expect(msg).toMatch(/^Validation failed:/)
33+
// Both field names should appear in the summary.
34+
expect(msg).toContain('host')
35+
expect(msg).toContain('port')
36+
}
37+
})
38+
39+
it('formats root-level errors as "(root)"', () => {
40+
const fakeSchema = {
41+
safeParse: () => ({
42+
success: false as const,
43+
error: {
44+
issues: [{ path: [], message: 'must be a number' }],
45+
},
46+
}),
47+
}
48+
expect(() => parseSchema(fakeSchema, 'x')).toThrow(
49+
/Validation failed: \(root\): must be a number/,
50+
)
51+
})
52+
53+
it('joins nested path segments with dots', () => {
54+
const fakeSchema = {
55+
safeParse: () => ({
56+
success: false as const,
57+
error: {
58+
issues: [
59+
{ path: ['user', 'name'], message: 'required' },
60+
{ path: ['user', 'age'], message: 'must be positive' },
61+
],
62+
},
63+
}),
64+
}
65+
try {
66+
parseSchema(fakeSchema, {})
67+
// Force failure if no throw.
68+
expect.fail('parseSchema should have thrown')
69+
} catch (err) {
70+
const msg = (err as Error).message
71+
expect(msg).toContain('user.name: required')
72+
expect(msg).toContain('user.age: must be positive')
73+
}
74+
})
75+
76+
it('propagates TypeError for unsupported schema (not a validation error)', () => {
77+
expect(() => parseSchema(null, 'x')).toThrow(TypeError)
78+
})
79+
})

test/unit/schema/validate.test.mts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/**
2+
* @fileoverview Unit tests for `validateSchema` — the non-throwing universal
3+
* validator exported from `@socketsecurity/lib/schema/validate`.
4+
*
5+
* Exercises all three supported schema kinds:
6+
* - Zod v3 (via `zod@3`)
7+
* - Zod v4 (via `zod@4`)
8+
* - TypeBox (via the bundled `src/external/@sinclair/typebox` runtime)
9+
*
10+
* Plus error normalization (Zod issue shape + TypeBox ValueError path
11+
* conversion) and the unsupported-schema TypeError branch.
12+
*/
13+
14+
import * as zodV3 from 'zod/v3'
15+
import * as zodV4 from 'zod/v4'
16+
import { describe, expect, it } from 'vitest'
17+
18+
import { validateSchema } from '@socketsecurity/lib/schema/validate'
19+
20+
// TypeBox is bundled under src/external/. Tests run against the
21+
// compiled dist externals path.
22+
import { Type } from '../../../src/external/@sinclair/typebox'
23+
24+
describe('schema/validate', () => {
25+
describe('Zod v3', () => {
26+
it('returns ok with typed value for valid input', () => {
27+
const User = zodV3.object({ name: zodV3.string(), age: zodV3.number() })
28+
const result = validateSchema(User, { name: 'Alice', age: 30 })
29+
expect(result.ok).toBe(true)
30+
if (result.ok) {
31+
expect(result.value).toEqual({ name: 'Alice', age: 30 })
32+
}
33+
})
34+
35+
it('returns normalized errors for invalid input', () => {
36+
const User = zodV3.object({ name: zodV3.string(), age: zodV3.number() })
37+
const result = validateSchema(User, { name: 123, age: 'oops' })
38+
expect(result.ok).toBe(false)
39+
if (!result.ok) {
40+
expect(result.errors.length).toBeGreaterThanOrEqual(2)
41+
for (const issue of result.errors) {
42+
expect(Array.isArray(issue.path)).toBe(true)
43+
expect(typeof issue.message).toBe('string')
44+
}
45+
// Path should carry the field name.
46+
const paths = result.errors.map(e => e.path.join('.'))
47+
expect(paths).toContain('name')
48+
expect(paths).toContain('age')
49+
}
50+
})
51+
52+
it('normalizes nested path segments', () => {
53+
const Wrap = zodV3.object({
54+
user: zodV3.object({ age: zodV3.number() }),
55+
})
56+
const result = validateSchema(Wrap, { user: { age: 'bad' } })
57+
expect(result.ok).toBe(false)
58+
if (!result.ok) {
59+
expect(result.errors[0]!.path).toEqual(['user', 'age'])
60+
}
61+
})
62+
})
63+
64+
describe('Zod v4', () => {
65+
it('returns ok with typed value for valid input', () => {
66+
const User = zodV4.object({ name: zodV4.string() })
67+
const result = validateSchema(User, { name: 'Bob' })
68+
expect(result.ok).toBe(true)
69+
if (result.ok) {
70+
expect(result.value).toEqual({ name: 'Bob' })
71+
}
72+
})
73+
74+
it('returns normalized errors for invalid input', () => {
75+
const User = zodV4.object({ name: zodV4.string() })
76+
const result = validateSchema(User, { name: 42 })
77+
expect(result.ok).toBe(false)
78+
if (!result.ok) {
79+
expect(result.errors.length).toBeGreaterThanOrEqual(1)
80+
expect(result.errors[0]!.path).toEqual(['name'])
81+
expect(typeof result.errors[0]!.message).toBe('string')
82+
}
83+
})
84+
})
85+
86+
describe('TypeBox', () => {
87+
it('returns ok for valid input against a TypeBox schema', () => {
88+
const S = Type.Object({ name: Type.String(), age: Type.Number() })
89+
const result = validateSchema(S, { name: 'Carol', age: 25 })
90+
expect(result.ok).toBe(true)
91+
if (result.ok) {
92+
expect(result.value).toEqual({ name: 'Carol', age: 25 })
93+
}
94+
})
95+
96+
it('returns normalized errors for invalid input', () => {
97+
const S = Type.Object({ age: Type.Number() })
98+
const result = validateSchema(S, { age: 'not-a-number' })
99+
expect(result.ok).toBe(false)
100+
if (!result.ok) {
101+
expect(result.errors.length).toBeGreaterThanOrEqual(1)
102+
for (const issue of result.errors) {
103+
expect(Array.isArray(issue.path)).toBe(true)
104+
expect(typeof issue.message).toBe('string')
105+
}
106+
}
107+
})
108+
109+
it('converts JSON-Pointer paths (/user/0/name) to segment arrays', () => {
110+
// TypeBox surfaces paths as JSON Pointers; validateSchema normalizes
111+
// them to arrays with numeric indices where applicable.
112+
const S = Type.Object({
113+
users: Type.Array(Type.Object({ name: Type.String() })),
114+
})
115+
const result = validateSchema(S, { users: [{ name: 42 }] })
116+
expect(result.ok).toBe(false)
117+
if (!result.ok) {
118+
// Path should include a numeric array index somewhere.
119+
const hasNumericIndex = result.errors.some(e =>
120+
e.path.some(seg => typeof seg === 'number'),
121+
)
122+
expect(hasNumericIndex).toBe(true)
123+
}
124+
})
125+
})
126+
127+
describe('duck-typed .safeParse', () => {
128+
it('accepts any object with .safeParse that returns success', () => {
129+
const fakeSchema = {
130+
safeParse: (data: unknown) => ({ success: true as const, data }),
131+
}
132+
const result = validateSchema(fakeSchema, { foo: 'bar' })
133+
expect(result.ok).toBe(true)
134+
if (result.ok) {
135+
expect(result.value).toEqual({ foo: 'bar' })
136+
}
137+
})
138+
139+
it('accepts any object with .safeParse that returns failure', () => {
140+
const fakeSchema = {
141+
safeParse: () => ({
142+
success: false as const,
143+
error: {
144+
issues: [{ path: ['field'], message: 'nope' }],
145+
},
146+
}),
147+
}
148+
const result = validateSchema(fakeSchema, 'anything')
149+
expect(result.ok).toBe(false)
150+
if (!result.ok) {
151+
expect(result.errors).toEqual([{ path: ['field'], message: 'nope' }])
152+
}
153+
})
154+
155+
it('falls back to a single synthetic issue when error.issues is missing', () => {
156+
const fakeSchema = {
157+
safeParse: () => ({
158+
success: false as const,
159+
error: { notIssues: true },
160+
}),
161+
}
162+
const result = validateSchema(fakeSchema, 'x')
163+
expect(result.ok).toBe(false)
164+
if (!result.ok) {
165+
expect(result.errors).toHaveLength(1)
166+
expect(result.errors[0]!.path).toEqual([])
167+
expect(typeof result.errors[0]!.message).toBe('string')
168+
}
169+
})
170+
171+
it('handles non-object error values by stringifying', () => {
172+
const fakeSchema = {
173+
safeParse: () => ({ success: false as const, error: 'flat-error' }),
174+
}
175+
const result = validateSchema(fakeSchema, 'x')
176+
expect(result.ok).toBe(false)
177+
if (!result.ok) {
178+
expect(result.errors[0]!.message).toContain('flat-error')
179+
}
180+
})
181+
182+
it('coerces a non-array issue.path to []', () => {
183+
const fakeSchema = {
184+
safeParse: () => ({
185+
success: false as const,
186+
error: {
187+
issues: [{ path: 'not-array', message: 'bad' }],
188+
},
189+
}),
190+
}
191+
const result = validateSchema(fakeSchema, 'x')
192+
expect(result.ok).toBe(false)
193+
if (!result.ok) {
194+
expect(result.errors[0]!.path).toEqual([])
195+
expect(result.errors[0]!.message).toBe('bad')
196+
}
197+
})
198+
199+
it('coerces a non-string issue.message to a default', () => {
200+
const fakeSchema = {
201+
safeParse: () => ({
202+
success: false as const,
203+
error: {
204+
issues: [{ path: [], message: 123 }],
205+
},
206+
}),
207+
}
208+
const result = validateSchema(fakeSchema, 'x')
209+
expect(result.ok).toBe(false)
210+
if (!result.ok) {
211+
expect(result.errors[0]!.message).toBe('Invalid value')
212+
}
213+
})
214+
})
215+
216+
describe('unsupported schema kind', () => {
217+
it('throws TypeError for null', () => {
218+
expect(() => validateSchema(null, 'x')).toThrow(TypeError)
219+
})
220+
221+
it('throws TypeError for a plain object without safeParse', () => {
222+
expect(() => validateSchema({ notASchema: true }, 'x')).toThrow(TypeError)
223+
})
224+
225+
it('throws TypeError for primitive inputs', () => {
226+
expect(() => validateSchema(42, 'x')).toThrow(TypeError)
227+
expect(() => validateSchema('string', 'x')).toThrow(TypeError)
228+
})
229+
})
230+
})

0 commit comments

Comments
 (0)