This is page 6 of 6. Use http://codebase.md/nspady/google-calendar-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursorignore
├── .dockerignore
├── .env.example
├── .github
│ └── workflows
│ ├── ci.yml
│ ├── publish.yml
│ └── README.md
├── .gitignore
├── .release-please-manifest.json
├── AGENTS.md
├── CHANGELOG.md
├── CLAUDE.md
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── advanced-usage.md
│ ├── architecture.md
│ ├── authentication.md
│ ├── deployment.md
│ ├── development.md
│ ├── docker.md
│ ├── README.md
│ └── testing.md
├── examples
│ ├── http-client.js
│ └── http-with-curl.sh
├── future_features
│ └── ARCHITECTURE_REDESIGN.md
├── gcp-oauth.keys.example.json
├── instructions
│ └── file_structure.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── account-manager.js
│ ├── build.js
│ ├── dev.js
│ └── test-docker.sh
├── src
│ ├── auth
│ │ ├── client.ts
│ │ ├── paths.d.ts
│ │ ├── paths.js
│ │ ├── server.ts
│ │ ├── tokenManager.ts
│ │ └── utils.ts
│ ├── auth-server.ts
│ ├── config
│ │ └── TransportConfig.ts
│ ├── handlers
│ │ ├── core
│ │ │ ├── BaseToolHandler.ts
│ │ │ ├── BatchRequestHandler.ts
│ │ │ ├── CreateEventHandler.ts
│ │ │ ├── DeleteEventHandler.ts
│ │ │ ├── FreeBusyEventHandler.ts
│ │ │ ├── GetCurrentTimeHandler.ts
│ │ │ ├── GetEventHandler.ts
│ │ │ ├── ListCalendarsHandler.ts
│ │ │ ├── ListColorsHandler.ts
│ │ │ ├── ListEventsHandler.ts
│ │ │ ├── RecurringEventHelpers.ts
│ │ │ ├── SearchEventsHandler.ts
│ │ │ └── UpdateEventHandler.ts
│ │ ├── utils
│ │ │ └── datetime.ts
│ │ └── utils.ts
│ ├── index.ts
│ ├── schemas
│ │ └── types.ts
│ ├── server.ts
│ ├── services
│ │ └── conflict-detection
│ │ ├── config.ts
│ │ ├── ConflictAnalyzer.ts
│ │ ├── ConflictDetectionService.ts
│ │ ├── EventSimilarityChecker.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── tests
│ │ ├── integration
│ │ │ ├── claude-mcp-integration.test.ts
│ │ │ ├── direct-integration.test.ts
│ │ │ ├── docker-integration.test.ts
│ │ │ ├── openai-mcp-integration.test.ts
│ │ │ └── test-data-factory.ts
│ │ └── unit
│ │ ├── console-statements.test.ts
│ │ ├── handlers
│ │ │ ├── BatchListEvents.test.ts
│ │ │ ├── BatchRequestHandler.test.ts
│ │ │ ├── CalendarNameResolution.test.ts
│ │ │ ├── create-event-blocking.test.ts
│ │ │ ├── CreateEventHandler.test.ts
│ │ │ ├── datetime-utils.test.ts
│ │ │ ├── duplicate-event-display.test.ts
│ │ │ ├── GetCurrentTimeHandler.test.ts
│ │ │ ├── GetEventHandler.test.ts
│ │ │ ├── list-events-registry.test.ts
│ │ │ ├── ListEventsHandler.test.ts
│ │ │ ├── RecurringEventHelpers.test.ts
│ │ │ ├── UpdateEventHandler.recurring.test.ts
│ │ │ ├── UpdateEventHandler.test.ts
│ │ │ ├── utils-conflict-format.test.ts
│ │ │ └── utils.test.ts
│ │ ├── index.test.ts
│ │ ├── schemas
│ │ │ ├── enhanced-properties.test.ts
│ │ │ ├── no-refs.test.ts
│ │ │ ├── schema-compatibility.test.ts
│ │ │ ├── tool-registration.test.ts
│ │ │ └── validators.test.ts
│ │ ├── services
│ │ │ └── conflict-detection
│ │ │ ├── ConflictAnalyzer.test.ts
│ │ │ └── EventSimilarityChecker.test.ts
│ │ └── utils
│ │ ├── event-id-validator.test.ts
│ │ └── field-mask-builder.test.ts
│ ├── tools
│ │ └── registry.ts
│ ├── transports
│ │ ├── http.ts
│ │ └── stdio.ts
│ ├── types
│ │ └── structured-responses.ts
│ └── utils
│ ├── event-id-validator.ts
│ ├── field-mask-builder.ts
│ └── response-builder.ts
├── tsconfig.json
├── tsconfig.lint.json
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/src/tests/integration/direct-integration.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4 | import { spawn, ChildProcess } from 'child_process';
5 | import { TestDataFactory, TestEvent } from './test-data-factory.js';
6 |
7 | /**
8 | * Comprehensive Integration Tests for Google Calendar MCP
9 | *
10 | * REQUIREMENTS TO RUN THESE TESTS:
11 | * 1. Valid Google OAuth credentials file at path specified by GOOGLE_OAUTH_CREDENTIALS env var
12 | * 2. Authenticated test account: Run `npm run dev auth:test` first
13 | * 3. TEST_CALENDAR_ID environment variable set to a real Google Calendar ID
14 | * 4. Network access to Google Calendar API
15 | *
16 | * These tests exercise all MCP tools against a real test calendar and will:
17 | * - Create, modify, and delete real calendar events
18 | * - Make actual API calls to Google Calendar
19 | * - Require valid authentication tokens
20 | *
21 | * Test Strategy:
22 | * 1. Create test events first
23 | * 2. Test read operations (list, search, freebusy)
24 | * 3. Test write operations (update)
25 | * 4. Clean up by deleting created events
26 | * 5. Track performance metrics throughout
27 | */
28 |
29 | describe('Google Calendar MCP - Direct Integration Tests', () => {
30 | let client: Client;
31 | let serverProcess: ChildProcess;
32 | let testFactory: TestDataFactory;
33 | let createdEventIds: string[] = [];
34 |
35 | const TEST_CALENDAR_ID = process.env.TEST_CALENDAR_ID || 'primary';
36 | const SEND_UPDATES = 'none' as const;
37 |
38 | beforeAll(async () => {
39 | // Start the MCP server
40 | console.log('🚀 Starting Google Calendar MCP server...');
41 |
42 | // Filter out undefined values from process.env and set NODE_ENV=test
43 | const cleanEnv = Object.fromEntries(
44 | Object.entries(process.env).filter(([_, value]) => value !== undefined)
45 | ) as Record<string, string>;
46 | cleanEnv.NODE_ENV = 'test';
47 |
48 | serverProcess = spawn('node', ['build/index.js'], {
49 | stdio: ['pipe', 'pipe', 'pipe'],
50 | env: cleanEnv
51 | });
52 |
53 | // Wait for server to start
54 | await new Promise(resolve => setTimeout(resolve, 3000));
55 |
56 | // Create MCP client
57 | client = new Client({
58 | name: "integration-test-client",
59 | version: "1.0.0"
60 | }, {
61 | capabilities: {
62 | tools: {}
63 | }
64 | });
65 |
66 | // Connect to server
67 | const transport = new StdioClientTransport({
68 | command: 'node',
69 | args: ['build/index.js'],
70 | env: cleanEnv
71 | });
72 |
73 | await client.connect(transport);
74 | console.log('✅ Connected to MCP server');
75 |
76 | // Initialize test factory
77 | testFactory = new TestDataFactory();
78 | }, 30000);
79 |
80 | afterAll(async () => {
81 | console.log('\n🏁 Starting final cleanup...');
82 |
83 | // Final cleanup - ensure all test events are removed
84 | const allEventIds = testFactory.getCreatedEventIds();
85 | if (allEventIds.length > 0) {
86 | console.log(`📋 Found ${allEventIds.length} total events created during all tests`);
87 | await cleanupAllTestEvents();
88 | } else {
89 | console.log('✨ No additional events to clean up');
90 | }
91 |
92 | // Close client connection
93 | if (client) {
94 | await client.close();
95 | console.log('🔌 Closed MCP client connection');
96 | }
97 |
98 | // Terminate server process
99 | if (serverProcess && !serverProcess.killed) {
100 | serverProcess.kill();
101 | await new Promise(resolve => setTimeout(resolve, 1000));
102 | console.log('🛑 Terminated MCP server process');
103 | }
104 |
105 | // Log performance summary
106 | logPerformanceSummary();
107 |
108 | console.log('✅ Integration test cleanup completed successfully\n');
109 | }, 30000);
110 |
111 | beforeEach(() => {
112 | testFactory.clearPerformanceMetrics();
113 | createdEventIds = [];
114 | });
115 |
116 | afterEach(async () => {
117 | // Cleanup events created in this test
118 | if (createdEventIds.length > 0) {
119 | console.log(`🧹 Cleaning up ${createdEventIds.length} events from test...`);
120 | await cleanupTestEvents(createdEventIds);
121 | createdEventIds = [];
122 | }
123 | });
124 |
125 | describe('Tool Availability and Basic Functionality', () => {
126 | it('should list all expected tools', async () => {
127 | const startTime = testFactory.startTimer('list-tools');
128 |
129 | try {
130 | const tools = await client.listTools();
131 |
132 | testFactory.endTimer('list-tools', startTime, true);
133 |
134 | expect(tools.tools).toBeDefined();
135 | expect(tools.tools.length).toBe(10);
136 |
137 | const toolNames = tools.tools.map(t => t.name);
138 | expect(toolNames).toContain('get-current-time');
139 | expect(toolNames).toContain('list-calendars');
140 | expect(toolNames).toContain('list-events');
141 | expect(toolNames).toContain('search-events');
142 | expect(toolNames).toContain('list-colors');
143 | expect(toolNames).toContain('create-event');
144 | expect(toolNames).toContain('update-event');
145 | expect(toolNames).toContain('delete-event');
146 | expect(toolNames).toContain('get-freebusy');
147 | expect(toolNames).toContain('get-event');
148 | } catch (error) {
149 | testFactory.endTimer('list-tools', startTime, false, String(error));
150 | throw error;
151 | }
152 | });
153 |
154 | it('should list calendars including test calendar', async () => {
155 | const startTime = testFactory.startTimer('list-calendars');
156 |
157 | try {
158 | const result = await client.callTool({
159 | name: 'list-calendars',
160 | arguments: {}
161 | });
162 |
163 | testFactory.endTimer('list-calendars', startTime, true);
164 |
165 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
166 | const response = JSON.parse((result.content as any)[0].text);
167 | expect(response.calendars).toBeDefined();
168 | expect(Array.isArray(response.calendars)).toBe(true);
169 | expect(response.totalCount).toBeDefined();
170 | expect(typeof response.totalCount).toBe('number');
171 | } catch (error) {
172 | testFactory.endTimer('list-calendars', startTime, false, String(error));
173 | throw error;
174 | }
175 | });
176 |
177 | it('should list available colors', async () => {
178 | const startTime = testFactory.startTimer('list-colors');
179 |
180 | try {
181 | const result = await client.callTool({
182 | name: 'list-colors',
183 | arguments: {}
184 | });
185 |
186 | testFactory.endTimer('list-colors', startTime, true);
187 |
188 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
189 | const response = JSON.parse((result.content as any)[0].text);
190 | expect(response.event).toBeDefined();
191 | expect(response.calendar).toBeDefined();
192 | } catch (error) {
193 | testFactory.endTimer('list-colors', startTime, false, String(error));
194 | throw error;
195 | }
196 | });
197 |
198 | it('should get current time without timezone parameter (uses primary calendar timezone)', async () => {
199 | const startTime = testFactory.startTimer('get-current-time');
200 |
201 | try {
202 | const result = await client.callTool({
203 | name: 'get-current-time',
204 | arguments: {}
205 | });
206 |
207 | testFactory.endTimer('get-current-time', startTime, true);
208 |
209 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
210 |
211 | const response = JSON.parse((result.content as any)[0].text);
212 | expect(response.currentTime).toBeDefined();
213 | expect(response.currentTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
214 | expect(response.timezone).toBeTypeOf('string');
215 | expect(response.offset).toBeDefined();
216 | expect(response.isDST).toBeTypeOf('boolean');
217 | } catch (error) {
218 | testFactory.endTimer('get-current-time', startTime, false, String(error));
219 | throw error;
220 | }
221 | });
222 |
223 | it('should get current time with timezone parameter', async () => {
224 | const startTime = testFactory.startTimer('get-current-time-with-timezone');
225 |
226 | try {
227 | const result = await client.callTool({
228 | name: 'get-current-time',
229 | arguments: {
230 | timeZone: 'America/Los_Angeles'
231 | }
232 | });
233 |
234 | testFactory.endTimer('get-current-time-with-timezone', startTime, true);
235 |
236 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
237 |
238 | const response = JSON.parse((result.content as any)[0].text);
239 | expect(response.currentTime).toBeDefined();
240 | expect(response.currentTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
241 | expect(response.timezone).toBe('America/Los_Angeles');
242 | expect(response.offset).toBeDefined();
243 | expect(response.offset).toMatch(/^[+-]\d{2}:\d{2}$/);
244 | expect(response.isDST).toBeTypeOf('boolean');
245 | } catch (error) {
246 | testFactory.endTimer('get-current-time-with-timezone', startTime, false, String(error));
247 | throw error;
248 | }
249 | });
250 |
251 | it('should get event by ID', async () => {
252 | const startTime = testFactory.startTimer('get-event');
253 |
254 | try {
255 | // First create an event
256 | const eventData = TestDataFactory.createSingleEvent({
257 | summary: `Test Get Event By ID ${Date.now()}`
258 | });
259 |
260 | const eventId = await createTestEvent(eventData);
261 | createdEventIds.push(eventId);
262 |
263 | // Now get the event by ID
264 | const result = await client.callTool({
265 | name: 'get-event',
266 | arguments: {
267 | calendarId: TEST_CALENDAR_ID,
268 | eventId: eventId
269 | }
270 | });
271 |
272 | testFactory.endTimer('get-event', startTime, true);
273 |
274 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
275 | const response = JSON.parse((result.content as any)[0].text);
276 | expect(response.event).toBeDefined();
277 | expect(response.event.summary).toBe(eventData.summary);
278 | expect(response.event.id).toBe(eventId);
279 | } catch (error) {
280 | testFactory.endTimer('get-event', startTime, false, String(error));
281 | throw error;
282 | }
283 | });
284 |
285 | it('should return error for non-existent event ID', async () => {
286 | const startTime = testFactory.startTimer('get-event-not-found');
287 |
288 | const result = await client.callTool({
289 | name: 'get-event',
290 | arguments: {
291 | calendarId: TEST_CALENDAR_ID,
292 | eventId: 'non-existent-event-id-12345'
293 | }
294 | });
295 |
296 | // Errors are returned as text content
297 | const text = (result.content as any)[0]?.text;
298 |
299 | if (text && (text.includes('not found') || text.includes('Event with ID'))) {
300 | testFactory.endTimer('get-event-not-found', startTime, true);
301 | // This is expected - test passes
302 | } else {
303 | testFactory.endTimer('get-event-not-found', startTime, false, 'Expected error for non-existent event');
304 | throw new Error('Expected get-event to return error for non-existent event');
305 | }
306 | });
307 |
308 | it('should get event with specific fields', async () => {
309 | const startTime = testFactory.startTimer('get-event-with-fields');
310 |
311 | try {
312 | // First create an event with extended data
313 | const eventData = TestDataFactory.createColoredEvent('9', {
314 | summary: `Test Get Event With Fields ${Date.now()}`,
315 | description: 'Testing field filtering',
316 | location: 'Test Location'
317 | });
318 |
319 | const eventId = await createTestEvent(eventData);
320 | createdEventIds.push(eventId);
321 |
322 | // Get event with specific fields
323 | const result = await client.callTool({
324 | name: 'get-event',
325 | arguments: {
326 | calendarId: TEST_CALENDAR_ID,
327 | eventId: eventId,
328 | fields: ['colorId', 'description', 'location', 'created', 'updated']
329 | }
330 | });
331 |
332 | testFactory.endTimer('get-event-with-fields', startTime, true);
333 |
334 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
335 | const response = JSON.parse((result.content as any)[0].text);
336 | expect(response.event).toBeDefined();
337 | expect(response.event.summary).toBe(eventData.summary);
338 | expect(response.event.description).toBe(eventData.description);
339 | expect(response.event.location).toBe(eventData.location);
340 | // Color information may not be included when specific fields are requested
341 | // Just verify the event was retrieved with the requested fields
342 | } catch (error) {
343 | testFactory.endTimer('get-event-with-fields', startTime, false, String(error));
344 | throw error;
345 | }
346 | });
347 | });
348 |
349 | describe('Event Creation and Management Workflow', () => {
350 | describe('Single Event Operations', () => {
351 | it('should create, list, search, update, and delete a single event', async () => {
352 | // 1. Create event
353 | const eventData = TestDataFactory.createSingleEvent({
354 | summary: `Integration Test - Single Event Workflow ${Date.now()}`
355 | });
356 |
357 | const eventId = await createTestEvent(eventData);
358 | createdEventIds.push(eventId);
359 |
360 | // 2. List events to verify creation
361 | const timeRanges = TestDataFactory.getTimeRanges();
362 | await verifyEventInList(eventId, timeRanges.nextWeek);
363 |
364 | // 3. Search for the event
365 | await verifyEventInSearch(eventData.summary);
366 |
367 | // 4. Update the event
368 | await updateTestEvent(eventId, {
369 | summary: 'Updated Integration Test Event',
370 | location: 'Updated Location'
371 | });
372 |
373 | // 5. Verify update took effect
374 | await verifyEventInSearch('Integration');
375 |
376 | // 6. Delete will happen in afterEach cleanup
377 | });
378 |
379 | it('should handle all-day events', async () => {
380 | const allDayEvent = TestDataFactory.createAllDayEvent({
381 | summary: `Integration Test - All Day Event ${Date.now()}`
382 | });
383 |
384 | const eventId = await createTestEvent(allDayEvent);
385 | createdEventIds.push(eventId);
386 |
387 | // Verify all-day event appears in searches
388 | await verifyEventInSearch(allDayEvent.summary);
389 | });
390 |
391 | it('should correctly display all-day events in non-UTC timezones', async () => {
392 | // Create an all-day event for a specific date
393 | // For all-day events, use date-only format (YYYY-MM-DD)
394 | const startDate = '2025-03-15'; // March 15, 2025
395 | const endDate = '2025-03-16'; // March 16, 2025 (exclusive)
396 |
397 | // Create all-day event
398 | const createResult = await client.callTool({
399 | name: 'create-event',
400 | arguments: {
401 | calendarId: TEST_CALENDAR_ID,
402 | summary: `All-Day Event Timezone Test ${Date.now()}`,
403 | description: 'Testing all-day event display in different timezones',
404 | start: startDate,
405 | end: endDate
406 | }
407 | });
408 |
409 | const eventId = extractEventId(createResult);
410 | expect(eventId).toBeTruthy();
411 | if (eventId) createdEventIds.push(eventId);
412 |
413 | // Test 1: List events without timezone (should use calendar's default)
414 | const listDefaultTz = await client.callTool({
415 | name: 'list-events',
416 | arguments: {
417 | calendarId: TEST_CALENDAR_ID,
418 | timeMin: '2025-03-14T00:00:00',
419 | timeMax: '2025-03-17T23:59:59'
420 | }
421 | });
422 |
423 | const defaultText = (listDefaultTz.content as any)[0].text;
424 | console.log('Default timezone listing:', defaultText);
425 |
426 | // Test 2: List events with UTC timezone
427 | const listUTC = await client.callTool({
428 | name: 'list-events',
429 | arguments: {
430 | calendarId: TEST_CALENDAR_ID,
431 | timeMin: '2025-03-14T00:00:00Z',
432 | timeMax: '2025-03-17T23:59:59Z',
433 | timeZone: 'UTC'
434 | }
435 | });
436 |
437 | const utcText = (listUTC.content as any)[0].text;
438 | console.log('UTC listing:', utcText);
439 |
440 | // Test 3: List events with Pacific timezone (UTC-7/8)
441 | const listPacific = await client.callTool({
442 | name: 'list-events',
443 | arguments: {
444 | calendarId: TEST_CALENDAR_ID,
445 | timeMin: '2025-03-14T00:00:00-07:00',
446 | timeMax: '2025-03-17T23:59:59-07:00',
447 | timeZone: 'America/Los_Angeles'
448 | }
449 | });
450 |
451 | const pacificResponse = JSON.parse((listPacific.content as any)[0].text);
452 | console.log('Pacific timezone listing:', JSON.stringify(pacificResponse, null, 2));
453 |
454 | // Parse the other responses too
455 | const defaultResponse = JSON.parse(defaultText);
456 | const utcResponse = JSON.parse(utcText);
457 |
458 | // All listings should have events with dates on March 15, 2025
459 | // Check that all responses have events
460 | expect(defaultResponse.events).toBeDefined();
461 | expect(utcResponse.events).toBeDefined();
462 | expect(pacificResponse.events).toBeDefined();
463 |
464 | // For all-day events, the date should be 2025-03-15
465 | if (defaultResponse.events.length > 0) {
466 | const event = defaultResponse.events[0];
467 | if (event.start.date) {
468 | expect(event.start.date).toBe('2025-03-15');
469 | }
470 | }
471 | });
472 |
473 | it('should handle events with attendees', async () => {
474 | const eventWithAttendees = TestDataFactory.createEventWithAttendees({
475 | summary: `Integration Test - Event with Attendees ${Date.now()}`
476 | });
477 |
478 | const eventId = await createTestEvent(eventWithAttendees);
479 | createdEventIds.push(eventId);
480 |
481 | await verifyEventInSearch(eventWithAttendees.summary);
482 | });
483 |
484 | it('should handle colored events', async () => {
485 | const coloredEvent = TestDataFactory.createColoredEvent('9', {
486 | summary: `Integration Test - Colored Event ${Date.now()}`
487 | });
488 |
489 | const eventId = await createTestEvent(coloredEvent);
490 | createdEventIds.push(eventId);
491 |
492 | await verifyEventInSearch(coloredEvent.summary);
493 | });
494 |
495 | it('should create event without timezone and use calendar default', async () => {
496 | // First, get the calendar details to know the expected default timezone
497 | const calendarResult = await client.callTool({
498 | name: 'list-calendars',
499 | arguments: {}
500 | });
501 |
502 | expect(TestDataFactory.validateEventResponse(calendarResult)).toBe(true);
503 |
504 | // Create event data without timezone
505 | const eventData = TestDataFactory.createSingleEvent({
506 | summary: `Integration Test - Default Timezone Event ${Date.now()}`
507 | });
508 |
509 | // Remove timezone from the event data to test default behavior
510 | const eventDataWithoutTimezone = {
511 | ...eventData,
512 | timeZone: undefined
513 | };
514 | delete eventDataWithoutTimezone.timeZone;
515 |
516 | // Also convert datetime strings to timezone-naive format
517 | eventDataWithoutTimezone.start = eventDataWithoutTimezone.start.replace(/[+-]\d{2}:\d{2}$|Z$/, '');
518 | eventDataWithoutTimezone.end = eventDataWithoutTimezone.end.replace(/[+-]\d{2}:\d{2}$|Z$/, '');
519 |
520 | const startTime = testFactory.startTimer('create-event-default-timezone');
521 |
522 | try {
523 | const result = await client.callTool({
524 | name: 'create-event',
525 | arguments: {
526 | calendarId: TEST_CALENDAR_ID,
527 | ...eventDataWithoutTimezone
528 | }
529 | });
530 |
531 | testFactory.endTimer('create-event-default-timezone', startTime, true);
532 |
533 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
534 |
535 | const eventId = extractEventId(result);
536 | expect(eventId).toBeTruthy();
537 |
538 | createdEventIds.push(eventId!);
539 | testFactory.addCreatedEventId(eventId!);
540 |
541 | // Verify the event was created successfully and shows up in searches
542 | await verifyEventInSearch(eventData.summary);
543 |
544 | // Verify the response contains expected event data
545 | const response = JSON.parse((result.content as any)[0].text);
546 | expect(response.event).toBeDefined();
547 | expect(response.event.summary).toBe(eventData.summary);
548 |
549 | console.log('✅ Event created successfully without explicit timezone - using calendar default');
550 | } catch (error) {
551 | testFactory.endTimer('create-event-default-timezone', startTime, false, String(error));
552 | throw error;
553 | }
554 | });
555 | });
556 |
557 | describe('Recurring Event Operations', () => {
558 | it('should create and manage recurring events', async () => {
559 | // Create recurring event with unique name
560 | const timestamp = Date.now();
561 | const recurringEvent = TestDataFactory.createRecurringEvent({
562 | summary: `Integration Test - Recurring Weekly Meeting ${timestamp}`
563 | });
564 |
565 | const eventId = await createTestEvent(recurringEvent);
566 | createdEventIds.push(eventId);
567 |
568 | // Verify recurring event
569 | await verifyEventInSearch(recurringEvent.summary);
570 |
571 | // Test different update scopes
572 | await testRecurringEventUpdates(eventId);
573 | });
574 |
575 |
576 | it('should handle update-event with future instances scope (thisAndFollowing)', async () => {
577 | // Create a recurring event with unique name
578 | const timestamp = Date.now();
579 | const recurringEvent = TestDataFactory.createRecurringEvent({
580 | summary: `Weekly Team Meeting - Future Instances Test ${timestamp}`,
581 | description: 'This is a recurring weekly meeting',
582 | location: 'Conference Room A'
583 | });
584 |
585 | const eventId = await createTestEvent(recurringEvent);
586 | createdEventIds.push(eventId);
587 |
588 | // Wait for event to be searchable
589 | await new Promise(resolve => setTimeout(resolve, 2000));
590 |
591 | // Calculate a future date (3 weeks from now)
592 | const futureDate = new Date();
593 | futureDate.setDate(futureDate.getDate() + 21);
594 | const futureStartDate = TestDataFactory.formatDateTimeRFC3339WithTimezone(futureDate);
595 |
596 | // Update future instances
597 | const updateResult = await client.callTool({
598 | name: 'update-event',
599 | arguments: {
600 | calendarId: TEST_CALENDAR_ID,
601 | eventId: eventId,
602 | modificationScope: 'thisAndFollowing',
603 | futureStartDate: futureStartDate,
604 | summary: 'Updated Team Meeting - Future Instances',
605 | location: 'New Conference Room',
606 | timeZone: 'America/Los_Angeles',
607 | sendUpdates: SEND_UPDATES
608 | }
609 | });
610 |
611 | expect(TestDataFactory.validateEventResponse(updateResult)).toBe(true);
612 | const responseText = (updateResult.content as any)[0].text;
613 | const response = JSON.parse(responseText);
614 | expect(response.event).toBeDefined();
615 | expect(response.event.summary).toBe('Updated Team Meeting - Future Instances');
616 | });
617 |
618 | it('should maintain backward compatibility with existing update-event calls', async () => {
619 | // Create a recurring event with unique name
620 | const timestamp = Date.now();
621 | const recurringEvent = TestDataFactory.createRecurringEvent({
622 | summary: `Weekly Team Meeting - Backward Compatibility Test ${timestamp}`
623 | });
624 |
625 | const eventId = await createTestEvent(recurringEvent);
626 | createdEventIds.push(eventId);
627 |
628 | // Wait for event to be searchable
629 | await new Promise(resolve => setTimeout(resolve, 2000));
630 |
631 | // Legacy call format without new parameters (should default to 'all' scope)
632 | const updateResult = await client.callTool({
633 | name: 'update-event',
634 | arguments: {
635 | calendarId: TEST_CALENDAR_ID,
636 | eventId: eventId,
637 | summary: 'Updated Weekly Meeting - All Instances',
638 | location: 'Conference Room B',
639 | timeZone: 'America/Los_Angeles',
640 | sendUpdates: SEND_UPDATES
641 | // No modificationScope, originalStartTime, or futureStartDate
642 | }
643 | });
644 |
645 | expect(TestDataFactory.validateEventResponse(updateResult)).toBe(true);
646 | const responseText = (updateResult.content as any)[0].text;
647 | const response = JSON.parse(responseText);
648 | expect(response.event).toBeDefined();
649 | expect(response.event.summary).toBe('Updated Weekly Meeting - All Instances');
650 |
651 | // Verify all instances were updated
652 | await verifyEventInSearch('Updated Weekly Meeting - All Instances');
653 | });
654 |
655 | it('should handle validation errors for missing required fields', async () => {
656 | // Test case 1: Missing originalStartTime for 'thisEventOnly' scope
657 | const invalidSingleResult = await client.callTool({
658 | name: 'update-event',
659 | arguments: {
660 | calendarId: TEST_CALENDAR_ID,
661 | eventId: 'recurring123',
662 | modificationScope: 'thisEventOnly',
663 | timeZone: 'America/Los_Angeles',
664 | summary: 'Test Update'
665 | // missing originalStartTime
666 | }
667 | });
668 |
669 | // Errors are returned as text content
670 | const invalidSingleText = (invalidSingleResult.content as any)[0]?.text;
671 | expect(invalidSingleText).toContain('originalStartTime');
672 |
673 | // Test case 2: Missing futureStartDate for 'thisAndFollowing' scope
674 | const invalidFutureResult = await client.callTool({
675 | name: 'update-event',
676 | arguments: {
677 | calendarId: TEST_CALENDAR_ID,
678 | eventId: 'recurring123',
679 | modificationScope: 'thisAndFollowing',
680 | timeZone: 'America/Los_Angeles',
681 | summary: 'Test Update'
682 | // missing futureStartDate
683 | }
684 | });
685 |
686 | // Errors are returned as text content
687 | const invalidFutureText = (invalidFutureResult.content as any)[0]?.text;
688 | expect(invalidFutureText).toContain('futureStartDate');
689 | });
690 |
691 | it('should reject non-"all" scopes for single (non-recurring) events', async () => {
692 | // Create a single (non-recurring) event
693 | const singleEvent = TestDataFactory.createSingleEvent({
694 | summary: `Single Event - Scope Test ${Date.now()}`
695 | });
696 |
697 | const eventId = await createTestEvent(singleEvent);
698 | createdEventIds.push(eventId);
699 |
700 | // Wait for event to be created
701 | await new Promise(resolve => setTimeout(resolve, 1000));
702 |
703 | // Try to update with 'thisEventOnly' scope (should fail)
704 | const invalidResult = await client.callTool({
705 | name: 'update-event',
706 | arguments: {
707 | calendarId: TEST_CALENDAR_ID,
708 | eventId: eventId,
709 | modificationScope: 'thisEventOnly',
710 | originalStartTime: singleEvent.start,
711 | summary: 'Updated Single Event',
712 | timeZone: 'America/Los_Angeles',
713 | sendUpdates: SEND_UPDATES
714 | }
715 | });
716 |
717 | // Errors are returned as text content
718 | const errorText = (invalidResult.content as any)[0]?.text?.toLowerCase() || '';
719 | expect(errorText).toMatch(/scope.*only applies to recurring events|not a recurring event/i);
720 | });
721 |
722 | it('should handle complex recurring event updates with all fields', async () => {
723 | // Create a complex recurring event
724 | const complexEvent = TestDataFactory.createRecurringEvent({
725 | summary: `Complex Weekly Meeting ${Date.now()}`,
726 | description: 'Original meeting with all fields',
727 | location: 'Executive Conference Room',
728 | colorId: '9'
729 | });
730 |
731 | // Add attendees and reminders
732 | const complexEventWithExtras = {
733 | ...complexEvent,
734 | attendees: [
735 | { email: '[email protected]' },
736 | { email: '[email protected]' }
737 | ],
738 | reminders: {
739 | useDefault: false,
740 | overrides: [
741 | { method: 'email' as const, minutes: 1440 }, // 1 day before
742 | { method: 'popup' as const, minutes: 15 }
743 | ]
744 | }
745 | };
746 |
747 | const eventId = await createTestEvent(complexEventWithExtras);
748 | createdEventIds.push(eventId);
749 |
750 | // Wait for event to be searchable
751 | await new Promise(resolve => setTimeout(resolve, 2000));
752 |
753 | // Update with all fields
754 | const updateResult = await client.callTool({
755 | name: 'update-event',
756 | arguments: {
757 | calendarId: TEST_CALENDAR_ID,
758 | eventId: eventId,
759 | modificationScope: 'all',
760 | summary: 'Updated Complex Meeting - All Fields',
761 | description: 'Updated meeting with all the bells and whistles',
762 | location: 'New Executive Conference Room',
763 | colorId: '11', // Different color
764 | attendees: [
765 | { email: '[email protected]' },
766 | { email: '[email protected]' },
767 | { email: '[email protected]' } // Added attendee
768 | ],
769 | reminders: {
770 | useDefault: false,
771 | overrides: [
772 | { method: 'email' as const, minutes: 1440 },
773 | { method: 'popup' as const, minutes: 30 } // Changed from 15 to 30
774 | ]
775 | },
776 | timeZone: 'America/Los_Angeles',
777 | sendUpdates: SEND_UPDATES
778 | }
779 | });
780 |
781 | expect(TestDataFactory.validateEventResponse(updateResult)).toBe(true);
782 | const updateResponse = JSON.parse((updateResult.content as any)[0].text);
783 | expect(updateResponse.event).toBeDefined();
784 | expect(updateResponse.event.summary).toBe('Updated Complex Meeting - All Fields');
785 |
786 | // Verify the update
787 | await verifyEventInSearch('Updated Complex Meeting - All Fields');
788 | });
789 |
790 | it('should convert timed event to all-day event and back (Issue #118)', async () => {
791 | console.log('\n🧪 Testing timed ↔ all-day event conversion (Issue #118)...');
792 |
793 | // Step 1: Create a timed event
794 | const timedEvent = TestDataFactory.createSingleEvent({
795 | summary: `Conversion Test ${Date.now()}`,
796 | description: 'Testing conversion between timed and all-day formats'
797 | });
798 |
799 | const eventId = await createTestEvent(timedEvent);
800 | createdEventIds.push(eventId);
801 | console.log(`✅ Created timed event: ${eventId}`);
802 |
803 | // Wait for event to be created
804 | await new Promise(resolve => setTimeout(resolve, 2000));
805 |
806 | // Step 2: Convert timed event to all-day event
807 | console.log('🔄 Converting timed event to all-day...');
808 | const toAllDayResult = await client.callTool({
809 | name: 'update-event',
810 | arguments: {
811 | calendarId: TEST_CALENDAR_ID,
812 | eventId: eventId,
813 | start: '2025-10-25',
814 | end: '2025-10-26',
815 | sendUpdates: SEND_UPDATES
816 | }
817 | });
818 |
819 | expect(TestDataFactory.validateEventResponse(toAllDayResult)).toBe(true);
820 | const allDayResponse = JSON.parse((toAllDayResult.content as any)[0].text);
821 | expect(allDayResponse.event).toBeDefined();
822 | expect(allDayResponse.event.start.date).toBe('2025-10-25');
823 | expect(allDayResponse.event.end.date).toBe('2025-10-26');
824 | expect(allDayResponse.event.start.dateTime).toBeUndefined();
825 | console.log('✅ Successfully converted to all-day event');
826 |
827 | // Wait for update to propagate
828 | await new Promise(resolve => setTimeout(resolve, 2000));
829 |
830 | // Step 3: Convert all-day event back to timed event
831 | console.log('🔄 Converting all-day event back to timed...');
832 | const toTimedResult = await client.callTool({
833 | name: 'update-event',
834 | arguments: {
835 | calendarId: TEST_CALENDAR_ID,
836 | eventId: eventId,
837 | start: '2025-10-25T09:00:00',
838 | end: '2025-10-25T10:00:00',
839 | timeZone: 'America/Los_Angeles',
840 | sendUpdates: SEND_UPDATES
841 | }
842 | });
843 |
844 | expect(TestDataFactory.validateEventResponse(toTimedResult)).toBe(true);
845 | const timedResponse = JSON.parse((toTimedResult.content as any)[0].text);
846 | expect(timedResponse.event).toBeDefined();
847 | expect(timedResponse.event.start.dateTime).toBeDefined();
848 | expect(timedResponse.event.end.dateTime).toBeDefined();
849 | expect(timedResponse.event.start.date).toBeUndefined();
850 | console.log('✅ Successfully converted back to timed event');
851 |
852 | // Step 4: Verify we can create an all-day event directly and convert it
853 | console.log('🔄 Testing direct all-day event creation and conversion...');
854 | const allDayEventData = {
855 | summary: `All-Day Conversion Test ${Date.now()}`,
856 | start: '2025-12-25',
857 | end: '2025-12-26',
858 | description: 'Testing all-day to timed conversion'
859 | };
860 |
861 | const allDayEventId = await createTestEvent(allDayEventData);
862 | createdEventIds.push(allDayEventId);
863 | console.log(`✅ Created all-day event: ${allDayEventId}`);
864 |
865 | // Wait for event to be created
866 | await new Promise(resolve => setTimeout(resolve, 2000));
867 |
868 | // Convert all-day to timed
869 | const directConversionResult = await client.callTool({
870 | name: 'update-event',
871 | arguments: {
872 | calendarId: TEST_CALENDAR_ID,
873 | eventId: allDayEventId,
874 | start: '2025-12-25T10:00:00',
875 | end: '2025-12-25T17:00:00',
876 | timeZone: 'America/Los_Angeles',
877 | sendUpdates: SEND_UPDATES
878 | }
879 | });
880 |
881 | expect(TestDataFactory.validateEventResponse(directConversionResult)).toBe(true);
882 | const directResponse = JSON.parse((directConversionResult.content as any)[0].text);
883 | expect(directResponse.event).toBeDefined();
884 | expect(directResponse.event.start.dateTime).toBeDefined();
885 | expect(directResponse.event.end.dateTime).toBeDefined();
886 | console.log('✅ Successfully converted all-day event to timed event');
887 |
888 | console.log('✨ All conversion tests passed!');
889 | });
890 | });
891 |
892 | describe('Batch and Multi-Calendar Operations', () => {
893 | it('should handle multiple calendar queries', async () => {
894 | const startTime = testFactory.startTimer('list-events-multiple-calendars');
895 |
896 | try {
897 | const timeRanges = TestDataFactory.getTimeRanges();
898 | const result = await client.callTool({
899 | name: 'list-events',
900 | arguments: {
901 | calendarId: JSON.stringify(['primary', TEST_CALENDAR_ID]),
902 | timeMin: timeRanges.nextWeek.timeMin,
903 | timeMax: timeRanges.nextWeek.timeMax
904 | }
905 | });
906 |
907 | testFactory.endTimer('list-events-multiple-calendars', startTime, true);
908 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
909 | } catch (error) {
910 | testFactory.endTimer('list-events-multiple-calendars', startTime, false, String(error));
911 | throw error;
912 | }
913 | });
914 |
915 | it('should list events with specific fields', async () => {
916 | // Create an event with various fields
917 | const eventData = TestDataFactory.createEventWithAttendees({
918 | summary: `Integration Test - Field Filtering ${Date.now()}`,
919 | description: 'Testing field filtering in list-events',
920 | location: 'Conference Room A'
921 | });
922 |
923 | const eventId = await createTestEvent(eventData);
924 | createdEventIds.push(eventId);
925 |
926 | const startTime = testFactory.startTimer('list-events-with-fields');
927 |
928 | try {
929 | const timeRanges = TestDataFactory.getTimeRanges();
930 | const result = await client.callTool({
931 | name: 'list-events',
932 | arguments: {
933 | calendarId: TEST_CALENDAR_ID,
934 | timeMin: timeRanges.nextWeek.timeMin,
935 | timeMax: timeRanges.nextWeek.timeMax,
936 | fields: ['description', 'location', 'attendees', 'created', 'updated', 'creator', 'organizer']
937 | }
938 | });
939 |
940 | testFactory.endTimer('list-events-with-fields', startTime, true);
941 |
942 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
943 | const responseText = (result.content as any)[0].text;
944 | expect(responseText).toContain(eventId);
945 | expect(responseText).toContain(eventData.summary);
946 | // The response should include the additional fields we requested
947 | expect(responseText).toContain(eventData.description!);
948 | expect(responseText).toContain(eventData.location!);
949 | } catch (error) {
950 | testFactory.endTimer('list-events-with-fields', startTime, false, String(error));
951 | throw error;
952 | }
953 | });
954 |
955 | it('should filter events by extended properties', async () => {
956 | // Create two events - one with matching properties, one without
957 | const matchingEventData = TestDataFactory.createSingleEvent({
958 | summary: `Integration Test - Matching Extended Props ${Date.now()}`
959 | });
960 |
961 | const nonMatchingEventData = TestDataFactory.createSingleEvent({
962 | summary: `Integration Test - Non-Matching Extended Props ${Date.now()}`
963 | });
964 |
965 | // Create event with extended properties
966 | const result1 = await client.callTool({
967 | name: 'create-event',
968 | arguments: {
969 | calendarId: TEST_CALENDAR_ID,
970 | ...matchingEventData,
971 | extendedProperties: {
972 | private: {
973 | testRun: 'integration-test',
974 | environment: 'test'
975 | },
976 | shared: {
977 | visibility: 'team'
978 | }
979 | }
980 | }
981 | });
982 |
983 | const matchingEventId = extractEventId(result1);
984 | createdEventIds.push(matchingEventId!);
985 |
986 | // Create event without matching properties
987 | const result2 = await client.callTool({
988 | name: 'create-event',
989 | arguments: {
990 | calendarId: TEST_CALENDAR_ID,
991 | ...nonMatchingEventData,
992 | extendedProperties: {
993 | private: {
994 | testRun: 'other-test',
995 | environment: 'production'
996 | }
997 | }
998 | }
999 | });
1000 |
1001 | const nonMatchingEventId = extractEventId(result2);
1002 | createdEventIds.push(nonMatchingEventId!);
1003 |
1004 | // Wait for events to be searchable
1005 | await new Promise(resolve => setTimeout(resolve, 1000));
1006 |
1007 | const startTime = testFactory.startTimer('list-events-extended-properties');
1008 |
1009 | try {
1010 | const timeRanges = TestDataFactory.getTimeRanges();
1011 | const result = await client.callTool({
1012 | name: 'list-events',
1013 | arguments: {
1014 | calendarId: TEST_CALENDAR_ID,
1015 | timeMin: timeRanges.nextWeek.timeMin,
1016 | timeMax: timeRanges.nextWeek.timeMax,
1017 | privateExtendedProperty: ['testRun=integration-test', 'environment=test'],
1018 | sharedExtendedProperty: ['visibility=team']
1019 | }
1020 | });
1021 |
1022 | testFactory.endTimer('list-events-extended-properties', startTime, true);
1023 |
1024 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1025 | const responseText = (result.content as any)[0].text;
1026 |
1027 | // Should find the matching event
1028 | expect(responseText).toContain(matchingEventId);
1029 | expect(responseText).toContain('Matching Extended Props');
1030 |
1031 | // Should NOT find the non-matching event
1032 | expect(responseText).not.toContain(nonMatchingEventId);
1033 | expect(responseText).not.toContain('Non-Matching Extended Props');
1034 | } catch (error) {
1035 | testFactory.endTimer('list-events-extended-properties', startTime, false, String(error));
1036 | throw error;
1037 | }
1038 | });
1039 |
1040 | it('should resolve calendar names to IDs automatically', async () => {
1041 | const startTime = testFactory.startTimer('list-events-calendar-name-resolution');
1042 |
1043 | try {
1044 | // First, get the list of calendars to find a calendar name
1045 | const calendarsResult = await client.callTool({
1046 | name: 'list-calendars',
1047 | arguments: {}
1048 | });
1049 |
1050 | expect(TestDataFactory.validateEventResponse(calendarsResult)).toBe(true);
1051 | const calendarsResponse = JSON.parse((calendarsResult.content as any)[0].text);
1052 | expect(calendarsResponse.calendars).toBeDefined();
1053 | expect(calendarsResponse.calendars.length).toBeGreaterThan(0);
1054 |
1055 | // Get the first calendar's name (summary field)
1056 | const firstCalendar = calendarsResponse.calendars[0];
1057 | const calendarName = firstCalendar.summary;
1058 | const calendarId = firstCalendar.id;
1059 |
1060 | console.log(`🔍 Testing calendar name resolution: "${calendarName}" -> "${calendarId}"`);
1061 |
1062 | // Test 1: Use calendar name instead of ID
1063 | const timeRanges = TestDataFactory.getTimeRanges();
1064 | const resultWithName = await client.callTool({
1065 | name: 'list-events',
1066 | arguments: {
1067 | calendarId: calendarName, // Using calendar name, not ID
1068 | timeMin: timeRanges.nextWeek.timeMin,
1069 | timeMax: timeRanges.nextWeek.timeMax
1070 | }
1071 | });
1072 |
1073 | expect(TestDataFactory.validateEventResponse(resultWithName)).toBe(true);
1074 | const responseWithName = JSON.parse((resultWithName.content as any)[0].text);
1075 | console.log(`✅ Successfully listed events using calendar name: "${calendarName}"`);
1076 |
1077 | // Test 2: Use calendar ID directly (for comparison)
1078 | const resultWithId = await client.callTool({
1079 | name: 'list-events',
1080 | arguments: {
1081 | calendarId: calendarId,
1082 | timeMin: timeRanges.nextWeek.timeMin,
1083 | timeMax: timeRanges.nextWeek.timeMax
1084 | }
1085 | });
1086 |
1087 | expect(TestDataFactory.validateEventResponse(resultWithId)).toBe(true);
1088 | const responseWithId = JSON.parse((resultWithId.content as any)[0].text);
1089 |
1090 | // Both methods should return the same events
1091 | expect(responseWithName.totalCount).toBe(responseWithId.totalCount);
1092 | console.log(`✅ Calendar name and ID both return ${responseWithId.totalCount} events`);
1093 |
1094 | // Test 3: Use multiple calendar names in an array
1095 | if (calendarsResponse.calendars.length > 1) {
1096 | const secondCalendar = calendarsResponse.calendars[1];
1097 | const calendarNames = [calendarName, secondCalendar.summary];
1098 |
1099 | console.log(`🔍 Testing multiple calendar names: ${JSON.stringify(calendarNames)}`);
1100 |
1101 | const resultWithMultipleNames = await client.callTool({
1102 | name: 'list-events',
1103 | arguments: {
1104 | calendarId: JSON.stringify(calendarNames),
1105 | timeMin: timeRanges.nextWeek.timeMin,
1106 | timeMax: timeRanges.nextWeek.timeMax
1107 | }
1108 | });
1109 |
1110 | expect(TestDataFactory.validateEventResponse(resultWithMultipleNames)).toBe(true);
1111 | const responseWithMultipleNames = JSON.parse((resultWithMultipleNames.content as any)[0].text);
1112 | console.log(`✅ Successfully listed events from ${calendarNames.length} calendars using names`);
1113 | expect(responseWithMultipleNames.calendars).toBeDefined();
1114 | expect(responseWithMultipleNames.calendars.length).toBe(2);
1115 | }
1116 |
1117 | // Test 4: Invalid calendar name should provide helpful error
1118 | // Note: MCP tools return errors as responses (with error content), not as thrown exceptions
1119 | const result = await client.callTool({
1120 | name: 'list-events',
1121 | arguments: {
1122 | calendarId: 'ThisCalendarNameDefinitelyDoesNotExist_XYZ123',
1123 | timeMin: timeRanges.nextWeek.timeMin,
1124 | timeMax: timeRanges.nextWeek.timeMax
1125 | }
1126 | });
1127 |
1128 | // Extract the error message from the MCP response
1129 | const resultText = (result.content as any)[0]?.text || JSON.stringify(result);
1130 |
1131 | // Verify it contains our resolution error message
1132 | expect(resultText).toContain('Calendar(s) not found');
1133 | expect(resultText).toContain('ThisCalendarNameDefinitelyDoesNotExist_XYZ123');
1134 | expect(resultText).toContain('Available calendars');
1135 | console.log('✅ Helpful error message provided for invalid calendar name');
1136 | console.log(` Error: ${resultText.substring(0, 150)}...`);
1137 |
1138 | testFactory.endTimer('list-events-calendar-name-resolution', startTime, true);
1139 | } catch (error) {
1140 | testFactory.endTimer('list-events-calendar-name-resolution', startTime, false, String(error));
1141 | throw error;
1142 | }
1143 | });
1144 |
1145 | it('should search events with specific fields', async () => {
1146 | // Create an event with rich data
1147 | const eventData = TestDataFactory.createColoredEvent('11', {
1148 | summary: `Search Test - Field Filtering Event ${Date.now()}`,
1149 | description: 'This event tests field filtering in search-events',
1150 | location: 'Virtual Meeting Room'
1151 | });
1152 |
1153 | const eventId = await createTestEvent(eventData);
1154 | createdEventIds.push(eventId);
1155 |
1156 | // Wait for event to be searchable
1157 | await new Promise(resolve => setTimeout(resolve, 2000));
1158 |
1159 | const startTime = testFactory.startTimer('search-events-with-fields');
1160 |
1161 | try {
1162 | const timeRanges = TestDataFactory.getTimeRanges();
1163 | const result = await client.callTool({
1164 | name: 'search-events',
1165 | arguments: {
1166 | calendarId: TEST_CALENDAR_ID,
1167 | query: 'Field Filtering',
1168 | timeMin: timeRanges.nextWeek.timeMin,
1169 | timeMax: timeRanges.nextWeek.timeMax,
1170 | fields: ['colorId', 'description', 'location', 'created', 'updated', 'htmlLink']
1171 | }
1172 | });
1173 |
1174 | testFactory.endTimer('search-events-with-fields', startTime, true);
1175 |
1176 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1177 | const responseText = (result.content as any)[0].text;
1178 | expect(responseText).toContain(eventId);
1179 | expect(responseText).toContain(eventData.summary);
1180 | expect(responseText).toContain(eventData.description!);
1181 | expect(responseText).toContain(eventData.location!);
1182 | // Color information may not be included when specific fields are requested
1183 | // Just verify the search found the event with the requested fields
1184 | } catch (error) {
1185 | testFactory.endTimer('search-events-with-fields', startTime, false, String(error));
1186 | throw error;
1187 | }
1188 | });
1189 |
1190 | it('should search events filtered by extended properties', async () => {
1191 | // Create event with searchable content and extended properties
1192 | const uniqueId = Date.now();
1193 | const eventData = TestDataFactory.createSingleEvent({
1194 | summary: `Search Extended Props Test Event ${uniqueId}`,
1195 | description: 'This event has extended properties for filtering'
1196 | });
1197 |
1198 | const result = await client.callTool({
1199 | name: 'create-event',
1200 | arguments: {
1201 | calendarId: TEST_CALENDAR_ID,
1202 | ...eventData,
1203 | allowDuplicates: true, // Add this to handle duplicate events from previous runs
1204 | extendedProperties: {
1205 | private: {
1206 | searchTest: `enabled-${uniqueId}`,
1207 | category: 'integration'
1208 | },
1209 | shared: {
1210 | team: 'qa'
1211 | }
1212 | }
1213 | }
1214 | });
1215 |
1216 | const eventId = extractEventId(result);
1217 | expect(eventId).toBeTruthy(); // Make sure we got an event ID
1218 | createdEventIds.push(eventId!);
1219 |
1220 | // Wait for event to be searchable
1221 | await new Promise(resolve => setTimeout(resolve, 2000));
1222 |
1223 | const startTime = testFactory.startTimer('search-events-extended-properties');
1224 |
1225 | try {
1226 | const timeRanges = TestDataFactory.getTimeRanges();
1227 | const searchResult = await client.callTool({
1228 | name: 'search-events',
1229 | arguments: {
1230 | calendarId: TEST_CALENDAR_ID,
1231 | query: 'Extended Props',
1232 | timeMin: timeRanges.nextWeek.timeMin,
1233 | timeMax: timeRanges.nextWeek.timeMax,
1234 | privateExtendedProperty: [`searchTest=enabled-${uniqueId}`, 'category=integration'],
1235 | sharedExtendedProperty: ['team=qa']
1236 | }
1237 | });
1238 |
1239 | testFactory.endTimer('search-events-extended-properties', startTime, true);
1240 |
1241 | expect(TestDataFactory.validateEventResponse(searchResult)).toBe(true);
1242 | const response = JSON.parse((searchResult.content as any)[0].text);
1243 | expect(response.events).toBeDefined();
1244 | expect(response.events.length).toBeGreaterThan(0);
1245 | expect(response.events[0].id).toBe(eventId);
1246 | expect(response.events[0].summary).toContain('Search Extended Props Test Event');
1247 | } catch (error) {
1248 | testFactory.endTimer('search-events-extended-properties', startTime, false, String(error));
1249 | throw error;
1250 | }
1251 | });
1252 | });
1253 |
1254 | describe('Free/Busy Queries', () => {
1255 | it('should check availability for test calendar', async () => {
1256 | const startTime = testFactory.startTimer('get-freebusy');
1257 |
1258 | try {
1259 | const timeRanges = TestDataFactory.getTimeRanges();
1260 | const result = await client.callTool({
1261 | name: 'get-freebusy',
1262 | arguments: {
1263 | calendars: [{ id: TEST_CALENDAR_ID }],
1264 | timeMin: timeRanges.nextWeek.timeMin,
1265 | timeMax: timeRanges.nextWeek.timeMax,
1266 | timeZone: 'America/Los_Angeles'
1267 | }
1268 | });
1269 |
1270 | testFactory.endTimer('get-freebusy', startTime, true);
1271 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1272 |
1273 | const response = JSON.parse((result.content as any)[0].text);
1274 | expect(response.timeMin).toBeDefined();
1275 | expect(response.timeMax).toBeDefined();
1276 | expect(response.calendars).toBeDefined();
1277 | expect(typeof response.calendars).toBe('object');
1278 | } catch (error) {
1279 | testFactory.endTimer('get-freebusy', startTime, false, String(error));
1280 | throw error;
1281 | }
1282 | });
1283 |
1284 | it('should create event with custom event ID', async () => {
1285 | // Google Calendar event IDs must use base32hex encoding: lowercase a-v and 0-9 only
1286 | // Generate a valid base32hex ID
1287 | const timestamp = Date.now().toString(32).replace(/[w-z]/g, (c) =>
1288 | String.fromCharCode(c.charCodeAt(0) - 22)
1289 | );
1290 | const randomPart = Math.random().toString(32).substring(2, 8).replace(/[w-z]/g, (c) =>
1291 | String.fromCharCode(c.charCodeAt(0) - 22)
1292 | );
1293 | const customEventId = `test${timestamp}${randomPart}`.substring(0, 26);
1294 |
1295 | const eventData = TestDataFactory.createSingleEvent({
1296 | summary: `Integration Test - Custom Event ID ${Date.now()}`
1297 | });
1298 |
1299 | const startTime = testFactory.startTimer('create-event-custom-id');
1300 |
1301 | try {
1302 | const result = await client.callTool({
1303 | name: 'create-event',
1304 | arguments: {
1305 | calendarId: TEST_CALENDAR_ID,
1306 | eventId: customEventId,
1307 | ...eventData
1308 | }
1309 | });
1310 |
1311 | testFactory.endTimer('create-event-custom-id', startTime, true);
1312 |
1313 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1314 |
1315 | const responseText = (result.content as any)[0].text;
1316 | expect(responseText).toContain(customEventId);
1317 |
1318 | // Clean up
1319 | createdEventIds.push(customEventId);
1320 | testFactory.addCreatedEventId(customEventId);
1321 | } catch (error) {
1322 | testFactory.endTimer('create-event-custom-id', startTime, false, String(error));
1323 | throw error;
1324 | }
1325 | });
1326 |
1327 | it('should handle duplicate custom event ID error', async () => {
1328 | // Google Calendar event IDs must use base32hex encoding: lowercase a-v and 0-9 only
1329 | // Generate a valid base32hex ID
1330 | const timestamp = Date.now().toString(32).replace(/[w-z]/g, (c) =>
1331 | String.fromCharCode(c.charCodeAt(0) - 22)
1332 | );
1333 | const randomPart = Math.random().toString(32).substring(2, 8).replace(/[w-z]/g, (c) =>
1334 | String.fromCharCode(c.charCodeAt(0) - 22)
1335 | );
1336 | const customEventId = `dup${timestamp}${randomPart}`.substring(0, 26);
1337 |
1338 | const eventData = TestDataFactory.createSingleEvent({
1339 | summary: `Integration Test - Duplicate ID Test ${Date.now()}`
1340 | });
1341 |
1342 | // First create an event with custom ID
1343 | const result1 = await client.callTool({
1344 | name: 'create-event',
1345 | arguments: {
1346 | calendarId: TEST_CALENDAR_ID,
1347 | eventId: customEventId,
1348 | ...eventData
1349 | }
1350 | });
1351 |
1352 | expect(TestDataFactory.validateEventResponse(result1)).toBe(true);
1353 | createdEventIds.push(customEventId);
1354 |
1355 | // Wait a moment for Google Calendar to fully process the event
1356 | await new Promise(resolve => setTimeout(resolve, 1000));
1357 |
1358 | // Try to create another event with the same ID
1359 | const startTime = testFactory.startTimer('create-event-duplicate-id');
1360 |
1361 | try {
1362 | await client.callTool({
1363 | name: 'create-event',
1364 | arguments: {
1365 | calendarId: TEST_CALENDAR_ID,
1366 | eventId: customEventId,
1367 | ...eventData
1368 | }
1369 | });
1370 |
1371 | // If we get here, the duplicate wasn't caught (test should fail)
1372 | testFactory.endTimer('create-event-duplicate-id', startTime, false);
1373 | expect.fail('Expected error for duplicate event ID');
1374 | } catch (error: any) {
1375 | testFactory.endTimer('create-event-duplicate-id', startTime, true);
1376 |
1377 | // The error should mention the ID already exists
1378 | const errorMessage = error.message || String(error);
1379 | expect(errorMessage).toMatch(/already exists|duplicate|conflict|409/i);
1380 | }
1381 | });
1382 |
1383 | it('should create event with transparency and visibility options', async () => {
1384 | const eventData = TestDataFactory.createSingleEvent({
1385 | summary: `Integration Test - Transparency and Visibility ${Date.now()}`
1386 | });
1387 |
1388 | const startTime = testFactory.startTimer('create-event-transparency-visibility');
1389 |
1390 | try {
1391 | const result = await client.callTool({
1392 | name: 'create-event',
1393 | arguments: {
1394 | calendarId: TEST_CALENDAR_ID,
1395 | ...eventData,
1396 | transparency: 'transparent',
1397 | visibility: 'private',
1398 | guestsCanInviteOthers: false,
1399 | guestsCanModify: true,
1400 | guestsCanSeeOtherGuests: false
1401 | }
1402 | });
1403 |
1404 | testFactory.endTimer('create-event-transparency-visibility', startTime, true);
1405 |
1406 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1407 |
1408 | const eventId = extractEventId(result);
1409 | expect(eventId).toBeTruthy();
1410 |
1411 | createdEventIds.push(eventId!);
1412 | testFactory.addCreatedEventId(eventId!);
1413 | } catch (error) {
1414 | testFactory.endTimer('create-event-transparency-visibility', startTime, false, String(error));
1415 | throw error;
1416 | }
1417 | });
1418 |
1419 | it('should create event with extended properties', async () => {
1420 | const eventData = TestDataFactory.createSingleEvent({
1421 | summary: `Integration Test - Extended Properties ${Date.now()}`
1422 | });
1423 |
1424 | const startTime = testFactory.startTimer('create-event-extended-properties');
1425 |
1426 | try {
1427 | const result = await client.callTool({
1428 | name: 'create-event',
1429 | arguments: {
1430 | calendarId: TEST_CALENDAR_ID,
1431 | ...eventData,
1432 | extendedProperties: {
1433 | private: {
1434 | projectId: 'proj-123',
1435 | customerId: 'cust-456',
1436 | category: 'meeting'
1437 | },
1438 | shared: {
1439 | department: 'engineering',
1440 | team: 'backend'
1441 | }
1442 | }
1443 | }
1444 | });
1445 |
1446 | testFactory.endTimer('create-event-extended-properties', startTime, true);
1447 |
1448 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1449 |
1450 | const eventId = extractEventId(result);
1451 | expect(eventId).toBeTruthy();
1452 |
1453 | createdEventIds.push(eventId!);
1454 | testFactory.addCreatedEventId(eventId!);
1455 |
1456 | // Verify the event can be found by extended properties
1457 | await new Promise(resolve => setTimeout(resolve, 1000));
1458 |
1459 | const searchResult = await client.callTool({
1460 | name: 'list-events',
1461 | arguments: {
1462 | calendarId: TEST_CALENDAR_ID,
1463 | timeMin: eventData.start,
1464 | timeMax: eventData.end,
1465 | privateExtendedProperty: ['projectId=proj-123', 'customerId=cust-456']
1466 | }
1467 | });
1468 |
1469 | expect(TestDataFactory.validateEventResponse(searchResult)).toBe(true);
1470 | const searchResponse = JSON.parse((searchResult.content as any)[0].text);
1471 | expect(searchResponse.events).toBeDefined();
1472 | const foundEvent = searchResponse.events.find((e: any) => e.id === eventId);
1473 | expect(foundEvent).toBeDefined();
1474 | } catch (error) {
1475 | testFactory.endTimer('create-event-extended-properties', startTime, false, String(error));
1476 | throw error;
1477 | }
1478 | });
1479 |
1480 | it('should create event with conference data', async () => {
1481 | const eventData = TestDataFactory.createSingleEvent({
1482 | summary: `Integration Test - Conference Event ${Date.now()}`
1483 | });
1484 |
1485 | const startTime = testFactory.startTimer('create-event-conference');
1486 |
1487 | try {
1488 | const result = await client.callTool({
1489 | name: 'create-event',
1490 | arguments: {
1491 | calendarId: TEST_CALENDAR_ID,
1492 | ...eventData,
1493 | conferenceData: {
1494 | createRequest: {
1495 | requestId: `conf-${Date.now()}`,
1496 | conferenceSolutionKey: {
1497 | type: 'hangoutsMeet'
1498 | }
1499 | }
1500 | }
1501 | }
1502 | });
1503 |
1504 | testFactory.endTimer('create-event-conference', startTime, true);
1505 |
1506 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1507 |
1508 | const eventId = extractEventId(result);
1509 | expect(eventId).toBeTruthy();
1510 |
1511 | createdEventIds.push(eventId!);
1512 | testFactory.addCreatedEventId(eventId!);
1513 | } catch (error) {
1514 | testFactory.endTimer('create-event-conference', startTime, false, String(error));
1515 | throw error;
1516 | }
1517 | });
1518 |
1519 | it('should create event with source information', async () => {
1520 | const eventData = TestDataFactory.createSingleEvent({
1521 | summary: `Integration Test - Event with Source ${Date.now()}`
1522 | });
1523 |
1524 | const startTime = testFactory.startTimer('create-event-source');
1525 |
1526 | try {
1527 | const result = await client.callTool({
1528 | name: 'create-event',
1529 | arguments: {
1530 | calendarId: TEST_CALENDAR_ID,
1531 | ...eventData,
1532 | source: {
1533 | url: 'https://example.com/events/123',
1534 | title: 'Original Event Source'
1535 | }
1536 | }
1537 | });
1538 |
1539 | testFactory.endTimer('create-event-source', startTime, true);
1540 |
1541 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1542 |
1543 | const eventId = extractEventId(result);
1544 | expect(eventId).toBeTruthy();
1545 |
1546 | createdEventIds.push(eventId!);
1547 | testFactory.addCreatedEventId(eventId!);
1548 | } catch (error) {
1549 | testFactory.endTimer('create-event-source', startTime, false, String(error));
1550 | throw error;
1551 | }
1552 | });
1553 |
1554 | it('should create event with complex attendee details', async () => {
1555 | const eventData = TestDataFactory.createSingleEvent({
1556 | summary: `Integration Test - Complex Attendees ${Date.now()}`
1557 | });
1558 |
1559 | const startTime = testFactory.startTimer('create-event-complex-attendees');
1560 |
1561 | try {
1562 | const result = await client.callTool({
1563 | name: 'create-event',
1564 | arguments: {
1565 | calendarId: TEST_CALENDAR_ID,
1566 | ...eventData,
1567 | attendees: [
1568 | {
1569 | email: '[email protected]',
1570 | displayName: 'Required Attendee',
1571 | optional: false,
1572 | responseStatus: 'needsAction',
1573 | comment: 'Looking forward to the meeting',
1574 | additionalGuests: 2
1575 | },
1576 | {
1577 | email: '[email protected]',
1578 | displayName: 'Optional Attendee',
1579 | optional: true,
1580 | responseStatus: 'tentative'
1581 | }
1582 | ],
1583 | sendUpdates: 'none' // Don't send real emails in tests
1584 | }
1585 | });
1586 |
1587 | testFactory.endTimer('create-event-complex-attendees', startTime, true);
1588 |
1589 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
1590 |
1591 | const eventId = extractEventId(result);
1592 | expect(eventId).toBeTruthy();
1593 |
1594 | createdEventIds.push(eventId!);
1595 | testFactory.addCreatedEventId(eventId!);
1596 | } catch (error) {
1597 | testFactory.endTimer('create-event-complex-attendees', startTime, false, String(error));
1598 | throw error;
1599 | }
1600 | });
1601 | });
1602 | });
1603 |
1604 | describe('Error Handling and Edge Cases', () => {
1605 | it('should handle invalid calendar ID gracefully', async () => {
1606 | const invalidData = TestDataFactory.getInvalidTestData();
1607 | const now = new Date();
1608 | const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
1609 |
1610 | try {
1611 | await client.callTool({
1612 | name: 'list-events',
1613 | arguments: {
1614 | calendarId: invalidData.invalidCalendarId,
1615 | timeMin: TestDataFactory.formatDateTimeRFC3339WithTimezone(now),
1616 | timeMax: TestDataFactory.formatDateTimeRFC3339WithTimezone(tomorrow)
1617 | }
1618 | });
1619 |
1620 | // If we get here, the error wasn't caught (test should fail)
1621 | expect.fail('Expected error for invalid calendar ID');
1622 | } catch (error: any) {
1623 | // Should get an error about invalid calendar ID
1624 | const errorMessage = error.message || String(error);
1625 | expect(errorMessage.toLowerCase()).toContain('error');
1626 | }
1627 | });
1628 |
1629 | it('should handle invalid event ID gracefully', async () => {
1630 | const invalidData = TestDataFactory.getInvalidTestData();
1631 |
1632 | try {
1633 | await client.callTool({
1634 | name: 'delete-event',
1635 | arguments: {
1636 | calendarId: TEST_CALENDAR_ID,
1637 | eventId: invalidData.invalidEventId,
1638 | sendUpdates: SEND_UPDATES
1639 | }
1640 | });
1641 |
1642 | // If we get here, the error wasn't caught (test should fail)
1643 | expect.fail('Expected error for invalid event ID');
1644 | } catch (error: any) {
1645 | // Should get an error about invalid event ID
1646 | const errorMessage = error.message || String(error);
1647 | expect(errorMessage.toLowerCase()).toContain('error');
1648 | }
1649 | });
1650 |
1651 | it('should handle malformed date formats gracefully', async () => {
1652 | const invalidData = TestDataFactory.getInvalidTestData();
1653 |
1654 | try {
1655 | await client.callTool({
1656 | name: 'create-event',
1657 | arguments: {
1658 | calendarId: TEST_CALENDAR_ID,
1659 | summary: 'Test Event',
1660 | start: invalidData.invalidTimeFormat,
1661 | end: invalidData.invalidTimeFormat,
1662 | timeZone: 'America/Los_Angeles',
1663 | sendUpdates: SEND_UPDATES
1664 | }
1665 | });
1666 |
1667 | // If we get here, the error wasn't caught (test should fail)
1668 | expect.fail('Expected error for malformed date format');
1669 | } catch (error: any) {
1670 | // Should get an error about invalid time value
1671 | const errorMessage = error.message || String(error);
1672 | expect(errorMessage.toLowerCase()).toMatch(/invalid|error|time/i);
1673 | }
1674 | });
1675 | });
1676 |
1677 | describe('Timezone Handling Validation', () => {
1678 | it('should correctly interpret timezone-naive timeMin/timeMax in specified timezone', async () => {
1679 | // Test scenario: Create an event at 10:00 AM Los Angeles time,
1680 | // then use list-events with timezone-naive timeMin/timeMax and explicit timeZone
1681 | // to verify the event is found within a narrow time window.
1682 |
1683 | console.log('🧪 Testing timezone interpretation fix...');
1684 |
1685 | // Step 1: Create an event at 10:00 AM Los Angeles time on a specific date
1686 | const testDate = new Date();
1687 | testDate.setDate(testDate.getDate() + 7); // Next week to avoid conflicts
1688 | const year = testDate.getFullYear();
1689 | const month = String(testDate.getMonth() + 1).padStart(2, '0');
1690 | const day = String(testDate.getDate()).padStart(2, '0');
1691 |
1692 | const eventStart = `${year}-${month}-${day}T10:00:00-08:00`; // 10:00 AM PST (or PDT)
1693 | const eventEnd = `${year}-${month}-${day}T11:00:00-08:00`; // 11:00 AM PST (or PDT)
1694 |
1695 | const eventData: TestEvent = {
1696 | summary: 'Timezone Test Event - LA Time',
1697 | start: eventStart,
1698 | end: eventEnd,
1699 | description: 'This event tests timezone interpretation in list-events calls',
1700 | timeZone: 'America/Los_Angeles',
1701 | sendUpdates: SEND_UPDATES
1702 | };
1703 |
1704 | console.log(`📅 Creating event at ${eventStart} (Los Angeles time)`);
1705 |
1706 | const eventId = await createTestEvent(eventData);
1707 | createdEventIds.push(eventId);
1708 |
1709 | // Step 2: Use list-events with timezone-naive timeMin/timeMax and explicit timeZone
1710 | // This should correctly interpret the times as Los Angeles time, not system time
1711 |
1712 | // Define a narrow time window that includes our event (9:30 AM - 11:30 AM LA time)
1713 | const timeMin = `${year}-${month}-${day}T09:30:00`; // Timezone-naive
1714 | const timeMax = `${year}-${month}-${day}T11:30:00`; // Timezone-naive
1715 |
1716 | console.log(`🔍 Searching for event using timezone-naive times: ${timeMin} to ${timeMax} (interpreted as Los Angeles time)`);
1717 |
1718 | const startTime = testFactory.startTimer('list-events-timezone-naive');
1719 |
1720 | try {
1721 | const listResult = await client.callTool({
1722 | name: 'list-events',
1723 | arguments: {
1724 | calendarId: TEST_CALENDAR_ID,
1725 | timeMin: timeMin,
1726 | timeMax: timeMax,
1727 | timeZone: 'America/Los_Angeles' // This should interpret the timezone-naive times as LA time
1728 | }
1729 | });
1730 |
1731 | testFactory.endTimer('list-events-timezone-naive', startTime, true);
1732 |
1733 | expect(TestDataFactory.validateEventResponse(listResult)).toBe(true);
1734 | const responseText = (listResult.content as any)[0].text;
1735 |
1736 | // The event should be found because:
1737 | // - Event is at 10:00-11:00 AM LA time
1738 | // - Search window is 9:30-11:30 AM LA time (correctly interpreted)
1739 | expect(responseText).toContain(eventId);
1740 | expect(responseText).toContain('Timezone Test Event - LA Time');
1741 |
1742 | console.log('✅ Event found in timezone-aware search');
1743 |
1744 | // Step 3: Test the negative case - narrow window that excludes the event
1745 | // Search for 8:00-9:00 AM LA time (should NOT find the 10:00 AM event)
1746 | const excludingTimeMin = `${year}-${month}-${day}T08:00:00`;
1747 | const excludingTimeMax = `${year}-${month}-${day}T09:00:00`;
1748 |
1749 | console.log(`🔍 Testing negative case with excluding time window: ${excludingTimeMin} to ${excludingTimeMax}`);
1750 |
1751 | const excludingResult = await client.callTool({
1752 | name: 'list-events',
1753 | arguments: {
1754 | calendarId: TEST_CALENDAR_ID,
1755 | timeMin: excludingTimeMin,
1756 | timeMax: excludingTimeMax,
1757 | timeZone: 'America/Los_Angeles'
1758 | }
1759 | });
1760 |
1761 | expect(TestDataFactory.validateEventResponse(excludingResult)).toBe(true);
1762 | const excludingResponseText = (excludingResult.content as any)[0].text;
1763 |
1764 | // The event should NOT be found in this time window
1765 | expect(excludingResponseText).not.toContain(eventId);
1766 |
1767 | console.log('✅ Event correctly excluded from non-overlapping time window');
1768 | } catch (error) {
1769 | testFactory.endTimer('list-events-timezone-naive', startTime, false, String(error));
1770 | throw error;
1771 | }
1772 | });
1773 |
1774 | it('should correctly handle DST transitions in timezone interpretation', async () => {
1775 | // Test during DST period (July) to ensure DST is handled correctly
1776 | console.log('🧪 Testing DST timezone interpretation...');
1777 |
1778 | // Create an event in July (PDT period)
1779 | const eventStart = '2024-07-15T10:00:00-07:00'; // 10:00 AM PDT
1780 | const eventEnd = '2024-07-15T11:00:00-07:00'; // 11:00 AM PDT
1781 |
1782 | const eventData: TestEvent = {
1783 | summary: 'DST Timezone Test Event',
1784 | start: eventStart,
1785 | end: eventEnd,
1786 | description: 'This event tests DST timezone interpretation',
1787 | timeZone: 'America/Los_Angeles',
1788 | sendUpdates: SEND_UPDATES
1789 | };
1790 |
1791 | console.log(`📅 Creating DST event at ${eventStart} (Los Angeles PDT)`);
1792 |
1793 | const eventId = await createTestEvent(eventData);
1794 | createdEventIds.push(eventId);
1795 |
1796 | const startTime = testFactory.startTimer('list-events-dst');
1797 |
1798 | try {
1799 | // Search with timezone-naive times during DST period
1800 | const timeMin = '2024-07-15T09:30:00'; // Should be interpreted as PDT
1801 | const timeMax = '2024-07-15T11:30:00'; // Should be interpreted as PDT
1802 |
1803 | console.log(`🔍 Searching during DST period: ${timeMin} to ${timeMax} (PDT)`);
1804 |
1805 | const listResult = await client.callTool({
1806 | name: 'list-events',
1807 | arguments: {
1808 | calendarId: TEST_CALENDAR_ID,
1809 | timeMin: timeMin,
1810 | timeMax: timeMax,
1811 | timeZone: 'America/Los_Angeles'
1812 | }
1813 | });
1814 |
1815 | testFactory.endTimer('list-events-dst', startTime, true);
1816 |
1817 | expect(TestDataFactory.validateEventResponse(listResult)).toBe(true);
1818 | const responseText = (listResult.content as any)[0].text;
1819 |
1820 | expect(responseText).toContain(eventId);
1821 | expect(responseText).toContain('DST Timezone Test Event');
1822 |
1823 | console.log('✅ DST timezone interpretation works correctly');
1824 | } catch (error) {
1825 | testFactory.endTimer('list-events-dst', startTime, false, String(error));
1826 | throw error;
1827 | }
1828 | });
1829 |
1830 | it('should preserve timezone-aware datetime inputs regardless of timeZone parameter', async () => {
1831 | // Test that when timeMin/timeMax already have timezone info,
1832 | // the timeZone parameter doesn't override them
1833 | console.log('🧪 Testing timezone-aware datetime preservation...');
1834 |
1835 | const testDate = new Date();
1836 | testDate.setDate(testDate.getDate() + 8);
1837 | const year = testDate.getFullYear();
1838 | const month = String(testDate.getMonth() + 1).padStart(2, '0');
1839 | const day = String(testDate.getDate()).padStart(2, '0');
1840 |
1841 | // Create event in New York time
1842 | const eventStart = `${year}-${month}-${day}T14:00:00-05:00`; // 2:00 PM EST
1843 | const eventEnd = `${year}-${month}-${day}T15:00:00-05:00`; // 3:00 PM EST
1844 |
1845 | const eventData: TestEvent = {
1846 | summary: 'Timezone-Aware Input Test Event',
1847 | start: eventStart,
1848 | end: eventEnd,
1849 | timeZone: 'America/New_York',
1850 | sendUpdates: SEND_UPDATES
1851 | };
1852 |
1853 | const eventId = await createTestEvent(eventData);
1854 | createdEventIds.push(eventId);
1855 |
1856 | const startTime = testFactory.startTimer('list-events-timezone-aware');
1857 |
1858 | try {
1859 | // Search using timezone-aware timeMin/timeMax with a different timeZone parameter
1860 | // The timezone-aware inputs should be preserved, not converted
1861 | const timeMin = `${year}-${month}-${day}T13:30:00-05:00`; // 1:30 PM EST (timezone-aware)
1862 | const timeMax = `${year}-${month}-${day}T15:30:00-05:00`; // 3:30 PM EST (timezone-aware)
1863 |
1864 | const listResult = await client.callTool({
1865 | name: 'list-events',
1866 | arguments: {
1867 | calendarId: TEST_CALENDAR_ID,
1868 | timeMin: timeMin,
1869 | timeMax: timeMax,
1870 | timeZone: 'America/Los_Angeles' // Different timezone - should be ignored
1871 | }
1872 | });
1873 |
1874 | testFactory.endTimer('list-events-timezone-aware', startTime, true);
1875 |
1876 | expect(TestDataFactory.validateEventResponse(listResult)).toBe(true);
1877 | const responseText = (listResult.content as any)[0].text;
1878 |
1879 | expect(responseText).toContain(eventId);
1880 | expect(responseText).toContain('Timezone-Aware Input Test Event');
1881 |
1882 | console.log('✅ Timezone-aware inputs preserved correctly');
1883 | } catch (error) {
1884 | testFactory.endTimer('list-events-timezone-aware', startTime, false, String(error));
1885 | throw error;
1886 | }
1887 | });
1888 | });
1889 |
1890 | describe('Enhanced Conflict Detection', () => {
1891 | describe('Smart Duplicate Detection with Simplified Algorithm', () => {
1892 | it('should detect duplicates with rules-based similarity scoring', async () => {
1893 | // Create base event with fixed time for consistent duplicate detection
1894 | const fixedStart = new Date();
1895 | fixedStart.setDate(fixedStart.getDate() + 5); // 5 days from now
1896 | fixedStart.setHours(14, 0, 0, 0); // 2 PM
1897 | const fixedEnd = new Date(fixedStart);
1898 | fixedEnd.setHours(15, 0, 0, 0); // 3 PM
1899 |
1900 | // Pre-check: Clear any existing events in this time window
1901 | const timeRangeStart = new Date(fixedStart);
1902 | timeRangeStart.setHours(0, 0, 0, 0); // Start of day
1903 | const timeRangeEnd = new Date(fixedStart);
1904 | timeRangeEnd.setHours(23, 59, 59, 999); // End of day
1905 |
1906 | const existingEventsResult = await client.callTool({
1907 | name: 'list-events',
1908 | arguments: {
1909 | calendarId: TEST_CALENDAR_ID,
1910 | timeMin: TestDataFactory.formatDateTimeRFC3339(timeRangeStart),
1911 | timeMax: TestDataFactory.formatDateTimeRFC3339(timeRangeEnd)
1912 | }
1913 | });
1914 |
1915 | // Delete any existing events found
1916 | const existingEventIds = TestDataFactory.extractAllEventIds(existingEventsResult);
1917 | if (existingEventIds.length > 0) {
1918 | console.log(`🧹 Pre-test cleanup: Removing ${existingEventIds.length} existing events from test time window`);
1919 | for (const eventId of existingEventIds) {
1920 | try {
1921 | await client.callTool({
1922 | name: 'delete-event',
1923 | arguments: {
1924 | calendarId: TEST_CALENDAR_ID,
1925 | eventId,
1926 | sendUpdates: SEND_UPDATES
1927 | }
1928 | });
1929 | } catch (error) {
1930 | // Ignore errors - event might be protected or already deleted
1931 | }
1932 | }
1933 | // Wait for deletions to propagate
1934 | await new Promise(resolve => setTimeout(resolve, 2000));
1935 | }
1936 |
1937 | const timestamp = Date.now();
1938 | const baseEvent = TestDataFactory.createSingleEvent({
1939 | summary: `Team Meeting ${timestamp}`,
1940 | location: 'Conference Room A',
1941 | start: TestDataFactory.formatDateTimeRFC3339(fixedStart),
1942 | end: TestDataFactory.formatDateTimeRFC3339(fixedEnd)
1943 | });
1944 |
1945 | const baseEventId = await createTestEvent(baseEvent);
1946 | createdEventIds.push(baseEventId);
1947 |
1948 | // Note: Google Calendar has eventual consistency - events may not immediately
1949 | // appear in list queries. This delay helps but doesn't guarantee visibility.
1950 | await new Promise(resolve => setTimeout(resolve, 3000));
1951 |
1952 | // Test 1: Exact title + overlapping time = 95% similarity (blocked)
1953 | const exactDuplicateResult = await client.callTool({
1954 | name: 'create-event',
1955 | arguments: {
1956 | calendarId: TEST_CALENDAR_ID,
1957 | ...baseEvent
1958 | }
1959 | });
1960 |
1961 | // In v2.0, exact duplicates throw an error returned as text
1962 | const exactDuplicateText = (exactDuplicateResult.content as any)[0]?.text;
1963 | expect(exactDuplicateText).toContain('Duplicate event detected');
1964 |
1965 | // Test 2: Similar title + overlapping time = 70% similarity (warning)
1966 | const similarTitleEvent = {
1967 | ...baseEvent,
1968 | summary: `Team Meeting ${timestamp} Discussion` // Contains "Team Meeting"
1969 | };
1970 |
1971 | const similarResult = await client.callTool({
1972 | name: 'create-event',
1973 | arguments: {
1974 | calendarId: TEST_CALENDAR_ID,
1975 | ...similarTitleEvent,
1976 | allowDuplicates: true // Allow creation despite warning
1977 | }
1978 | });
1979 |
1980 | const similarResponse = JSON.parse((similarResult.content as any)[0].text);
1981 | expect(similarResponse.event).toBeDefined();
1982 | expect(similarResponse.warnings).toBeDefined();
1983 | expect(similarResponse.duplicates).toBeDefined();
1984 | expect(similarResponse.duplicates.length).toBeGreaterThan(0);
1985 | if (similarResponse.duplicates[0]) {
1986 | expect(similarResponse.duplicates[0].event.similarity).toBeGreaterThanOrEqual(0.7);
1987 | }
1988 | const similarEventId = extractEventId(similarResult);
1989 | if (similarEventId) createdEventIds.push(similarEventId);
1990 |
1991 | // Test 3: Same title on same day but different time = NO DUPLICATE (different time window)
1992 | const laterTime = new Date(baseEvent.start);
1993 | laterTime.setHours(laterTime.getHours() + 3);
1994 | const laterEndTime = new Date(baseEvent.end);
1995 | laterEndTime.setHours(laterEndTime.getHours() + 3);
1996 |
1997 | const sameDayEvent = {
1998 | ...baseEvent,
1999 | start: TestDataFactory.formatDateTimeRFC3339(laterTime),
2000 | end: TestDataFactory.formatDateTimeRFC3339(laterEndTime)
2001 | };
2002 |
2003 | const sameDayResult = await client.callTool({
2004 | name: 'create-event',
2005 | arguments: {
2006 | calendarId: TEST_CALENDAR_ID,
2007 | ...sameDayEvent
2008 | }
2009 | });
2010 |
2011 | // With exact time window search, events at different times are NOT detected as duplicates
2012 | const sameDayResponse = JSON.parse((sameDayResult.content as any)[0].text);
2013 | expect(sameDayResponse.event).toBeDefined();
2014 | expect(sameDayResponse.duplicates).toBeUndefined();
2015 | expect(sameDayResponse.warnings).toBeUndefined();
2016 | const sameDayEventId = extractEventId(sameDayResult);
2017 | if (sameDayEventId) createdEventIds.push(sameDayEventId);
2018 |
2019 | // Test 4: Same title but different day = NO DUPLICATE (different time window)
2020 | const nextWeek = new Date(baseEvent.start);
2021 | nextWeek.setDate(nextWeek.getDate() + 7);
2022 | const nextWeekEnd = new Date(baseEvent.end);
2023 | nextWeekEnd.setDate(nextWeekEnd.getDate() + 7);
2024 |
2025 | const differentDayEvent = {
2026 | ...baseEvent,
2027 | start: TestDataFactory.formatDateTimeRFC3339(nextWeek),
2028 | end: TestDataFactory.formatDateTimeRFC3339(nextWeekEnd)
2029 | };
2030 |
2031 | const differentDayResult = await client.callTool({
2032 | name: 'create-event',
2033 | arguments: {
2034 | calendarId: TEST_CALENDAR_ID,
2035 | ...differentDayEvent
2036 | }
2037 | });
2038 |
2039 | // With exact time window search, events on different days are NOT detected as duplicates
2040 | const differentDayResponse = JSON.parse((differentDayResult.content as any)[0].text);
2041 | expect(differentDayResponse.event).toBeDefined();
2042 | expect(differentDayResponse.duplicates).toBeUndefined();
2043 | const differentDayEventId = extractEventId(differentDayResult);
2044 | if (differentDayEventId) createdEventIds.push(differentDayEventId);
2045 | });
2046 |
2047 | });
2048 |
2049 | describe('Adjacent Event Handling (No False Positives)', () => {
2050 | it('should not flag back-to-back meetings as conflicts', async () => {
2051 | const baseDate = new Date();
2052 | baseDate.setDate(baseDate.getDate() + 7); // 7 days from now
2053 | baseDate.setHours(9, 0, 0, 0);
2054 |
2055 | // Pre-check: Clear any existing events in this time window
2056 | const timeRangeStart = new Date(baseDate);
2057 | timeRangeStart.setHours(0, 0, 0, 0); // Start of day
2058 | const timeRangeEnd = new Date(baseDate);
2059 | timeRangeEnd.setHours(23, 59, 59, 999); // End of day
2060 |
2061 | const existingEventsResult = await client.callTool({
2062 | name: 'list-events',
2063 | arguments: {
2064 | calendarId: TEST_CALENDAR_ID,
2065 | timeMin: TestDataFactory.formatDateTimeRFC3339(timeRangeStart),
2066 | timeMax: TestDataFactory.formatDateTimeRFC3339(timeRangeEnd)
2067 | }
2068 | });
2069 |
2070 | // Delete any existing events found
2071 | const existingEventIds = TestDataFactory.extractAllEventIds(existingEventsResult);
2072 | if (existingEventIds.length > 0) {
2073 | console.log(`🧹 Pre-test cleanup: Removing ${existingEventIds.length} existing events from test time window`);
2074 | for (const eventId of existingEventIds) {
2075 | try {
2076 | await client.callTool({
2077 | name: 'delete-event',
2078 | arguments: {
2079 | calendarId: TEST_CALENDAR_ID,
2080 | eventId,
2081 | sendUpdates: SEND_UPDATES
2082 | }
2083 | });
2084 | } catch (error) {
2085 | // Ignore errors - event might be protected or already deleted
2086 | }
2087 | }
2088 | // Wait for deletions to propagate
2089 | await new Promise(resolve => setTimeout(resolve, 2000));
2090 | }
2091 |
2092 | // Create first meeting 9-10am
2093 | const timestamp = Date.now();
2094 | const firstStart = new Date(baseDate);
2095 | const firstEnd = new Date(firstStart);
2096 | firstEnd.setHours(10, 0, 0, 0);
2097 |
2098 | const firstMeeting = TestDataFactory.createSingleEvent({
2099 | summary: `Morning Standup ${timestamp}`,
2100 | description: 'Daily team sync',
2101 | location: 'Room A',
2102 | start: TestDataFactory.formatDateTimeRFC3339(firstStart),
2103 | end: TestDataFactory.formatDateTimeRFC3339(firstEnd)
2104 | });
2105 |
2106 | const firstId = await createTestEvent(firstMeeting);
2107 | createdEventIds.push(firstId);
2108 |
2109 | // Note: Google Calendar has eventual consistency - events may not immediately
2110 | // appear in list queries. This delay helps but doesn't guarantee visibility.
2111 | await new Promise(resolve => setTimeout(resolve, 3000));
2112 |
2113 | // Create second meeting 10-11am (immediately after)
2114 | const secondStart = new Date(baseDate);
2115 | secondStart.setHours(10, 0, 0, 0);
2116 | const secondEnd = new Date(secondStart);
2117 | secondEnd.setHours(11, 0, 0, 0);
2118 |
2119 | const secondMeeting = TestDataFactory.createSingleEvent({
2120 | summary: `Project Review ${timestamp}`,
2121 | description: 'Weekly project status update',
2122 | location: 'Room B',
2123 | start: TestDataFactory.formatDateTimeRFC3339(secondStart),
2124 | end: TestDataFactory.formatDateTimeRFC3339(secondEnd)
2125 | });
2126 |
2127 | const result = await client.callTool({
2128 | name: 'create-event',
2129 | arguments: {
2130 | calendarId: TEST_CALENDAR_ID,
2131 | ...secondMeeting
2132 | }
2133 | });
2134 |
2135 | // Should not show conflict warning for adjacent events
2136 | const resultResponse = JSON.parse((result.content as any)[0].text);
2137 | expect(resultResponse.event).toBeDefined();
2138 | expect(resultResponse.conflicts).toBeUndefined();
2139 | expect(resultResponse.warnings).toBeUndefined();
2140 | const secondId = extractEventId(result);
2141 | if (secondId) createdEventIds.push(secondId);
2142 |
2143 | // Create third meeting 10:30-11:30am (overlaps with second)
2144 | const thirdStart = new Date(baseDate);
2145 | thirdStart.setHours(10, 30, 0, 0); // 10:30 AM
2146 | const thirdEnd = new Date(thirdStart);
2147 | thirdEnd.setHours(11, 30, 0, 0); // 11:30 AM
2148 |
2149 | const thirdMeeting = TestDataFactory.createSingleEvent({
2150 | summary: 'Design Discussion',
2151 | description: 'UI/UX design review',
2152 | location: 'Design Lab',
2153 | start: TestDataFactory.formatDateTimeRFC3339(thirdStart),
2154 | end: TestDataFactory.formatDateTimeRFC3339(thirdEnd)
2155 | });
2156 |
2157 | const conflictResult = await client.callTool({
2158 | name: 'create-event',
2159 | arguments: {
2160 | calendarId: TEST_CALENDAR_ID,
2161 | ...thirdMeeting
2162 | }
2163 | });
2164 |
2165 | // Should show conflict for actual overlap
2166 | const conflictResponse = JSON.parse((conflictResult.content as any)[0].text);
2167 | expect(conflictResponse.event).toBeDefined();
2168 | expect(conflictResponse.warnings).toBeDefined();
2169 | expect(conflictResponse.conflicts).toBeDefined();
2170 | expect(conflictResponse.conflicts.length).toBeGreaterThan(0);
2171 | if (conflictResponse.conflicts[0]) {
2172 | expect(conflictResponse.conflicts[0].overlap?.duration).toContain('30 minute');
2173 | expect(conflictResponse.conflicts[0].overlap?.percentage).toContain('50%');
2174 | }
2175 | const thirdId = extractEventId(conflictResult);
2176 | if (thirdId) createdEventIds.push(thirdId);
2177 | });
2178 | });
2179 |
2180 | describe('Unified Threshold Configuration', () => {
2181 | it('should use configurable duplicate detection threshold', async () => {
2182 | // Use fixed time for consistent testing
2183 | const fixedStart = new Date();
2184 | fixedStart.setDate(fixedStart.getDate() + 8); // 8 days from now
2185 | fixedStart.setHours(10, 0, 0, 0); // 10 AM
2186 | const fixedEnd = new Date(fixedStart);
2187 | fixedEnd.setHours(11, 0, 0, 0); // 11 AM
2188 |
2189 | // Pre-check: Clear any existing events in this time window
2190 | const timeRangeStart = new Date(fixedStart);
2191 | timeRangeStart.setHours(0, 0, 0, 0); // Start of day
2192 | const timeRangeEnd = new Date(fixedStart);
2193 | timeRangeEnd.setHours(23, 59, 59, 999); // End of day
2194 |
2195 | const existingEventsResult = await client.callTool({
2196 | name: 'list-events',
2197 | arguments: {
2198 | calendarId: TEST_CALENDAR_ID,
2199 | timeMin: TestDataFactory.formatDateTimeRFC3339(timeRangeStart),
2200 | timeMax: TestDataFactory.formatDateTimeRFC3339(timeRangeEnd)
2201 | }
2202 | });
2203 |
2204 | // Delete any existing events found
2205 | const existingEventIds = TestDataFactory.extractAllEventIds(existingEventsResult);
2206 | if (existingEventIds.length > 0) {
2207 | console.log(`🧹 Pre-test cleanup: Removing ${existingEventIds.length} existing events from test time window`);
2208 | for (const eventId of existingEventIds) {
2209 | try {
2210 | await client.callTool({
2211 | name: 'delete-event',
2212 | arguments: {
2213 | calendarId: TEST_CALENDAR_ID,
2214 | eventId,
2215 | sendUpdates: SEND_UPDATES
2216 | }
2217 | });
2218 | } catch (error) {
2219 | // Ignore errors - event might be protected or already deleted
2220 | }
2221 | }
2222 | // Wait for deletions to propagate
2223 | await new Promise(resolve => setTimeout(resolve, 2000));
2224 | }
2225 |
2226 | const timestamp = Date.now();
2227 | const baseEvent = TestDataFactory.createSingleEvent({
2228 | summary: `Quarterly Planning ${timestamp}`,
2229 | start: TestDataFactory.formatDateTimeRFC3339(fixedStart),
2230 | end: TestDataFactory.formatDateTimeRFC3339(fixedEnd)
2231 | });
2232 |
2233 | const baseId = await createTestEvent(baseEvent);
2234 | createdEventIds.push(baseId);
2235 |
2236 | // Note: Google Calendar has eventual consistency - events may not immediately
2237 | // appear in list queries. This delay helps but doesn't guarantee visibility.
2238 | await new Promise(resolve => setTimeout(resolve, 3000));
2239 |
2240 | // Test with custom threshold of 0.5 for similar title at same time
2241 | const similarEvent = {
2242 | ...baseEvent,
2243 | summary: `Quarterly Planning ${timestamp} Meeting` // Similar but not identical title
2244 | };
2245 |
2246 | const lowThresholdResult = await client.callTool({
2247 | name: 'create-event',
2248 | arguments: {
2249 | calendarId: TEST_CALENDAR_ID,
2250 | ...similarEvent,
2251 | duplicateSimilarityThreshold: 0.5,
2252 | allowDuplicates: true // Allow creation despite warning
2253 | }
2254 | });
2255 |
2256 | // Track for cleanup immediately after creation
2257 | const lowThresholdId = extractEventId(lowThresholdResult);
2258 | if (lowThresholdId) createdEventIds.push(lowThresholdId);
2259 |
2260 | // Should show warning since similarity > 50% threshold
2261 | const lowThresholdResponse = JSON.parse((lowThresholdResult.content as any)[0].text);
2262 | expect(lowThresholdResponse.event).toBeDefined();
2263 | expect(lowThresholdResponse.duplicates).toBeDefined();
2264 | expect(lowThresholdResponse.duplicates.length).toBeGreaterThan(0);
2265 |
2266 | // Test with high threshold of 0.9 (should not flag ~70% similarity)
2267 | const slightlyDifferentEvent = {
2268 | ...baseEvent,
2269 | summary: 'Q4 Planning' // Different enough title to be below 90% threshold
2270 | };
2271 |
2272 | const highThresholdResult = await client.callTool({
2273 | name: 'create-event',
2274 | arguments: {
2275 | calendarId: TEST_CALENDAR_ID,
2276 | ...slightlyDifferentEvent,
2277 | duplicateSimilarityThreshold: 0.9
2278 | }
2279 | });
2280 |
2281 | // Track for cleanup immediately after creation
2282 | const highThresholdId = extractEventId(highThresholdResult);
2283 | if (highThresholdId) createdEventIds.push(highThresholdId);
2284 |
2285 | // Should not show DUPLICATE warning since similarity < 90% threshold
2286 | // Note: May show conflict warning if events overlap in time
2287 | const highThresholdResponse = JSON.parse((highThresholdResult.content as any)[0].text);
2288 | expect(highThresholdResponse.event).toBeDefined();
2289 | expect(highThresholdResponse.duplicates).toBeUndefined();
2290 | });
2291 |
2292 | it('should allow exact duplicates with allowDuplicates flag', async () => {
2293 | // Use fixed time for exact duplicate
2294 | const fixedStart = new Date();
2295 | fixedStart.setDate(fixedStart.getDate() + 9); // 9 days from now
2296 | fixedStart.setHours(15, 0, 0, 0); // 3 PM
2297 | const fixedEnd = new Date(fixedStart);
2298 | fixedEnd.setHours(16, 0, 0, 0); // 4 PM
2299 |
2300 | // Pre-check: Clear any existing events in this time window
2301 | const timeRangeStart = new Date(fixedStart);
2302 | timeRangeStart.setHours(0, 0, 0, 0); // Start of day
2303 | const timeRangeEnd = new Date(fixedStart);
2304 | timeRangeEnd.setHours(23, 59, 59, 999); // End of day
2305 |
2306 | const existingEventsResult = await client.callTool({
2307 | name: 'list-events',
2308 | arguments: {
2309 | calendarId: TEST_CALENDAR_ID,
2310 | timeMin: TestDataFactory.formatDateTimeRFC3339(timeRangeStart),
2311 | timeMax: TestDataFactory.formatDateTimeRFC3339(timeRangeEnd)
2312 | }
2313 | });
2314 |
2315 | // Delete any existing events found
2316 | const existingEventIds = TestDataFactory.extractAllEventIds(existingEventsResult);
2317 | if (existingEventIds.length > 0) {
2318 | console.log(`🧹 Pre-test cleanup: Removing ${existingEventIds.length} existing events from test time window`);
2319 | for (const eventId of existingEventIds) {
2320 | try {
2321 | await client.callTool({
2322 | name: 'delete-event',
2323 | arguments: {
2324 | calendarId: TEST_CALENDAR_ID,
2325 | eventId,
2326 | sendUpdates: SEND_UPDATES
2327 | }
2328 | });
2329 | } catch (error) {
2330 | // Ignore errors - event might be protected or already deleted
2331 | }
2332 | }
2333 | // Wait for deletions to propagate
2334 | await new Promise(resolve => setTimeout(resolve, 2000));
2335 | }
2336 |
2337 | const event = TestDataFactory.createSingleEvent({
2338 | summary: `Important Presentation ${Date.now()}`,
2339 | start: TestDataFactory.formatDateTimeRFC3339(fixedStart),
2340 | end: TestDataFactory.formatDateTimeRFC3339(fixedEnd)
2341 | });
2342 |
2343 | const firstId = await createTestEvent(event);
2344 | createdEventIds.push(firstId);
2345 |
2346 | // Note: Google Calendar has eventual consistency - events may not immediately
2347 | // appear in list queries. This delay helps but doesn't guarantee visibility.
2348 | await new Promise(resolve => setTimeout(resolve, 3000));
2349 |
2350 | // Try to create exact duplicate with allowDuplicates=true
2351 | const duplicateResult = await client.callTool({
2352 | name: 'create-event',
2353 | arguments: {
2354 | calendarId: TEST_CALENDAR_ID,
2355 | ...event,
2356 | allowDuplicates: true
2357 | }
2358 | });
2359 |
2360 | // Should create with warning but not block
2361 | const duplicateResponse = JSON.parse((duplicateResult.content as any)[0].text);
2362 | expect(duplicateResponse.event).toBeDefined();
2363 | expect(duplicateResponse.warnings).toBeDefined();
2364 | expect(duplicateResponse.duplicates).toBeDefined();
2365 | expect(duplicateResponse.duplicates.length).toBeGreaterThan(0);
2366 | expect(duplicateResponse.duplicates[0].event.similarity).toBeGreaterThan(0.6); // Similarity may vary due to timestamps
2367 | const duplicateId = extractEventId(duplicateResult);
2368 | if (duplicateId) createdEventIds.push(duplicateId);
2369 | });
2370 | });
2371 |
2372 | describe('Conflict Detection Performance', () => {
2373 | it('should detect conflicts for overlapping events', async () => {
2374 | // Create multiple events for conflict checking
2375 | const baseTime = new Date();
2376 | baseTime.setDate(baseTime.getDate() + 10); // 10 days from now
2377 | baseTime.setHours(14, 0, 0, 0); // 2 PM
2378 |
2379 | // Pre-check: Clear any existing events in this time window
2380 | const timeRangeStart = new Date(baseTime);
2381 | timeRangeStart.setHours(0, 0, 0, 0); // Start of day
2382 | const timeRangeEnd = new Date(baseTime);
2383 | timeRangeEnd.setHours(23, 59, 59, 999); // End of day
2384 |
2385 | const existingEventsResult = await client.callTool({
2386 | name: 'list-events',
2387 | arguments: {
2388 | calendarId: TEST_CALENDAR_ID,
2389 | timeMin: TestDataFactory.formatDateTimeRFC3339(timeRangeStart),
2390 | timeMax: TestDataFactory.formatDateTimeRFC3339(timeRangeEnd)
2391 | }
2392 | });
2393 |
2394 | // Delete any existing events found
2395 | const existingEventIds = TestDataFactory.extractAllEventIds(existingEventsResult);
2396 | if (existingEventIds.length > 0) {
2397 | console.log(`🧹 Pre-test cleanup: Removing ${existingEventIds.length} existing events from test time window`);
2398 | for (const eventId of existingEventIds) {
2399 | try {
2400 | await client.callTool({
2401 | name: 'delete-event',
2402 | arguments: {
2403 | calendarId: TEST_CALENDAR_ID,
2404 | eventId,
2405 | sendUpdates: SEND_UPDATES
2406 | }
2407 | });
2408 | } catch (error) {
2409 | // Ignore errors - event might be protected or already deleted
2410 | }
2411 | }
2412 | // Wait for deletions to propagate
2413 | await new Promise(resolve => setTimeout(resolve, 2000));
2414 | }
2415 |
2416 | const events = [];
2417 | for (let i = 0; i < 3; i++) {
2418 | const startTime = new Date(baseTime.getTime() + i * 2 * 60 * 60 * 1000);
2419 | const event = TestDataFactory.createSingleEvent({
2420 | summary: `Cache Test Event ${i + 1} ${Date.now()}`,
2421 | start: TestDataFactory.formatDateTimeRFC3339(startTime),
2422 | end: TestDataFactory.formatDateTimeRFC3339(new Date(startTime.getTime() + 60 * 60 * 1000))
2423 | });
2424 | const id = await createTestEvent(event);
2425 | createdEventIds.push(id);
2426 | events.push(event);
2427 | }
2428 |
2429 | // Longer delay to ensure events are indexed in Google Calendar
2430 | await new Promise(resolve => setTimeout(resolve, 3000));
2431 |
2432 | // First conflict check
2433 | const overlappingEvent = TestDataFactory.createSingleEvent({
2434 | summary: 'Overlapping Meeting',
2435 | start: events[1].start, // Same time as second event
2436 | end: events[1].end
2437 | });
2438 |
2439 | const result1 = await client.callTool({
2440 | name: 'create-event',
2441 | arguments: {
2442 | calendarId: TEST_CALENDAR_ID,
2443 | ...overlappingEvent,
2444 | allowDuplicates: true
2445 | }
2446 | });
2447 |
2448 | // Should detect a conflict (100% overlap)
2449 | const responseText = (result1.content as any)[0].text;
2450 | const response1 = JSON.parse(responseText);
2451 | expect(response1.conflicts).toBeDefined();
2452 | expect(response1.conflicts.length).toBeGreaterThan(0);
2453 | expect(response1.conflicts[0].overlap.percentage).toBe('100%');
2454 | const overlappingId = response1.event?.id;
2455 | if (overlappingId) createdEventIds.push(overlappingId);
2456 |
2457 | // Second conflict check with different event
2458 | const anotherOverlapping = TestDataFactory.createSingleEvent({
2459 | summary: 'Another Overlapping Meeting',
2460 | start: events[1].start,
2461 | end: events[1].end
2462 | });
2463 |
2464 | const result2 = await client.callTool({
2465 | name: 'create-event',
2466 | arguments: {
2467 | calendarId: TEST_CALENDAR_ID,
2468 | ...anotherOverlapping,
2469 | allowDuplicates: true
2470 | }
2471 | });
2472 |
2473 | // Should also detect a conflict
2474 | const responseText2 = (result2.content as any)[0].text;
2475 | const response2 = JSON.parse(responseText2);
2476 | expect(response2.conflicts).toBeDefined();
2477 | expect(response2.conflicts.length).toBeGreaterThan(0);
2478 | // Check that at least one conflict has 100% overlap
2479 | const has100PercentOverlap = response2.conflicts.some((c: any) =>
2480 | c.overlap && c.overlap.percentage === '100%'
2481 | );
2482 | expect(has100PercentOverlap).toBe(true);
2483 | const anotherId = extractEventId(result2);
2484 | if (anotherId) createdEventIds.push(anotherId);
2485 | });
2486 | });
2487 | });
2488 |
2489 | describe('Performance Benchmarks', () => {
2490 | it('should complete basic operations within reasonable time limits', async () => {
2491 | // Create a test event for performance testing
2492 | const eventData = TestDataFactory.createSingleEvent({
2493 | summary: `Performance Test Event ${Date.now()}`
2494 | });
2495 |
2496 | const eventId = await createTestEvent(eventData);
2497 | createdEventIds.push(eventId);
2498 |
2499 | // Test various operations and collect metrics
2500 | const timeRanges = TestDataFactory.getTimeRanges();
2501 |
2502 | await verifyEventInList(eventId, timeRanges.nextWeek);
2503 | await verifyEventInSearch(eventData.summary);
2504 |
2505 | // Get all performance metrics
2506 | const metrics = testFactory.getPerformanceMetrics();
2507 |
2508 | // Log performance results
2509 | console.log('\n📊 Performance Metrics:');
2510 | metrics.forEach(metric => {
2511 | console.log(` ${metric.operation}: ${metric.duration}ms (${metric.success ? '✅' : '❌'})`);
2512 | });
2513 |
2514 | // Basic performance assertions
2515 | const createMetric = metrics.find(m => m.operation === 'create-event');
2516 | const listMetric = metrics.find(m => m.operation === 'list-events');
2517 | const searchMetric = metrics.find(m => m.operation === 'search-events');
2518 |
2519 | expect(createMetric?.success).toBe(true);
2520 | expect(listMetric?.success).toBe(true);
2521 | expect(searchMetric?.success).toBe(true);
2522 |
2523 | // All operations should complete within 30 seconds
2524 | metrics.forEach(metric => {
2525 | expect(metric.duration).toBeLessThan(30000);
2526 | });
2527 | });
2528 | });
2529 |
2530 | // Helper Functions
2531 | function extractEventId(result: any): string | null {
2532 | try {
2533 | const text = (result.content as any)[0]?.text;
2534 | if (!text) return null;
2535 |
2536 | const response = JSON.parse(text);
2537 | return response.event?.id || null;
2538 | } catch {
2539 | return null;
2540 | }
2541 | }
2542 |
2543 | async function createTestEvent(eventData: TestEvent, allowDuplicates: boolean = true): Promise<string> {
2544 | const startTime = testFactory.startTimer('create-event');
2545 |
2546 | try {
2547 | const result = await client.callTool({
2548 | name: 'create-event',
2549 | arguments: {
2550 | calendarId: TEST_CALENDAR_ID,
2551 | ...eventData,
2552 | allowDuplicates
2553 | }
2554 | });
2555 |
2556 | testFactory.endTimer('create-event', startTime, true);
2557 |
2558 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
2559 |
2560 | // Handle structured JSON response
2561 | const text = (result.content as any)[0]?.text;
2562 | if (!text) throw new Error('No response text');
2563 |
2564 | // Check if it's an error message (not JSON)
2565 | if (text.includes('Duplicate event detected') || text.includes('Error:')) {
2566 | throw new Error(text);
2567 | }
2568 |
2569 | const response = JSON.parse(text);
2570 | const eventId = response.event?.id;
2571 |
2572 | expect(eventId).toBeTruthy();
2573 |
2574 | testFactory.addCreatedEventId(eventId);
2575 |
2576 | return eventId;
2577 | } catch (error) {
2578 | testFactory.endTimer('create-event', startTime, false, String(error));
2579 | throw error;
2580 | }
2581 | }
2582 |
2583 | async function verifyEventInList(eventId: string, timeRange: { timeMin: string; timeMax: string }): Promise<void> {
2584 | const startTime = testFactory.startTimer('list-events');
2585 |
2586 | try {
2587 | const result = await client.callTool({
2588 | name: 'list-events',
2589 | arguments: {
2590 | calendarId: TEST_CALENDAR_ID,
2591 | timeMin: timeRange.timeMin,
2592 | timeMax: timeRange.timeMax
2593 | }
2594 | });
2595 |
2596 | testFactory.endTimer('list-events', startTime, true);
2597 |
2598 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
2599 |
2600 | // Handle structured JSON response
2601 | const text = (result.content as any)[0]?.text;
2602 | const response = JSON.parse(text);
2603 |
2604 | // Check if the event ID is in the list
2605 | const eventIds = response.events?.map((e: any) => e.id) || [];
2606 | expect(eventIds).toContain(eventId);
2607 | } catch (error) {
2608 | testFactory.endTimer('list-events', startTime, false, String(error));
2609 | throw error;
2610 | }
2611 | }
2612 |
2613 | async function verifyEventInSearch(query: string): Promise<void> {
2614 | // Add small delay to allow Google Calendar search index to update
2615 | await new Promise(resolve => setTimeout(resolve, 1000));
2616 |
2617 | const startTime = testFactory.startTimer('search-events');
2618 |
2619 | try {
2620 | const timeRanges = TestDataFactory.getTimeRanges();
2621 | const result = await client.callTool({
2622 | name: 'search-events',
2623 | arguments: {
2624 | calendarId: TEST_CALENDAR_ID,
2625 | query,
2626 | timeMin: timeRanges.nextWeek.timeMin,
2627 | timeMax: timeRanges.nextWeek.timeMax
2628 | }
2629 | });
2630 |
2631 | testFactory.endTimer('search-events', startTime, true);
2632 |
2633 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
2634 |
2635 | // Handle structured JSON response
2636 | const text = (result.content as any)[0]?.text;
2637 | const response = JSON.parse(text);
2638 |
2639 | // Check if query matches any event in results
2640 | const hasMatch = response.events?.some((e: any) =>
2641 | e.summary?.toLowerCase().includes(query.toLowerCase()) ||
2642 | e.description?.toLowerCase().includes(query.toLowerCase())
2643 | );
2644 | expect(hasMatch).toBe(true);
2645 | } catch (error) {
2646 | testFactory.endTimer('search-events', startTime, false, String(error));
2647 | throw error;
2648 | }
2649 | }
2650 |
2651 | async function updateTestEvent(eventId: string, updates: Partial<TestEvent>): Promise<void> {
2652 | const startTime = testFactory.startTimer('update-event');
2653 |
2654 | try {
2655 | const result = await client.callTool({
2656 | name: 'update-event',
2657 | arguments: {
2658 | calendarId: TEST_CALENDAR_ID,
2659 | eventId,
2660 | ...updates,
2661 | timeZone: updates.timeZone || 'America/Los_Angeles',
2662 | sendUpdates: SEND_UPDATES
2663 | }
2664 | });
2665 |
2666 | testFactory.endTimer('update-event', startTime, true);
2667 |
2668 | expect(TestDataFactory.validateEventResponse(result)).toBe(true);
2669 | } catch (error) {
2670 | testFactory.endTimer('update-event', startTime, false, String(error));
2671 | throw error;
2672 | }
2673 | }
2674 |
2675 | async function testRecurringEventUpdates(eventId: string): Promise<void> {
2676 | // Test updating all instances
2677 | await updateTestEvent(eventId, {
2678 | summary: 'Updated Recurring Meeting - All Instances'
2679 | });
2680 |
2681 | // Verify the update
2682 | await verifyEventInSearch('Recurring');
2683 | }
2684 |
2685 | async function cleanupTestEvents(eventIds: string[]): Promise<void> {
2686 | const cleanupResults = { success: 0, failed: 0 };
2687 |
2688 | for (const eventId of eventIds) {
2689 | try {
2690 | const deleteStartTime = testFactory.startTimer('delete-event');
2691 |
2692 | await client.callTool({
2693 | name: 'delete-event',
2694 | arguments: {
2695 | calendarId: TEST_CALENDAR_ID,
2696 | eventId,
2697 | sendUpdates: SEND_UPDATES
2698 | }
2699 | });
2700 |
2701 | testFactory.endTimer('delete-event', deleteStartTime, true);
2702 | cleanupResults.success++;
2703 | } catch (error: any) {
2704 | const deleteStartTime = testFactory.startTimer('delete-event');
2705 | testFactory.endTimer('delete-event', deleteStartTime, false, String(error));
2706 |
2707 | // Only warn for non-404 errors (404 means event was already deleted)
2708 | const errorMessage = String(error);
2709 | if (!errorMessage.includes('404') && !errorMessage.includes('Not Found')) {
2710 | console.warn(`⚠️ Failed to cleanup event ${eventId}:`, errorMessage);
2711 | }
2712 | cleanupResults.failed++;
2713 | }
2714 | }
2715 |
2716 | if (cleanupResults.success > 0) {
2717 | console.log(`✅ Successfully deleted ${cleanupResults.success} test event(s)`);
2718 | }
2719 | if (cleanupResults.failed > 0 && cleanupResults.failed !== eventIds.length) {
2720 | console.log(`⚠️ Failed to delete ${cleanupResults.failed} test event(s) (may have been already deleted)`);
2721 | }
2722 | }
2723 |
2724 | async function cleanupAllTestEvents(): Promise<void> {
2725 | const allEventIds = testFactory.getCreatedEventIds();
2726 | await cleanupTestEvents(allEventIds);
2727 | testFactory.clearCreatedEventIds();
2728 | }
2729 |
2730 | function logPerformanceSummary(): void {
2731 | const metrics = testFactory.getPerformanceMetrics();
2732 | if (metrics.length === 0) return;
2733 |
2734 | console.log('\n📈 Final Performance Summary:');
2735 |
2736 | const byOperation = metrics.reduce((acc, metric) => {
2737 | if (!acc[metric.operation]) {
2738 | acc[metric.operation] = {
2739 | count: 0,
2740 | totalDuration: 0,
2741 | successCount: 0,
2742 | errors: []
2743 | };
2744 | }
2745 |
2746 | acc[metric.operation].count++;
2747 | acc[metric.operation].totalDuration += metric.duration;
2748 | if (metric.success) {
2749 | acc[metric.operation].successCount++;
2750 | } else if (metric.error) {
2751 | acc[metric.operation].errors.push(metric.error);
2752 | }
2753 |
2754 | return acc;
2755 | }, {} as Record<string, { count: number; totalDuration: number; successCount: number; errors: string[] }>);
2756 |
2757 | Object.entries(byOperation).forEach(([operation, stats]) => {
2758 | const avgDuration = Math.round(stats.totalDuration / stats.count);
2759 | const successRate = Math.round((stats.successCount / stats.count) * 100);
2760 |
2761 | console.log(` ${operation}:`);
2762 | console.log(` Calls: ${stats.count}`);
2763 | console.log(` Avg Duration: ${avgDuration}ms`);
2764 | console.log(` Success Rate: ${successRate}%`);
2765 |
2766 | if (stats.errors.length > 0) {
2767 | console.log(` Errors: ${stats.errors.length}`);
2768 | }
2769 | });
2770 | }
2771 | });
2772 |
```