n8n/packages/@n8n/json-schema-to-zod/test/parsers/parse-object.test.ts

905 lines
20 KiB
TypeScript

/* eslint-disable n8n-local-rules/no-skipped-tests */
import type { JSONSchema7 } from 'json-schema';
import { z, ZodError } from 'zod';
import { parseObject } from '../../src/parsers/parse-object';
describe('parseObject', () => {
test('should handle with missing properties', () => {
expect(
parseObject(
{
type: 'object',
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.any()));
});
test('should handle with empty properties', () => {
expect(
parseObject(
{
type: 'object',
properties: {},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({}));
});
test('With properties - should handle optional and required properties', () => {
expect(
parseObject(
{
type: 'object',
required: ['myRequiredString'],
properties: {
myOptionalString: {
type: 'string',
},
myRequiredString: {
type: 'string',
},
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ myOptionalString: z.string().optional(), myRequiredString: z.string() }),
);
});
test('With properties - should handle additionalProperties when set to false', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: false,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).strict());
});
test('With properties - should handle additionalProperties when set to true', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).catchall(z.any()));
});
test('With properties - should handle additionalProperties when provided a schema', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: { type: 'number' },
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).catchall(z.number()));
});
test('Without properties - should handle additionalProperties when set to false', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: false,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.never()));
});
test('Without properties - should handle additionalProperties when set to true', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.any()));
});
test('Without properties - should handle additionalProperties when provided a schema', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: { type: 'number' },
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.number()));
});
test('Without properties - should include falsy defaults', () => {
expect(
parseObject(
{
type: 'object',
properties: {
s: {
type: 'string',
default: '',
},
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ s: z.string().default('') }));
});
test('eh', () => {
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
anyOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.object({ a: z.string() })
.and(z.union([z.object({ b: z.string() }), z.object({ c: z.string() })])),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
anyOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ a: z.string() }).and(z.union([z.object({ b: z.string() }), z.any()])));
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
oneOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(
z.any().superRefine((x, ctx) => {
const schemas = [z.object({ b: z.string() }), z.object({ c: z.string() })];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(
schema.safeParse(x),
),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
oneOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(
z.any().superRefine((x, ctx) => {
const schemas = [z.object({ b: z.string() }), z.any()];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(
schema.safeParse(x),
),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
allOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.object({ a: z.string() })
.and(z.intersection(z.object({ b: z.string() }), z.object({ c: z.string() }))),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
allOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(z.intersection(z.object({ b: z.string() }), z.any())),
);
});
const run = (zodSchema: z.ZodTypeAny, data: unknown) => zodSchema.safeParse(data);
test('Functional tests - run', () => {
expect(run(z.string(), 'hello')).toEqual({
success: true,
data: 'hello',
});
});
test('Functional tests - properties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
};
const expected = z.object({ a: z.string(), b: z.number().optional() });
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { a: 'hello' })).toEqual({
success: true,
data: {
a: 'hello',
},
});
expect(run(result, { a: 'hello', b: 123 })).toEqual({
success: true,
data: {
a: 'hello',
b: 123,
},
});
expect(run(result, { b: 'hello', x: true })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['a'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'number',
received: 'string',
path: ['b'],
message: 'Expected number, received string',
},
]),
});
});
test('Functional tests - properties and additionalProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
additionalProperties: { type: 'boolean' },
};
const expected = z.object({ a: z.string(), b: z.number().optional() }).catchall(z.boolean());
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { b: 'hello', x: 'true' })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['a'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'number',
received: 'string',
path: ['b'],
message: 'Expected number, received string',
},
{
code: 'invalid_type',
expected: 'boolean',
received: 'string',
path: ['x'],
message: 'Expected boolean, received string',
},
]),
});
});
test('Functional tests - properties and single-item patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
patternProperties: {
'\\.': { type: 'array' },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.array(z.any()))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { a: 'a', b: 2, '.': [] })).toEqual({
success: true,
data: { a: 'a', b: 2, '.': [] },
});
expect(run(result, { a: 'a', b: 2, '.': '[]' })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'array',
received: 'string',
path: ['.'],
message: 'Expected array, received string',
},
]),
});
});
test('Functional tests - properties, additionalProperties and patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
additionalProperties: { type: 'boolean' },
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.union([z.array(z.any()), z.array(z.any()).min(1), z.boolean()]))
.superRefine((value, ctx) => {
for (const key in value) {
let evaluated = ['a', 'b'].includes(key);
if (key.match(new RegExp('\\\\.'))) {
evaluated = true;
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\\\,'))) {
evaluated = true;
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (!evaluated) {
const result = z.boolean().safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: 'Invalid input: must match catchall schema',
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - additionalProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
additionalProperties: { type: 'boolean' },
};
const expected = z.record(z.boolean());
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - additionalProperties and patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
additionalProperties: { type: 'boolean' },
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.record(z.union([z.array(z.any()), z.array(z.any()).min(1), z.boolean()]))
.superRefine((value, ctx) => {
for (const key in value) {
let evaluated = false;
if (key.match(new RegExp('\\\\.'))) {
evaluated = true;
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\\\,'))) {
evaluated = true;
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (!evaluated) {
const result = z.boolean().safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: 'Invalid input: must match catchall schema',
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { x: true, '.': [], ',': [] })).toEqual({
success: false,
error: new ZodError([
{
path: [','],
code: 'custom',
message: 'Invalid input: Key matching regex /,/ must match schema',
params: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'array',
inclusive: true,
exact: false,
message: 'Array must contain at least 1 element(s)',
path: [],
},
],
},
},
]),
});
});
test('Functional tests - single-item patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
patternProperties: {
'\\.': { type: 'array' },
},
};
const expected = z.record(z.array(z.any())).superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.record(z.union([z.array(z.any()), z.array(z.any()).min(1)]))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\,'))) {
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(run(result, { '.': [] })).toEqual({
success: true,
data: { '.': [] },
});
expect(run(result, { ',': [] })).toEqual({
success: false,
error: new ZodError([
{
path: [','],
code: 'custom',
message: 'Invalid input: Key matching regex /,/ must match schema',
params: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'array',
inclusive: true,
exact: false,
message: 'Array must contain at least 1 element(s)',
path: [],
},
],
},
},
]),
});
expect(result).toMatchZod(expected);
});
test('Functional tests - patternProperties and properties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.union([z.array(z.any()), z.array(z.any()).min(1)]))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\,'))) {
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
});