Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
}
Expand Down
49 changes: 49 additions & 0 deletions tests/domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 20 additions & 0 deletions tests/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down