Bladeren bron

Merge pull request #29930 from overleaf/mfb-fix-zod-iso-datetime-error

allow iso date time string with offset on zod validation. add unit te…

GitOrigin-RevId: 88407fe681a66d13737de41789a9ea807a23627a
Maria Florencia Besteiro Gonzalez 8 maanden geleden
bovenliggende
commit
dab59520c3

+ 176 - 0
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',
+        }),
+      ])
+    })
+  })
 })

+ 6 - 0
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
+}

+ 17 - 11
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 }