diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 63c0a39ba..c855d1dce 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -94,17 +94,19 @@ export const eventDraftSchema = z ) { ctx.addIssue({ code: "custom", message: "End must be after a valid start", path: ["end"] }); } - const dateOnly = /^\d{4}-\d{2}-\d{2}$/; + const dateOnly = z.iso.date(); const timed = /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/; - if ( - !(value.allDay ? dateOnly : timed).test(value.start) || - !(value.allDay ? dateOnly : timed).test(value.end) - ) { + // Validate the calendar date separately so timed values can retain minute precision. + const validTimestamp = (timestamp: string) => + value.allDay + ? dateOnly.safeParse(timestamp).success + : timed.test(timestamp) && dateOnly.safeParse(timestamp.slice(0, 10)).success; + if (!validTimestamp(value.start) || !validTimestamp(value.end)) { ctx.addIssue({ code: "custom", message: value.allDay - ? "All-day events need date-only values" - : "Timed events need an explicit offset", + ? "All-day events need valid date-only values" + : "Timed events need valid date-times with an explicit offset", path: ["start"], }); } diff --git a/tests/domain.test.ts b/tests/domain.test.ts index 2d3ba1e91..a397cd912 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -80,6 +80,55 @@ test("events reject an end at or before the start", () => { assert.equal(eventDraftSchema.safeParse({ ...event, end: event.start }).success, false); }); +test("events reject nonexistent dates instead of normalizing them into the next month", () => { + for (const allDay of [true, false]) { + const format = (date: string) => (allDay ? date : `${date}T10:00:00+08:00`); + for (const date of ["2026-02-29", "2026-02-30", "2026-04-31", "2100-02-29"]) { + for (const field of ["start", "end"] as const) { + assert.equal( + eventDraftSchema.safeParse({ + ...event, + allDay, + start: format("2000-01-01"), + end: format("2200-01-01"), + [field]: format(date), + }).success, + false, + `${field} must reject ${format(date)}`, + ); + } + } + } +}); + +test("events accept valid leap days, month ends, and explicit offsets", () => { + for (const suffix of [ + "", + "T10:00Z", + "T10:00+05:30", + "T10:00:00Z", + "T10:00:00.123+08:00", + "T10:00:00-07:00", + ]) { + for (const [start, end] of [ + ["2000-02-29", "2000-03-01"], + ["2024-02-29", "2024-03-01"], + ["2026-04-30", "2026-05-01"], + ]) { + assert.equal( + eventDraftSchema.safeParse({ + ...event, + allDay: suffix === "", + start: start + suffix, + end: end + suffix, + }).success, + true, + `${start + suffix} to ${end + suffix} must remain valid`, + ); + } + } +}); + test("events default calendarId, location, and description", () => { const parsed = eventDraftSchema.parse(event); assert.equal(parsed.calendarId, "primary"); diff --git a/tests/google.test.ts b/tests/google.test.ts index c5a2535f5..667841c89 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -504,6 +504,26 @@ test("all-day writes use exclusive date-only end and invalid dates never call Go assert.equal(requests, 1); }); +test("calendar creates and updates reject nonexistent dates before contacting Google", async () => { + let requests = 0; + const client = clientWith(() => { + requests++; + return json(eventResponse); + }); + for (const allDay of [true, false]) { + const suffix = allDay ? "" : "T10:00:00+08:00"; + const draft = { + ...event(), + allDay, + start: `2026-02-29${suffix}`, + end: `2026-03-02${suffix}`, + }; + await assert.rejects(client.createEvent(draft), { name: "ZodError" }); + await assert.rejects(client.updateEvent("event-1", draft), { name: "ZodError" }); + } + assert.equal(requests, 0); +}); + test("event updates explicitly clear the opposite time representation when switching all-day mode", async () => { const client = clientWith(async (request) => { if (request.method === "GET") return json(eventResponse);