From f2f3788704dd9515321ec4ebbd04d7025206f1a0 Mon Sep 17 00:00:00 2001 From: wangtao <328929485+wangtaotaotao95@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:25:09 +0800 Subject: [PATCH 1/2] fix: reject nonexistent calendar dates before review or dispatch --- packages/domain/src/index.ts | 12 ++++------- tests/domain.test.ts | 42 ++++++++++++++++++++++++++++++++++++ tests/google.test.ts | 20 +++++++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index d152794c..ade19b52 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -94,17 +94,13 @@ 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 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) - ) { + const timestamp = value.allDay ? z.iso.date() : z.iso.datetime({ offset: true }); + if (!timestamp.safeParse(value.start).success || !timestamp.safeParse(value.end).success) { 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 2d3ba1e9..4f621bc8 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -80,6 +80,48 @@ 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: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 9b2e6456..87cdfb97 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -475,6 +475,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); From d262e166a67258e534da560966aa6faca63b7764 Mon Sep 17 00:00:00 2001 From: wangtao <328929485+wangtaotaotao95@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:29:52 +0800 Subject: [PATCH 2/2] fix: preserve minute precision when validating calendar dates --- packages/domain/src/index.ts | 10 ++++++++-- tests/domain.test.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ade19b52..57d9d279 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -94,8 +94,14 @@ export const eventDraftSchema = z ) { ctx.addIssue({ code: "custom", message: "End must be after a valid start", path: ["end"] }); } - const timestamp = value.allDay ? z.iso.date() : z.iso.datetime({ offset: true }); - if (!timestamp.safeParse(value.start).success || !timestamp.safeParse(value.end).success) { + const dateOnly = z.iso.date(); + const timed = /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/; + // 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 diff --git a/tests/domain.test.ts b/tests/domain.test.ts index 4f621bc8..a397cd91 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -102,7 +102,14 @@ test("events reject nonexistent dates instead of normalizing them into the next }); test("events accept valid leap days, month ends, and explicit offsets", () => { - for (const suffix of ["", "T10:00:00Z", "T10:00:00.123+08:00", "T10:00:00-07:00"]) { + 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"],