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
4 changes: 3 additions & 1 deletion skills/google-calendar/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ calendar.createEvent({
```

- **`summary`** defaults to `"Working Location"` if omitted
- **All-day working location events** must span **exactly one day**. Use the
next day as the exclusive `end` date.
- **`workingLocationProperties`** is **required** when `eventType` is
`"workingLocation"`
- **`workingLocationProperties.type`** — `"homeOffice"`, `"officeLocation"`, or
Expand Down Expand Up @@ -268,7 +270,7 @@ be changed — everything else is preserved.
- **Changing title/description**: Update `summary` or `description`
- **Adding Google Meet**: Set `addGoogleMeet: true` to generate a Meet link
- **Managing attachments**: Provide the full attachment list (replaces all
existing)
existing). Pass `attachments: []` to clear all attachments.

> **Important:** The `attendees` field is a full replacement, not an append. To
> add a new attendee, include all existing attendees plus the new one. The same
Expand Down
83 changes: 70 additions & 13 deletions workspace-server/src/__tests__/services/CalendarService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ describe('CalendarService', () => {
attendees: [{ email: 'new@example.com' }],
};

mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent });
mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });

const result = await calendarService.updateEvent({
eventId: 'event123',
Expand All @@ -867,7 +867,7 @@ describe('CalendarService', () => {
attendees: ['new@example.com'],
});

expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({
expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event123',
requestBody: {
Expand All @@ -889,14 +889,14 @@ describe('CalendarService', () => {
description: 'New updated description',
};

mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent });
mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });

const result = await calendarService.updateEvent({
eventId: 'event123',
description: 'New updated description',
});

expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({
expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event123',
requestBody: {
Expand All @@ -910,7 +910,7 @@ describe('CalendarService', () => {

it('should handle update errors', async () => {
const apiError = new Error('Update failed');
mockCalendarAPI.events.update.mockRejectedValue(apiError);
mockCalendarAPI.events.patch.mockRejectedValue(apiError);

const result = await calendarService.updateEvent({
eventId: 'event123',
Expand All @@ -927,21 +927,43 @@ describe('CalendarService', () => {
summary: 'Updated Meeting Only',
};

mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent });
mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });

await calendarService.updateEvent({
eventId: 'event123',
summary: 'Updated Meeting Only',
});

expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({
expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event123',
requestBody: {
summary: 'Updated Meeting Only',
},
});
});

it('should patch only changed fields for status events', async () => {
mockCalendarAPI.events.patch.mockResolvedValue({
data: {
id: 'focus123',
summary: 'Deep Work',
},
});

await calendarService.updateEvent({
eventId: 'focus123',
summary: 'Deep Work',
});

expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'focus123',
requestBody: {
summary: 'Deep Work',
},
});
});
});

describe('respondToEvent', () => {
Expand Down Expand Up @@ -1495,14 +1517,14 @@ describe('CalendarService', () => {
},
};

mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent });
mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });

const result = await calendarService.updateEvent({
eventId: 'event123',
addGoogleMeet: true,
});

const callArgs = mockCalendarAPI.events.update.mock.calls[0][0];
const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0];
expect(callArgs.conferenceDataVersion).toBe(1);
expect(callArgs.requestBody.conferenceData).toBeDefined();
expect(
Expand All @@ -1515,15 +1537,15 @@ describe('CalendarService', () => {

it('should not include conferenceData when addGoogleMeet is false', async () => {
const updatedEvent = { id: 'event123', summary: 'No Meet' };
mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent });
mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });

await calendarService.updateEvent({
eventId: 'event123',
summary: 'No Meet',
addGoogleMeet: false,
});

const callArgs = mockCalendarAPI.events.update.mock.calls[0][0];
const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0];
expect(callArgs.conferenceDataVersion).toBeUndefined();
expect(callArgs.requestBody.conferenceData).toBeUndefined();
});
Expand All @@ -1541,7 +1563,7 @@ describe('CalendarService', () => {
],
};

mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent });
mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });

const result = await calendarService.updateEvent({
eventId: 'event123',
Expand All @@ -1553,7 +1575,7 @@ describe('CalendarService', () => {
],
});

const callArgs = mockCalendarAPI.events.update.mock.calls[0][0];
const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0];
expect(callArgs.supportsAttachments).toBe(true);
expect(callArgs.requestBody.attachments).toEqual([
expect.objectContaining({
Expand All @@ -1564,6 +1586,26 @@ describe('CalendarService', () => {

expect(JSON.parse(result.content[0].text)).toEqual(updatedEvent);
});

it('should clear attachments when passed an empty array', async () => {
mockCalendarAPI.events.patch.mockResolvedValue({
data: { id: 'event123', attachments: [] },
});

await calendarService.updateEvent({
eventId: 'event123',
attachments: [],
});

expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event123',
supportsAttachments: true,
requestBody: {
attachments: [],
},
});
});
});
});

Expand Down Expand Up @@ -2121,6 +2163,21 @@ describe('CalendarService', () => {
expect(parsedResult.error).toBe('Invalid input format');
});

it('should reject all-day workingLocation events that span multiple days', async () => {
const result = await calendarService.createEvent({
start: { date: '2024-01-15' },
end: { date: '2024-01-17' },
eventType: 'workingLocation',
workingLocationProperties: { type: 'homeOffice' },
});

const parsedResult = JSON.parse(result.content[0].text);
expect(parsedResult.error).toBe('Invalid input format');
expect(parsedResult.details).toContain(
'all-day workingLocation events must span exactly one day',
);
});

it('should reject start with both dateTime and date', async () => {
const result = await calendarService.createEvent({
summary: 'Ambiguous Event',
Expand Down
151 changes: 151 additions & 0 deletions workspace-server/src/__tests__/services/CalendarValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from '@jest/globals';
import { z } from 'zod';
import {
validateCreateEventInput,
validateUpdateEventInput,
} from '../../services/CalendarValidation';

function getZodIssueMessages(fn: () => void): string[] {
try {
fn();
return [];
} catch (error) {
if (error instanceof z.ZodError) {
return error.issues.map((issue) => issue.message);
}
throw error;
}
}

describe('CalendarValidation', () => {
describe('validateCreateEventInput', () => {
it('accepts a single-day all-day working location event', () => {
expect(() =>
validateCreateEventInput({
start: { date: '2024-01-15' },
end: { date: '2024-01-16' },
eventType: 'workingLocation',
workingLocationProperties: { type: 'homeOffice' },
}),
).not.toThrow();
});

it('rejects an all-day working location event that spans multiple days', () => {
expect(() =>
validateCreateEventInput({
start: { date: '2024-01-15' },
end: { date: '2024-01-17' },
eventType: 'workingLocation',
workingLocationProperties: { type: 'homeOffice' },
}),
).toThrow(
'all-day workingLocation events must span exactly one day',
);
});

it('accepts a leap-day working location event that spans one day', () => {
expect(() =>
validateCreateEventInput({
start: { date: '2024-02-29' },
end: { date: '2024-03-01' },
eventType: 'workingLocation',
workingLocationProperties: { type: 'homeOffice' },
}),
).not.toThrow();
});

it('rejects a regular event without a summary', () => {
expect(() =>
validateCreateEventInput({
start: { dateTime: '2024-01-15T10:00:00Z' },
end: { dateTime: '2024-01-15T11:00:00Z' },
}),
).toThrow('summary is required for regular events');
});

it('rejects focus time events with all-day dates', () => {
expect(() =>
validateCreateEventInput({
start: { date: '2024-01-15' },
end: { date: '2024-01-16' },
eventType: 'focusTime',
}),
).toThrow('focusTime events cannot be all-day events');
});

it('rejects working location officeLocation without office details', () => {
const messages = getZodIssueMessages(() =>
validateCreateEventInput({
start: { date: '2024-01-15' },
end: { date: '2024-01-16' },
eventType: 'workingLocation',
workingLocationProperties: { type: 'officeLocation' },
}),
);

expect(messages).toContain(
'officeLocation is required when workingLocationProperties.type is "officeLocation"',
);
});

it('rejects invalid attendee emails', () => {
expect(() =>
validateCreateEventInput({
summary: 'Team Meeting',
start: { dateTime: '2024-01-15T10:00:00Z' },
end: { dateTime: '2024-01-15T11:00:00Z' },
attendees: ['not-an-email'],
}),
).toThrow('Invalid email format');
});
});

describe('validateUpdateEventInput', () => {
it('accepts all-day date updates', () => {
expect(() =>
validateUpdateEventInput({
eventId: 'event123',
start: { date: '2024-01-15' },
end: { date: '2024-01-16' },
}),
).not.toThrow();
});

it('rejects empty start objects', () => {
const messages = getZodIssueMessages(() =>
validateUpdateEventInput({
eventId: 'event123',
start: {},
}),
);

expect(messages).toContain(
'start must have exactly one of "dateTime" or "date"',
);
});

it('rejects invalid dateTime strings', () => {
expect(() =>
validateUpdateEventInput({
eventId: 'event123',
start: { dateTime: 'not-a-date' },
}),
).toThrow('Invalid ISO 8601 datetime format');
});

it('rejects invalid calendar dates', () => {
expect(() =>
validateUpdateEventInput({
eventId: 'event123',
start: { date: '2024-02-30' },
}),
).toThrow('Invalid date format. Expected YYYY-MM-DD');
});
});
});
Loading