Skip to content

Commit fb6930c

Browse files
fix(calendar): tighten createEvent validation for event types (#308)
* fix(calendar): tighten createEvent validation for event types - Reject start/end with both dateTime and date (must be exactly one) - Require summary for regular events (default/unset eventType) - Validate officeLocation/customLocation sub-properties are present for their respective workingLocationProperties.type values - Fix updateEvent datetime validation for all-day events * fix: address Gemini review feedback on calendar validation - Split combined start/end validation into separate checks with field-specific error paths - Move working location sub-property validation earlier to fail fast before calendar ID resolution and logging - Use !== undefined instead of truthiness for dateTime checks in updateEvent to catch empty strings * fix: add dateTime/date ambiguity validation to updateEvent Extend the same exactly-one-of check from createEvent to updateEvent, so providing both dateTime and date (or neither) in an update is rejected early with a clear error.
1 parent bd118ec commit fb6930c

File tree

2 files changed

+194
-6
lines changed

2 files changed

+194
-6
lines changed

workspace-server/src/__tests__/services/CalendarService.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,6 +1567,38 @@ describe('CalendarService', () => {
15671567
});
15681568
});
15691569

1570+
describe('updateEvent start/end validation', () => {
1571+
it('should reject start with both dateTime and date', async () => {
1572+
const result = await calendarService.updateEvent({
1573+
eventId: 'event1',
1574+
start: { dateTime: '2024-01-15T10:00:00Z', date: '2024-01-15' },
1575+
});
1576+
1577+
const parsedResult = JSON.parse(result.content[0].text);
1578+
expect(parsedResult.error).toBe('Invalid input format');
1579+
});
1580+
1581+
it('should reject end with both dateTime and date', async () => {
1582+
const result = await calendarService.updateEvent({
1583+
eventId: 'event1',
1584+
end: { dateTime: '2024-01-15T12:00:00Z', date: '2024-01-15' },
1585+
});
1586+
1587+
const parsedResult = JSON.parse(result.content[0].text);
1588+
expect(parsedResult.error).toBe('Invalid input format');
1589+
});
1590+
1591+
it('should reject start with neither dateTime nor date', async () => {
1592+
const result = await calendarService.updateEvent({
1593+
eventId: 'event1',
1594+
start: {},
1595+
});
1596+
1597+
const parsedResult = JSON.parse(result.content[0].text);
1598+
expect(parsedResult.error).toBe('Invalid input format');
1599+
});
1600+
});
1601+
15701602
describe('listEvents with eventTypes', () => {
15711603
beforeEach(async () => {
15721604
mockCalendarAPI.calendarList.list.mockResolvedValue({
@@ -2088,5 +2120,72 @@ describe('CalendarService', () => {
20882120
const parsedResult = JSON.parse(result.content[0].text);
20892121
expect(parsedResult.error).toBe('Invalid input format');
20902122
});
2123+
2124+
it('should reject start with both dateTime and date', async () => {
2125+
const result = await calendarService.createEvent({
2126+
summary: 'Ambiguous Event',
2127+
start: { dateTime: '2024-01-15T10:00:00Z', date: '2024-01-15' },
2128+
end: { dateTime: '2024-01-15T12:00:00Z' },
2129+
});
2130+
2131+
const parsedResult = JSON.parse(result.content[0].text);
2132+
expect(parsedResult.error).toBe('Invalid input format');
2133+
});
2134+
2135+
it('should reject end with both dateTime and date', async () => {
2136+
const result = await calendarService.createEvent({
2137+
summary: 'Ambiguous Event',
2138+
start: { dateTime: '2024-01-15T10:00:00Z' },
2139+
end: { dateTime: '2024-01-15T12:00:00Z', date: '2024-01-15' },
2140+
});
2141+
2142+
const parsedResult = JSON.parse(result.content[0].text);
2143+
expect(parsedResult.error).toBe('Invalid input format');
2144+
});
2145+
2146+
it('should require summary for regular events', async () => {
2147+
const result = await calendarService.createEvent({
2148+
start: { dateTime: '2024-01-15T10:00:00Z' },
2149+
end: { dateTime: '2024-01-15T12:00:00Z' },
2150+
});
2151+
2152+
const parsedResult = JSON.parse(result.content[0].text);
2153+
expect(parsedResult.error).toBe('Invalid input format');
2154+
});
2155+
2156+
it('should require summary for explicit default eventType', async () => {
2157+
const result = await calendarService.createEvent({
2158+
start: { dateTime: '2024-01-15T10:00:00Z' },
2159+
end: { dateTime: '2024-01-15T12:00:00Z' },
2160+
eventType: 'default',
2161+
});
2162+
2163+
const parsedResult = JSON.parse(result.content[0].text);
2164+
expect(parsedResult.error).toBe('Invalid input format');
2165+
});
2166+
2167+
it('should reject officeLocation type without officeLocation details', async () => {
2168+
const result = await calendarService.createEvent({
2169+
start: { date: '2024-01-15' },
2170+
end: { date: '2024-01-16' },
2171+
eventType: 'workingLocation',
2172+
workingLocationProperties: { type: 'officeLocation' },
2173+
});
2174+
2175+
const parsedResult = JSON.parse(result.content[0].text);
2176+
expect(parsedResult.error).toBe('Invalid input format');
2177+
});
2178+
2179+
it('should reject customLocation type without customLocation details', async () => {
2180+
const result = await calendarService.createEvent({
2181+
start: { date: '2024-01-15' },
2182+
end: { date: '2024-01-16' },
2183+
eventType: 'workingLocation',
2184+
workingLocationProperties: { type: 'customLocation' },
2185+
});
2186+
2187+
const parsedResult = JSON.parse(result.content[0].text);
2188+
expect(parsedResult.error).toBe('Invalid input format');
2189+
});
20912190
});
20922191
});

workspace-server/src/services/CalendarService.ts

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,15 +260,42 @@ export class CalendarService {
260260
const summary =
261261
input.summary ?? (eventType ? summaryDefaults[eventType] : undefined);
262262

263-
// Validate start/end: at least one of dateTime or date must be provided
264-
if ((!start.dateTime && !start.date) || (!end.dateTime && !end.date)) {
263+
// Validate start: exactly one of dateTime or date must be provided
264+
if ((!start.dateTime && !start.date) || (start.dateTime && start.date)) {
265265
return this.createValidationErrorResponse(
266266
new z.ZodError([
267267
{
268268
code: 'custom',
269269
message:
270-
'start and end must each have either "dateTime" (for timed events) or "date" (for all-day events)',
271-
path: ['start/end'],
270+
'start must have exactly one of "dateTime" (for timed events) or "date" (for all-day events)',
271+
path: ['start'],
272+
},
273+
]),
274+
);
275+
}
276+
277+
// Validate end: exactly one of dateTime or date must be provided
278+
if ((!end.dateTime && !end.date) || (end.dateTime && end.date)) {
279+
return this.createValidationErrorResponse(
280+
new z.ZodError([
281+
{
282+
code: 'custom',
283+
message:
284+
'end must have exactly one of "dateTime" (for timed events) or "date" (for all-day events)',
285+
path: ['end'],
286+
},
287+
]),
288+
);
289+
}
290+
291+
// Require summary for regular events
292+
if ((!eventType || eventType === 'default') && !input.summary) {
293+
return this.createValidationErrorResponse(
294+
new z.ZodError([
295+
{
296+
code: 'custom',
297+
message: 'summary is required for regular events',
298+
path: ['summary'],
272299
},
273300
]),
274301
);
@@ -304,6 +331,40 @@ export class CalendarService {
304331
);
305332
}
306333

334+
// Validate working location sub-properties match the declared type
335+
if (eventType === 'workingLocation' && workingLocationProperties) {
336+
if (
337+
workingLocationProperties.type === 'officeLocation' &&
338+
!workingLocationProperties.officeLocation
339+
) {
340+
return this.createValidationErrorResponse(
341+
new z.ZodError([
342+
{
343+
code: 'custom',
344+
message:
345+
'officeLocation is required when workingLocationProperties.type is "officeLocation"',
346+
path: ['workingLocationProperties', 'officeLocation'],
347+
},
348+
]),
349+
);
350+
}
351+
if (
352+
workingLocationProperties.type === 'customLocation' &&
353+
!workingLocationProperties.customLocation
354+
) {
355+
return this.createValidationErrorResponse(
356+
new z.ZodError([
357+
{
358+
code: 'custom',
359+
message:
360+
'customLocation is required when workingLocationProperties.type is "customLocation"',
361+
path: ['workingLocationProperties', 'customLocation'],
362+
},
363+
]),
364+
);
365+
}
366+
}
367+
307368
// Validate datetime formats (skip for date-only / all-day events)
308369
try {
309370
if (start.dateTime) {
@@ -611,12 +672,40 @@ export class CalendarService {
611672
attachments,
612673
} = input;
613674

675+
// Validate start/end: if provided, exactly one of dateTime or date
676+
if (start) {
677+
if ((start.dateTime && start.date) || (!start.dateTime && !start.date)) {
678+
return this.createValidationErrorResponse(
679+
new z.ZodError([
680+
{
681+
code: 'custom',
682+
message: 'start must have exactly one of "dateTime" or "date"',
683+
path: ['start'],
684+
},
685+
]),
686+
);
687+
}
688+
}
689+
if (end) {
690+
if ((end.dateTime && end.date) || (!end.dateTime && !end.date)) {
691+
return this.createValidationErrorResponse(
692+
new z.ZodError([
693+
{
694+
code: 'custom',
695+
message: 'end must have exactly one of "dateTime" or "date"',
696+
path: ['end'],
697+
},
698+
]),
699+
);
700+
}
701+
}
702+
614703
// Validate datetime formats if provided
615704
try {
616-
if (start) {
705+
if (start?.dateTime !== undefined) {
617706
iso8601DateTimeSchema.parse(start.dateTime);
618707
}
619-
if (end) {
708+
if (end?.dateTime !== undefined) {
620709
iso8601DateTimeSchema.parse(end.dateTime);
621710
}
622711
if (attendees) {

0 commit comments

Comments
 (0)