From dab59520c35872d197414c1bf57878bebc50ae48 Mon Sep 17 00:00:00 2001 From: Maria Florencia Besteiro Gonzalez Date: Mon, 1 Dec 2025 09:49:28 +0100 Subject: [PATCH] Merge pull request #29930 from overleaf/mfb-fix-zod-iso-datetime-error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allow iso date time string with offset on zod validation. add unit te… GitOrigin-RevId: 88407fe681a66d13737de41789a9ea807a23627a --- .../test/unit/src/zodHelpers.test.ts | 176 ++++++++++++++++++ libraries/validation-tools/types.d.ts | 6 + libraries/validation-tools/zodHelpers.js | 28 +-- 3 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 libraries/validation-tools/types.d.ts diff --git a/libraries/validation-tools/test/unit/src/zodHelpers.test.ts b/libraries/validation-tools/test/unit/src/zodHelpers.test.ts index 08fb6e758c..427eb66572 100644 --- a/libraries/validation-tools/test/unit/src/zodHelpers.test.ts +++ b/libraries/validation-tools/test/unit/src/zodHelpers.test.ts @@ -41,4 +41,180 @@ describe('zodHelpers', () => { expect(parsed.data?.toString()).toBe('507f1f77bcf86cd799439011') }) }) + describe('datetime', () => { + it('parses valid ISO 8601 datetime strings', () => { + const parsed = zz.datetime().safeParse('2024-01-01T12:00:00Z') + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(new Date('2024-01-01T12:00:00Z')) + }) + + it('parses a valid ISO 8601 datetime with offset', () => { + const parsed = zz + .datetime({ offset: true }) + .safeParse('2024-01-01T12:00:00+00:00') + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(new Date('2024-01-01T12:00:00+00:00')) + }) + + it('parses a valid Date object', () => { + const date = new Date('2024-01-01T12:00:00Z') + const parsed = zz.datetime().safeParse(date) + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(date) + }) + + it('fails to parse datetime with offset when offset option is false', () => { + const parsed = zz + .datetime({ offset: false }) + .safeParse('2024-01-01T12:00:00+00:00') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + code: 'invalid_format', + format: 'datetime', + message: 'Invalid ISO datetime', + }), + ]) + }) + + it('fails to parse null when schema is not nullable', () => { + const parsed = zz.datetime().safeParse(null) + expect(parsed.success).toBe(false) + expect(parsed.error?.message).toContain( + 'Invalid input: expected date, received null' + ) + }) + + it('fails to parse invalid datetime strings', () => { + const parsed = zz.datetime().safeParse('invalid-datetime') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + code: 'invalid_format', + format: 'datetime', + message: 'Invalid ISO datetime', + }), + ]) + }) + }) + describe('datetimeNullable', () => { + it('parses valid ISO 8601 datetime strings', () => { + const parsed = zz.datetimeNullable().safeParse('2024-01-01T12:00:00Z') + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(new Date('2024-01-01T12:00:00Z')) + }) + + it('parses a valid ISO 8601 datetime with offset', () => { + const parsed = zz + .datetimeNullable({ offset: true }) + .safeParse('2024-01-01T12:00:00+00:00') + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(new Date('2024-01-01T12:00:00+00:00')) + }) + + it('parses a valid Date object', () => { + const date = new Date('2024-01-01T12:00:00Z') + const parsed = zz.datetimeNullable().safeParse(date) + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(date) + }) + + it('fails to parse datetime with offset when offset option is false', () => { + const parsed = zz + .datetimeNullable({ offset: false }) + .safeParse('2024-01-01T12:00:00+00:00') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + code: 'invalid_format', + format: 'datetime', + message: 'Invalid ISO datetime', + }), + ]) + }) + + it('parses null when schema is nullable and input is null', () => { + const parsed = zz.datetimeNullable().safeParse(null) + expect(parsed.success).toBe(true) + expect(parsed.data).toBeNull() + }) + + it('fails to parse invalid datetime strings', () => { + const parsed = zz.datetimeNullable().safeParse('invalid-datetime') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + code: 'invalid_format', + format: 'datetime', + message: 'Invalid ISO datetime', + }), + ]) + }) + }) + describe('datetimeNullish', () => { + it('parses valid ISO 8601 datetime strings', () => { + const parsed = zz.datetimeNullish().safeParse('2024-01-01T12:00:00Z') + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(new Date('2024-01-01T12:00:00Z')) + }) + + it('parses a valid ISO 8601 datetime with offset', () => { + const parsed = zz + .datetimeNullish({ offset: true }) + .safeParse('2024-01-01T12:00:00+00:00') + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(new Date('2024-01-01T12:00:00+00:00')) + }) + + it('parses a valid Date object', () => { + const date = new Date('2024-01-01T12:00:00Z') + const parsed = zz.datetimeNullish().safeParse(date) + expect(parsed.success).toBe(true) + expect(parsed.data).toEqual(date) + }) + + it('parses null when schema is nullable and input is null', () => { + const parsed = zz.datetimeNullish().safeParse(null) + expect(parsed.success).toBe(true) + expect(parsed.data).toBeNull() + }) + + it('parses undefined when schema is nullish and input is undefined', () => { + const parsed = zz.datetimeNullish().safeParse(undefined) + expect(parsed.success).toBe(true) + expect(parsed.data).toBeUndefined() + }) + + it('fails to parse datetime with offset when offset option is false', () => { + const parsed = zz + .datetimeNullish({ offset: false }) + .safeParse('2024-01-01T12:00:00+00:00') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + code: 'invalid_format', + format: 'datetime', + message: 'Invalid ISO datetime', + }), + ]) + }) + + it('fails to parse invalid datetime strings', () => { + const parsed = zz.datetimeNullish().safeParse('invalid-datetime') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + code: 'invalid_format', + format: 'datetime', + message: 'Invalid ISO datetime', + }), + ]) + }) + }) }) diff --git a/libraries/validation-tools/types.d.ts b/libraries/validation-tools/types.d.ts new file mode 100644 index 0000000000..4f81d80730 --- /dev/null +++ b/libraries/validation-tools/types.d.ts @@ -0,0 +1,6 @@ +import z from 'zod' + +export interface DatetimeSchemaOptions extends z.core.$ZodISODateTimeParams { + allowNull?: boolean + allowUndefined?: boolean +} diff --git a/libraries/validation-tools/zodHelpers.js b/libraries/validation-tools/zodHelpers.js index 9506e679bd..6a45d7beaa 100644 --- a/libraries/validation-tools/zodHelpers.js +++ b/libraries/validation-tools/zodHelpers.js @@ -3,9 +3,19 @@ const mongodb = require('mongodb') const { ObjectId } = mongodb -const dateWithTransform = (schema, allowNull = false) => { - return schema.transform(dt => { - if (allowNull && !dt) return null +/** + * @import { DatetimeSchemaOptions } from './types' + */ + +/** + * @param {DatetimeSchemaOptions} options + */ +const datetimeSchema = ({ allowNull, allowUndefined, ...zodOptions } = {}) => { + const union = [z.date(), z.iso.datetime(zodOptions)] + if (allowNull) union.push(z.null()) + if (allowUndefined) union.push(z.undefined()) + return z.union(union).transform(dt => { + if (allowNull && !dt) return dt === null ? null : undefined return dt instanceof Date ? dt : new Date(dt) }) } @@ -19,14 +29,10 @@ const zz = { .refine(ObjectId.isValid, { message: 'invalid Mongo ObjectId' }) .transform(val => new ObjectId(val)), hex: () => z.string().regex(/^[0-9a-f]*$/), - datetime: () => dateWithTransform(z.union([z.iso.datetime(), z.date()])), - datetimeNullable: () => - dateWithTransform(z.union([z.iso.datetime(), z.date(), z.null()]), true), - datetimeNullish: () => - dateWithTransform( - z.union([z.iso.datetime(), z.date(), z.null(), z.undefined()]), - true - ), + datetime: options => datetimeSchema(options), + datetimeNullable: options => datetimeSchema({ ...options, allowNull: true }), + datetimeNullish: options => + datetimeSchema({ ...options, allowNull: true, allowUndefined: true }), } module.exports = { zz }