diff --git a/packages/validation/src/schemas/analytics.test.ts b/packages/validation/src/schemas/analytics.test.ts index cb87d405d..8f69a4407 100644 --- a/packages/validation/src/schemas/analytics.test.ts +++ b/packages/validation/src/schemas/analytics.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { analyticsEventSchema } from "./analytics"; +import { + analyticsDateRangeSchema, + analyticsEventSchema, +} from "./analytics"; const validEvent = { eventId: "test-id", @@ -96,3 +99,71 @@ describe("analyticsEventSchema referrer validation", () => { expect(result.success).toBe(false); }); }); + +describe("analyticsDateRangeSchema", () => { + it("accepts a complete range", () => { + const result = analyticsDateRangeSchema.safeParse({ + startDate: "2026-01-01", + endDate: "2026-01-31", + }); + expect(result.success).toBe(true); + }); + + it("accepts no dates at all", () => { + const result = analyticsDateRangeSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("rejects a start date without an end date", () => { + const result = analyticsDateRangeSchema.safeParse({ + startDate: "2026-01-01", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + "endDate is required when startDate is provided" + ); + } + }); + + it("rejects an end date without a start date", () => { + const result = analyticsDateRangeSchema.safeParse({ + endDate: "2026-01-31", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + "startDate is required when endDate is provided" + ); + } + }); + + it("rejects an inverted range", () => { + const result = analyticsDateRangeSchema.safeParse({ + startDate: "2026-02-01", + endDate: "2026-01-31", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + "startDate must be on or before endDate" + ); + } + }); + + it("accepts equal start and end dates", () => { + const result = analyticsDateRangeSchema.safeParse({ + startDate: "2026-01-31", + endDate: "2026-01-31", + }); + expect(result.success).toBe(true); + }); + + it("rejects non-ISO date strings", () => { + const result = analyticsDateRangeSchema.safeParse({ + startDate: "01/2026", + endDate: "2026-01-31", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/validation/src/schemas/analytics.ts b/packages/validation/src/schemas/analytics.ts index 79dd5c0e6..66e3603fb 100644 --- a/packages/validation/src/schemas/analytics.ts +++ b/packages/validation/src/schemas/analytics.ts @@ -65,6 +65,35 @@ const timestampSchema = z } ); +const analyticsDateOnlySchema = z.iso.date(); + +export const analyticsDateRangeSchema = z + .object({ + startDate: analyticsDateOnlySchema.optional(), + endDate: analyticsDateOnlySchema.optional(), + }) + .superRefine((range, ctx) => { + const hasStart = Boolean(range.startDate); + const hasEnd = Boolean(range.endDate); + if (hasStart !== hasEnd) { + ctx.addIssue({ + code: "custom", + message: hasStart + ? "endDate is required when startDate is provided" + : "startDate is required when endDate is provided", + path: [hasStart ? "endDate" : "startDate"], + }); + return; + } + if (range.startDate && range.endDate && range.startDate > range.endDate) { + ctx.addIssue({ + code: "custom", + message: "startDate must be on or before endDate", + path: ["startDate"], + }); + } + }); + export const analyticsEventSchema = z.object({ eventId: z.string().max(VALIDATION_LIMITS.EVENT_ID_MAX_LENGTH), name: z.string().min(1).max(VALIDATION_LIMITS.NAME_MAX_LENGTH),