This is page 3 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/unit/schemas/enhanced-properties.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from 'vitest';
2 | import { ToolSchemas } from '../../../tools/registry.js';
3 |
4 | describe('Enhanced Create-Event Properties', () => {
5 | const createEventSchema = ToolSchemas['create-event'];
6 |
7 | const baseEvent = {
8 | calendarId: 'primary',
9 | summary: 'Test Event',
10 | start: '2025-01-20T10:00:00',
11 | end: '2025-01-20T11:00:00'
12 | };
13 |
14 | describe('Guest Management Properties', () => {
15 | it('should accept transparency values', () => {
16 | expect(() => createEventSchema.parse({
17 | ...baseEvent,
18 | transparency: 'opaque'
19 | })).not.toThrow();
20 |
21 | expect(() => createEventSchema.parse({
22 | ...baseEvent,
23 | transparency: 'transparent'
24 | })).not.toThrow();
25 | });
26 |
27 | it('should reject invalid transparency values', () => {
28 | expect(() => createEventSchema.parse({
29 | ...baseEvent,
30 | transparency: 'invalid'
31 | })).toThrow();
32 | });
33 |
34 | it('should accept visibility values', () => {
35 | const validVisibilities = ['default', 'public', 'private', 'confidential'];
36 | validVisibilities.forEach(visibility => {
37 | expect(() => createEventSchema.parse({
38 | ...baseEvent,
39 | visibility
40 | })).not.toThrow();
41 | });
42 | });
43 |
44 | it('should accept guest permission booleans', () => {
45 | const event = {
46 | ...baseEvent,
47 | guestsCanInviteOthers: false,
48 | guestsCanModify: true,
49 | guestsCanSeeOtherGuests: false,
50 | anyoneCanAddSelf: true
51 | };
52 | expect(() => createEventSchema.parse(event)).not.toThrow();
53 | });
54 |
55 | it('should accept sendUpdates values', () => {
56 | const validSendUpdates = ['all', 'externalOnly', 'none'];
57 | validSendUpdates.forEach(sendUpdates => {
58 | expect(() => createEventSchema.parse({
59 | ...baseEvent,
60 | sendUpdates
61 | })).not.toThrow();
62 | });
63 | });
64 | });
65 |
66 | describe('Conference Data', () => {
67 | it('should accept valid conference data', () => {
68 | const event = {
69 | ...baseEvent,
70 | conferenceData: {
71 | createRequest: {
72 | requestId: 'unique-123',
73 | conferenceSolutionKey: {
74 | type: 'hangoutsMeet'
75 | }
76 | }
77 | }
78 | };
79 | expect(() => createEventSchema.parse(event)).not.toThrow();
80 | });
81 |
82 | it('should accept all conference solution types', () => {
83 | const types = ['hangoutsMeet', 'eventHangout', 'eventNamedHangout', 'addOn'];
84 | types.forEach(type => {
85 | const event = {
86 | ...baseEvent,
87 | conferenceData: {
88 | createRequest: {
89 | requestId: `req-${type}`,
90 | conferenceSolutionKey: { type }
91 | }
92 | }
93 | };
94 | expect(() => createEventSchema.parse(event)).not.toThrow();
95 | });
96 | });
97 |
98 | it('should reject conference data without required fields', () => {
99 | expect(() => createEventSchema.parse({
100 | ...baseEvent,
101 | conferenceData: {
102 | createRequest: {
103 | requestId: 'test'
104 | // Missing conferenceSolutionKey
105 | }
106 | }
107 | })).toThrow();
108 | });
109 | });
110 |
111 | describe('Extended Properties', () => {
112 | it('should accept extended properties', () => {
113 | const event = {
114 | ...baseEvent,
115 | extendedProperties: {
116 | private: {
117 | key1: 'value1',
118 | key2: 'value2'
119 | },
120 | shared: {
121 | sharedKey: 'sharedValue'
122 | }
123 | }
124 | };
125 | expect(() => createEventSchema.parse(event)).not.toThrow();
126 | });
127 |
128 | it('should accept only private properties', () => {
129 | const event = {
130 | ...baseEvent,
131 | extendedProperties: {
132 | private: { app: 'myapp' }
133 | }
134 | };
135 | expect(() => createEventSchema.parse(event)).not.toThrow();
136 | });
137 |
138 | it('should accept only shared properties', () => {
139 | const event = {
140 | ...baseEvent,
141 | extendedProperties: {
142 | shared: { category: 'meeting' }
143 | }
144 | };
145 | expect(() => createEventSchema.parse(event)).not.toThrow();
146 | });
147 |
148 | it('should accept empty extended properties object', () => {
149 | const event = {
150 | ...baseEvent,
151 | extendedProperties: {}
152 | };
153 | expect(() => createEventSchema.parse(event)).not.toThrow();
154 | });
155 | });
156 |
157 | describe('Attachments', () => {
158 | it('should accept attachments array', () => {
159 | const event = {
160 | ...baseEvent,
161 | attachments: [
162 | {
163 | fileUrl: 'https://example.com/file.pdf',
164 | title: 'Document',
165 | mimeType: 'application/pdf',
166 | iconLink: 'https://example.com/icon.png',
167 | fileId: 'file123'
168 | }
169 | ]
170 | };
171 | expect(() => createEventSchema.parse(event)).not.toThrow();
172 | });
173 |
174 | it('should accept minimal attachment (only fileUrl)', () => {
175 | const event = {
176 | ...baseEvent,
177 | attachments: [
178 | { fileUrl: 'https://example.com/file.pdf' }
179 | ]
180 | };
181 | expect(() => createEventSchema.parse(event)).not.toThrow();
182 | });
183 |
184 | it('should accept multiple attachments', () => {
185 | const event = {
186 | ...baseEvent,
187 | attachments: [
188 | { fileUrl: 'https://example.com/file1.pdf' },
189 | { fileUrl: 'https://example.com/file2.doc', title: 'Doc' },
190 | { fileUrl: 'https://example.com/file3.xls', mimeType: 'application/excel' }
191 | ]
192 | };
193 | expect(() => createEventSchema.parse(event)).not.toThrow();
194 | });
195 |
196 | it('should reject attachments without fileUrl', () => {
197 | expect(() => createEventSchema.parse({
198 | ...baseEvent,
199 | attachments: [
200 | { title: 'Document' } // Missing fileUrl
201 | ]
202 | })).toThrow();
203 | });
204 | });
205 |
206 | describe('Enhanced Attendees', () => {
207 | it('should accept attendees with all optional fields', () => {
208 | const event = {
209 | ...baseEvent,
210 | attendees: [
211 | {
212 | email: '[email protected]',
213 | displayName: 'Test User',
214 | optional: true,
215 | responseStatus: 'accepted',
216 | comment: 'Looking forward to it',
217 | additionalGuests: 2
218 | }
219 | ]
220 | };
221 | expect(() => createEventSchema.parse(event)).not.toThrow();
222 | });
223 |
224 | it('should accept all response status values', () => {
225 | const statuses = ['needsAction', 'declined', 'tentative', 'accepted'];
226 | statuses.forEach(responseStatus => {
227 | const event = {
228 | ...baseEvent,
229 | attendees: [
230 | { email: '[email protected]', responseStatus }
231 | ]
232 | };
233 | expect(() => createEventSchema.parse(event)).not.toThrow();
234 | });
235 | });
236 |
237 | it('should accept attendees with only email', () => {
238 | const event = {
239 | ...baseEvent,
240 | attendees: [
241 | { email: '[email protected]' }
242 | ]
243 | };
244 | expect(() => createEventSchema.parse(event)).not.toThrow();
245 | });
246 |
247 | it('should reject attendees without email', () => {
248 | expect(() => createEventSchema.parse({
249 | ...baseEvent,
250 | attendees: [
251 | { displayName: 'No Email User' }
252 | ]
253 | })).toThrow();
254 | });
255 |
256 | it('should reject negative additional guests', () => {
257 | expect(() => createEventSchema.parse({
258 | ...baseEvent,
259 | attendees: [
260 | { email: '[email protected]', additionalGuests: -1 }
261 | ]
262 | })).toThrow();
263 | });
264 | });
265 |
266 | describe('Source Property', () => {
267 | it('should accept source with url and title', () => {
268 | const event = {
269 | ...baseEvent,
270 | source: {
271 | url: 'https://example.com/event/123',
272 | title: 'External Event System'
273 | }
274 | };
275 | expect(() => createEventSchema.parse(event)).not.toThrow();
276 | });
277 |
278 | it('should reject source without url', () => {
279 | expect(() => createEventSchema.parse({
280 | ...baseEvent,
281 | source: { title: 'No URL' }
282 | })).toThrow();
283 | });
284 |
285 | it('should reject source without title', () => {
286 | expect(() => createEventSchema.parse({
287 | ...baseEvent,
288 | source: { url: 'https://example.com' }
289 | })).toThrow();
290 | });
291 | });
292 |
293 | describe('Combined Properties', () => {
294 | it('should accept event with all enhanced properties', () => {
295 | const complexEvent = {
296 | ...baseEvent,
297 | eventId: 'custom-id-123',
298 | description: 'Complex event with all features',
299 | location: 'Conference Room',
300 | transparency: 'opaque',
301 | visibility: 'public',
302 | guestsCanInviteOthers: true,
303 | guestsCanModify: false,
304 | guestsCanSeeOtherGuests: true,
305 | anyoneCanAddSelf: false,
306 | sendUpdates: 'all',
307 | conferenceData: {
308 | createRequest: {
309 | requestId: 'conf-123',
310 | conferenceSolutionKey: { type: 'hangoutsMeet' }
311 | }
312 | },
313 | extendedProperties: {
314 | private: { appId: '123' },
315 | shared: { category: 'meeting' }
316 | },
317 | attachments: [
318 | { fileUrl: 'https://example.com/agenda.pdf', title: 'Agenda' }
319 | ],
320 | attendees: [
321 | {
322 | email: '[email protected]',
323 | displayName: 'Alice',
324 | optional: false,
325 | responseStatus: 'accepted'
326 | },
327 | {
328 | email: '[email protected]',
329 | displayName: 'Bob',
330 | optional: true,
331 | responseStatus: 'tentative',
332 | additionalGuests: 1
333 | }
334 | ],
335 | source: {
336 | url: 'https://example.com/source',
337 | title: 'Source System'
338 | },
339 | colorId: '5',
340 | reminders: {
341 | useDefault: false,
342 | overrides: [{ method: 'popup', minutes: 15 }]
343 | }
344 | };
345 |
346 | expect(() => createEventSchema.parse(complexEvent)).not.toThrow();
347 | });
348 |
349 | it('should maintain backward compatibility with minimal event', () => {
350 | // Only required fields
351 | const minimalEvent = {
352 | calendarId: 'primary',
353 | summary: 'Simple Event',
354 | start: '2025-01-20T10:00:00',
355 | end: '2025-01-20T11:00:00'
356 | };
357 |
358 | expect(() => createEventSchema.parse(minimalEvent)).not.toThrow();
359 | });
360 | });
361 | });
```
--------------------------------------------------------------------------------
/src/tests/integration/claude-mcp-integration.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2 | import Anthropic from '@anthropic-ai/sdk';
3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5 |
6 | /**
7 | * Minimal Claude + MCP Integration Tests
8 | *
9 | * PURPOSE: Test ONLY what's unique to LLM integration:
10 | * 1. Can Claude understand user intent and select appropriate tools?
11 | * 2. Can Claude handle multi-step reasoning?
12 | * 3. Can Claude handle ambiguous requests appropriately?
13 | *
14 | * NOT TESTED HERE (covered in direct-integration.test.ts):
15 | * - Tool functionality
16 | * - Conflict detection
17 | * - Calendar operations
18 | * - Error handling
19 | * - Performance
20 | */
21 |
22 | interface LLMResponse {
23 | content: string;
24 | toolCalls: Array<{ name: string; arguments: Record<string, any> }>;
25 | executedResults: Array<{
26 | toolCall: { name: string; arguments: Record<string, any> };
27 | result: any;
28 | success: boolean;
29 | }>;
30 | }
31 |
32 | class ClaudeMCPClient {
33 | private anthropic: Anthropic;
34 | private mcpClient: Client;
35 |
36 | constructor(apiKey: string, mcpClient: Client) {
37 | this.anthropic = new Anthropic({ apiKey });
38 | this.mcpClient = mcpClient;
39 | }
40 |
41 | async sendMessage(prompt: string): Promise<LLMResponse> {
42 | // Get available tools from MCP server
43 | const availableTools = await this.mcpClient.listTools();
44 | const model = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5-20250929';
45 |
46 | // Convert MCP tools to Claude format
47 | const claudeTools = availableTools.tools.map(tool => ({
48 | name: tool.name,
49 | description: tool.description,
50 | input_schema: tool.inputSchema
51 | }));
52 |
53 | // Send to Claude
54 | const message = await this.anthropic.messages.create({
55 | model,
56 | max_tokens: 2500,
57 | tools: claudeTools,
58 | messages: [{
59 | role: 'user' as const,
60 | content: prompt
61 | }]
62 | });
63 |
64 | // Extract tool calls
65 | const toolCalls: Array<{ name: string; arguments: Record<string, any> }> = [];
66 | let textContent = '';
67 |
68 | message.content.forEach(content => {
69 | if (content.type === 'text') {
70 | textContent += content.text;
71 | } else if (content.type === 'tool_use') {
72 | toolCalls.push({
73 | name: content.name,
74 | arguments: content.input as Record<string, any>
75 | });
76 | }
77 | });
78 |
79 | // Execute tool calls
80 | const executedResults = [];
81 | for (const toolCall of toolCalls) {
82 | try {
83 | const result = await this.mcpClient.callTool({
84 | name: toolCall.name,
85 | arguments: toolCall.arguments
86 | });
87 |
88 | executedResults.push({
89 | toolCall,
90 | result,
91 | success: true
92 | });
93 | } catch (error) {
94 | executedResults.push({
95 | toolCall,
96 | result: { error: String(error) },
97 | success: false
98 | });
99 | }
100 | }
101 |
102 | return {
103 | content: textContent,
104 | toolCalls,
105 | executedResults
106 | };
107 | }
108 | }
109 |
110 | describe('Claude + MCP Essential Tests', () => {
111 | let mcpClient: Client;
112 | let claudeClient: ClaudeMCPClient;
113 |
114 | beforeAll(async () => {
115 | // Start MCP server
116 | const cleanEnv = Object.fromEntries(
117 | Object.entries(process.env).filter(([_, value]) => value !== undefined)
118 | ) as Record<string, string>;
119 | cleanEnv.NODE_ENV = 'test';
120 |
121 | // Create MCP client
122 | mcpClient = new Client({
123 | name: "minimal-test-client",
124 | version: "1.0.0"
125 | }, {
126 | capabilities: { tools: {} }
127 | });
128 |
129 | // Connect to server
130 | const transport = new StdioClientTransport({
131 | command: 'node',
132 | args: ['build/index.js'],
133 | env: cleanEnv
134 | });
135 |
136 | await mcpClient.connect(transport);
137 |
138 | // Initialize Claude client
139 | const apiKey = process.env.CLAUDE_API_KEY;
140 | if (!apiKey) {
141 | throw new Error('CLAUDE_API_KEY not set');
142 | }
143 |
144 | claudeClient = new ClaudeMCPClient(apiKey, mcpClient);
145 |
146 | // Verify connection
147 | const tools = await mcpClient.listTools();
148 | console.log(`Connected to MCP with ${tools.tools.length} tools available`);
149 | }, 30000);
150 |
151 | afterAll(async () => {
152 | if (mcpClient) await mcpClient.close();
153 | }, 10000);
154 |
155 | describe('Core LLM Capabilities', () => {
156 | it('should select appropriate tools for user intent', async () => {
157 | const testCases = [
158 | {
159 | intent: 'create',
160 | prompt: 'Schedule a meeting tomorrow at 3 PM',
161 | expectedTools: ['create-event', 'get-current-time']
162 | },
163 | {
164 | intent: 'search',
165 | prompt: 'Find my meetings with Sarah',
166 | expectedTools: ['search-events', 'list-events', 'get-current-time']
167 | },
168 | {
169 | intent: 'availability',
170 | prompt: 'Am I free tomorrow afternoon?',
171 | expectedTools: ['get-freebusy', 'list-events', 'get-current-time']
172 | }
173 | ];
174 |
175 | for (const test of testCases) {
176 | const response = await claudeClient.sendMessage(test.prompt);
177 |
178 | // Check if Claude used one of the expected tools
179 | const usedExpectedTool = response.toolCalls.some(tc =>
180 | test.expectedTools.includes(tc.name)
181 | );
182 |
183 | // Or at least understood the intent in its response
184 | const understoodIntent =
185 | usedExpectedTool ||
186 | response.content.toLowerCase().includes(test.intent);
187 |
188 | expect(understoodIntent).toBe(true);
189 | }
190 | }, 60000);
191 |
192 | it('should handle multi-step requests', async () => {
193 | const response = await claudeClient.sendMessage(
194 | 'What time is it now, and do I have any meetings in the next 2 hours?'
195 | );
196 |
197 | // This requires multiple tool calls or understanding multiple parts
198 | const handledMultiStep =
199 | response.toolCalls.length > 1 || // Multiple tools used
200 | (response.toolCalls.some(tc => tc.name === 'get-current-time') &&
201 | response.toolCalls.some(tc => tc.name === 'list-events')) || // Both time and events
202 | (response.content.includes('time') && response.content.includes('meeting')); // Understood both parts
203 |
204 | expect(handledMultiStep).toBe(true);
205 | }, 30000);
206 |
207 | it('should handle ambiguous requests gracefully', async () => {
208 | const response = await claudeClient.sendMessage(
209 | 'Set up the usual'
210 | );
211 |
212 | // Claude should either:
213 | // 1. Ask for clarification
214 | // 2. Make a reasonable attempt with available context
215 | // 3. Explain what information is needed
216 | const handledGracefully =
217 | response.content.toLowerCase().includes('what') ||
218 | response.content.toLowerCase().includes('specify') ||
219 | response.content.toLowerCase().includes('usual') ||
220 | response.content.toLowerCase().includes('more') ||
221 | response.toolCalls.length > 0; // Or attempts something
222 |
223 | expect(handledGracefully).toBe(true);
224 | }, 30000);
225 | });
226 |
227 | describe('Tool Selection Accuracy', () => {
228 | it('should distinguish between list and search operations', async () => {
229 | // Specific search should use search-events
230 | const searchResponse = await claudeClient.sendMessage(
231 | 'Find meetings about project alpha'
232 | );
233 |
234 | const usedSearch =
235 | searchResponse.toolCalls.some(tc => tc.name === 'search-events') ||
236 | searchResponse.content.toLowerCase().includes('search');
237 |
238 | // General list should use list-events
239 | const listResponse = await claudeClient.sendMessage(
240 | 'Show me tomorrow\'s schedule'
241 | );
242 |
243 | const usedList =
244 | listResponse.toolCalls.some(tc => tc.name === 'list-events') ||
245 | listResponse.content.toLowerCase().includes('tomorrow');
246 |
247 | // At least one should be correct
248 | expect(usedSearch || usedList).toBe(true);
249 | }, 30000);
250 |
251 | it('should understand when NOT to use tools', async () => {
252 | const response = await claudeClient.sendMessage(
253 | 'How does Google Calendar handle recurring events?'
254 | );
255 |
256 | // This is a question about calendars, not a calendar operation
257 | // Claude should either:
258 | // 1. Not use tools and explain
259 | // 2. Use minimal tools (like list-calendars) to provide context
260 | const appropriateResponse =
261 | response.toolCalls.length === 0 || // No tools
262 | response.toolCalls.length === 1 && response.toolCalls[0].name === 'list-calendars' || // Just checking calendars
263 | response.content.toLowerCase().includes('recurring'); // Explains about recurring events
264 |
265 | expect(appropriateResponse).toBe(true);
266 | }, 30000);
267 | });
268 |
269 | describe('Context Understanding', () => {
270 | it('should understand relative time expressions', async () => {
271 | const testPhrases = [
272 | 'tomorrow at 2 PM',
273 | 'next Monday',
274 | 'in 30 minutes'
275 | ];
276 |
277 | for (const phrase of testPhrases) {
278 | const response = await claudeClient.sendMessage(
279 | `Schedule a meeting ${phrase}`
280 | );
281 |
282 | // Claude should either get current time or attempt to create an event
283 | const understoodTime =
284 | response.toolCalls.some(tc =>
285 | tc.name === 'get-current-time' ||
286 | tc.name === 'create-event'
287 | ) ||
288 | response.content.toLowerCase().includes(phrase.split(' ')[0]); // References the time
289 |
290 | expect(understoodTime).toBe(true);
291 | }
292 | }, 60000);
293 | });
294 | });
295 |
296 | /**
297 | * What we removed:
298 | * ✂️ All conflict detection tests (tested in direct integration)
299 | * ✂️ Duplicate detection tests (tested in direct integration)
300 | * ✂️ Conference room booking tests (business logic, not LLM)
301 | * ✂️ Back-to-back meeting tests (calendar logic, not LLM)
302 | * ✂️ Specific warning message tests (tool behavior, not LLM)
303 | * ✂️ Performance tests (server performance, not LLM)
304 | * ✂️ Complex multi-event creation tests (tool functionality)
305 | *
306 | * What remains:
307 | * ✅ Tool selection for different intents (core LLM capability)
308 | * ✅ Multi-step request handling (LLM reasoning)
309 | * ✅ Ambiguous request handling (LLM robustness)
310 | * ✅ Context understanding (LLM comprehension)
311 | * ✅ Knowing when NOT to use tools (LLM judgment)
312 | */
```
--------------------------------------------------------------------------------
/docs/testing.md:
--------------------------------------------------------------------------------
```markdown
1 | # Testing Guide
2 |
3 | ## Quick Start
4 |
5 | ```bash
6 | npm test # Unit tests (no auth required)
7 | npm run test:integration # Integration tests (requires Google auth)
8 | npm run test:all # All tests (requires Google auth + LLM API keys)
9 | ```
10 |
11 | ## Test Structure
12 |
13 | - `src/tests/unit/` - Unit tests (mocked, no external dependencies)
14 | - `src/tests/integration/` - Integration tests (real Google Calendar API calls)
15 |
16 | ## Unit Tests
17 |
18 | **Requirements:** None - fully self-contained
19 |
20 | **Coverage:**
21 | - Request validation and schema compliance
22 | - Error handling and edge cases
23 | - Date/time parsing and timezone conversion logic
24 | - Mock-based handler functionality
25 | - Tool registration and validation
26 |
27 | **Run with:**
28 | ```bash
29 | npm test
30 | ```
31 |
32 | ## Integration Tests
33 |
34 | Integration tests are divided into three categories based on their requirements:
35 |
36 | ### 1. Direct Google Calendar Integration
37 |
38 | **Files:** `direct-integration.test.ts`
39 |
40 | **Requirements:**
41 | - Google OAuth credentials file
42 | - Authenticated test account
43 | - Real Google Calendar access
44 |
45 | **Setup:**
46 | ```bash
47 | # Set environment variables
48 | export GOOGLE_OAUTH_CREDENTIALS="path/to/your/oauth-credentials.json"
49 | export TEST_CALENDAR_ID="your-test-calendar-id"
50 |
51 | # Authenticate test account
52 | npm run dev auth:test
53 | ```
54 |
55 | **What these tests do:**
56 | - ✅ Create, read, update, delete real calendar events
57 | - ✅ Test multi-calendar operations with batch requests
58 | - ✅ Validate timezone handling with actual Google Calendar API
59 | - ✅ Test recurring event patterns and modifications
60 | - ✅ Verify free/busy queries and calendar listings
61 | - ✅ Performance benchmarking with real API latency
62 |
63 | **⚠️ Warning:** These tests modify real calendar data in your test calendar.
64 |
65 | ### 2. LLM Integration Tests
66 |
67 | **Files:** `claude-mcp-integration.test.ts`, `openai-mcp-integration.test.ts`
68 |
69 | **Requirements:**
70 | - Google OAuth credentials + authenticated test account (from above)
71 | - LLM API keys
72 | - **LLM models that support MCP (Claude) or function calling (OpenAI)**
73 |
74 | **Additional setup:**
75 | ```bash
76 | # Set LLM API keys
77 | export CLAUDE_API_KEY="your-claude-api-key"
78 | export OPENAI_API_KEY="your-openai-api-key"
79 |
80 | # Optional: specify models (must support MCP/function calling)
81 | export ANTHROPIC_MODEL="claude-3-5-haiku-20241022" # Default
82 | export OPENAI_MODEL="gpt-4o-mini" # Default
83 | ```
84 |
85 | **What these tests do:**
86 | - ✅ Test end-to-end MCP protocol integration with Claude
87 | - ✅ Test end-to-end MCP protocol integration with OpenAI
88 | - ✅ Validate AI assistant can successfully call calendar tools
89 | - ✅ Test complex multi-step AI workflows
90 |
91 | **⚠️ Warning:** These tests consume LLM API credits and modify real calendar data.
92 |
93 | **Important LLM Compatibility Notes:**
94 | - **Claude**: Only Claude 3.5+ models support MCP. Earlier models will fail.
95 | - **OpenAI**: Only GPT-4+ and select GPT-3.5-turbo models support function calling.
96 | - If you see "tool not found" or "function not supported" errors, verify your model selection.
97 |
98 | ### 3. Docker Integration Tests
99 |
100 | **Files:** `docker-integration.test.ts`
101 |
102 | **Requirements:**
103 | - Docker installed and running
104 | - Google OAuth credentials
105 |
106 | **What these tests do:**
107 | - ✅ Test containerized deployment
108 | - ✅ Validate HTTP transport mode
109 | - ✅ Test Docker environment configuration
110 |
111 | ### Running Specific Integration Test Types
112 |
113 | ```bash
114 | # Run only direct Google Calendar integration tests
115 | npm run test:integration -- direct-integration.test.ts
116 |
117 | # Run only LLM integration tests (requires API keys)
118 | npm run test:integration -- claude-mcp-integration.test.ts
119 | npm run test:integration -- openai-mcp-integration.test.ts
120 |
121 | # Run all integration tests (requires both Google auth + LLM API keys)
122 | npm run test:integration
123 | ```
124 |
125 | ## Environment Configuration
126 |
127 | ### Required Environment Variables
128 |
129 | | Variable | Required For | Purpose | Example |
130 | |----------|--------------|---------|---------|
131 | | `GOOGLE_OAUTH_CREDENTIALS` | All integration tests | Path to OAuth credentials file | `./gcp-oauth.keys.json` |
132 | | `TEST_CALENDAR_ID` | All integration tests | Target calendar for test operations | `[email protected]` or `primary` |
133 | | `CLAUDE_API_KEY` | Claude integration tests | Anthropic API access | `sk-ant-api03-...` |
134 | | `OPENAI_API_KEY` | OpenAI integration tests | OpenAI API access | `sk-...` |
135 | | `INVITEE_1` | Attendee tests | Test attendee email | `[email protected]` |
136 | | `INVITEE_2` | Attendee tests | Test attendee email | `[email protected]` |
137 |
138 | ### Optional Environment Variables
139 |
140 | | Variable | Purpose | Default | Notes |
141 | |----------|---------|---------|-------|
142 | | `GOOGLE_ACCOUNT_MODE` | Account mode | `normal` | Use `test` for testing |
143 | | `DEBUG_LLM_INTERACTIONS` | Debug logging | `false` | Set `true` for verbose LLM logs |
144 | | `ANTHROPIC_MODEL` | Claude model | `claude-3-5-haiku-20241022` | Must support MCP |
145 | | `OPENAI_MODEL` | OpenAI model | `gpt-4o-mini` | Must support function calling |
146 |
147 | ### Complete Setup Example
148 |
149 | 1. **Create `.env` file in project root:**
150 | ```env
151 | # Required for all integration tests
152 | GOOGLE_OAUTH_CREDENTIALS=./gcp-oauth.keys.json
153 | [email protected]
154 |
155 | # Required for LLM integration tests
156 | CLAUDE_API_KEY=sk-ant-api03-...
157 | OPENAI_API_KEY=sk-...
158 |
159 | # Required for attendee tests
160 | [email protected]
161 | [email protected]
162 |
163 | # Optional configurations
164 | GOOGLE_ACCOUNT_MODE=test
165 | DEBUG_LLM_INTERACTIONS=false
166 | ANTHROPIC_MODEL=claude-3-5-haiku-20241022
167 | OPENAI_MODEL=gpt-4o-mini
168 | ```
169 |
170 | 2. **Obtain Google OAuth Credentials:**
171 | - Go to [Google Cloud Console](https://console.cloud.google.com)
172 | - Create a new project or select existing
173 | - Enable Google Calendar API
174 | - Create OAuth 2.0 credentials (Desktop app type)
175 | - Download credentials JSON file
176 | - Save as `gcp-oauth.keys.json` in project root
177 |
178 | 3. **Authenticate Test Account:**
179 | ```bash
180 | # Creates tokens in ~/.config/google-calendar-mcp/tokens.json
181 | npm run dev auth:test
182 | ```
183 |
184 | 4. **Verify Setup:**
185 | ```bash
186 | # Check authentication status
187 | npm run dev account:status
188 |
189 | # Run a simple integration test
190 | npm run test:integration -- direct-integration.test.ts
191 | ```
192 |
193 |
194 | ## Troubleshooting
195 |
196 | ### Common Issues
197 |
198 | **Authentication Errors:**
199 | - **"No credentials found"**: Run `npm run dev auth:test` to authenticate
200 | - **"Token expired"**: Re-authenticate with `npm run dev auth:test`
201 | - **"Invalid credentials"**: Check `GOOGLE_OAUTH_CREDENTIALS` path is correct
202 | - **"Refresh token must be passed"**: Delete tokens and re-authenticate
203 |
204 | **API Errors:**
205 | - **Rate limits**: Tests include retry logic, but may still hit limits with frequent runs
206 | - **Calendar not found**: Verify `TEST_CALENDAR_ID` exists and is accessible
207 | - **Permission denied**: Ensure test account has write access to the calendar
208 | - **"Invalid time range"**: Free/busy queries limited to 3 months between timeMin and timeMax
209 |
210 | **LLM Integration Errors:**
211 | - **"Invalid API key"**: Check `CLAUDE_API_KEY`/`OPENAI_API_KEY` are set correctly
212 | - **"Insufficient credits"**: LLM tests consume API credits - ensure account has balance
213 | - **"Model not found"**: Verify model name and availability in your API plan
214 | - **"Tool not found" or "Function not supported"**:
215 | - Claude: Ensure using Claude 3.5+ model that supports MCP
216 | - OpenAI: Ensure using GPT-4+ or compatible GPT-3.5-turbo model
217 | - **"Maximum tokens exceeded"**: Some complex tests may hit token limits with verbose models
218 | - **Network timeouts**: LLM tests may take 2-5 minutes due to AI processing time
219 |
220 | **Docker Integration Errors:**
221 | - **"Docker not found"**: Ensure Docker is installed and running
222 | - **Port conflicts**: Docker tests use port 3000 - ensure it's available
223 | - **Build failures**: Check Docker build logs for missing dependencies
224 | - **"Cannot connect to Docker daemon"**: Start Docker Desktop or daemon
225 |
226 | ### Test Data Management
227 |
228 | **Calendar Cleanup:**
229 | - Tests attempt to clean up created events automatically
230 | - Failed tests may leave test events in your calendar
231 | - Manually delete events with "Integration Test" or "Test Event" in the title if needed
232 |
233 | **Test Isolation:**
234 | - Use a dedicated test calendar (`TEST_CALENDAR_ID`)
235 | - Don't use your personal calendar for testing
236 | - Consider creating a separate Google account for testing
237 |
238 | ### Performance Considerations
239 |
240 | **Test Duration:**
241 | - Unit tests: ~2 seconds
242 | - Direct integration tests: ~30-60 seconds
243 | - LLM integration tests: ~2-5 minutes (due to AI processing)
244 | - Full test suite: ~5-10 minutes
245 |
246 | **Parallel Execution:**
247 | - Unit tests run in parallel by default
248 | - Integration tests run sequentially to avoid API conflicts
249 | - Use `--reporter=verbose` for detailed progress during long test runs
250 |
251 | ## Development Tips
252 |
253 | ### Debugging Integration Tests
254 |
255 | 1. **Enable Debug Logging:**
256 | ```bash
257 | # Debug all LLM interactions
258 | export DEBUG_LLM_INTERACTIONS=true
259 |
260 | # Debug MCP server
261 | export DEBUG=mcp:*
262 | ```
263 |
264 | 2. **Run Single Test:**
265 | ```bash
266 | # Run specific test by name pattern
267 | npm run test:integration -- -t "should handle timezone"
268 | ```
269 |
270 | 3. **Interactive Testing:**
271 | ```bash
272 | # Use the dev menu for quick access to test commands
273 | npm run dev
274 | ```
275 |
276 | ### Writing New Integration Tests
277 |
278 | 1. **Use Test Data Factory:**
279 | ```typescript
280 | import { TestDataFactory } from './test-data-factory.js';
281 |
282 | const factory = new TestDataFactory();
283 | const testEvent = factory.createTestEvent({
284 | summary: 'My Test Event',
285 | start: factory.getTomorrowAt(14, 0),
286 | end: factory.getTomorrowAt(15, 0)
287 | });
288 | ```
289 |
290 | 2. **Track Created Events:**
291 | ```typescript
292 | // Events are automatically tracked for cleanup
293 | const eventId = TestDataFactory.extractEventIdFromResponse(result);
294 | ```
295 |
296 | 3. **LLM Context Logging:**
297 | ```typescript
298 | // Wrap LLM operations for automatic error logging
299 | await executeWithContextLogging('Test Name', async () => {
300 | const response = await llmClient.sendMessage('...');
301 | // Test assertions
302 | });
303 | ```
304 |
305 | ### Best Practices
306 |
307 | 1. **Environment Isolation:**
308 | - Always use `GOOGLE_ACCOUNT_MODE=test` for testing
309 | - Use a dedicated test calendar, not personal calendar
310 | - Consider separate Google account for testing
311 |
312 | 2. **Cost Management:**
313 | - LLM tests consume API credits
314 | - Run specific tests during development
315 | - Use smaller/cheaper models for initial testing
316 |
317 | 3. **Test Data:**
318 | - Tests auto-cleanup created events
319 | - Use unique event titles with timestamps
320 | - Verify cleanup in afterEach hooks
321 |
322 | 4. **Debugging Failures:**
323 | - Check `DEBUG_LLM_INTERACTIONS` output for LLM tests
324 | - Verify model compatibility for tool/function support
325 | - Check API quotas and rate limits
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/ListEventsHandler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { ListEventsHandler } from '../../../handlers/core/ListEventsHandler.js';
3 | import { OAuth2Client } from 'google-auth-library';
4 | import { google } from 'googleapis';
5 | import { convertToRFC3339 } from '../../../handlers/utils/datetime.js';
6 |
7 | // Mock googleapis globally
8 | vi.mock('googleapis', () => ({
9 | google: {
10 | calendar: vi.fn(() => ({
11 | events: {
12 | list: vi.fn()
13 | },
14 | calendarList: {
15 | get: vi.fn()
16 | }
17 | }))
18 | }
19 | }));
20 |
21 | describe('ListEventsHandler JSON String Handling', () => {
22 | const mockOAuth2Client = {
23 | getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
24 | } as unknown as OAuth2Client;
25 |
26 | const handler = new ListEventsHandler();
27 | let mockCalendar: any;
28 |
29 | beforeEach(() => {
30 | mockCalendar = {
31 | events: {
32 | list: vi.fn().mockResolvedValue({
33 | data: {
34 | items: [
35 | {
36 | id: 'test-event',
37 | summary: 'Test Event',
38 | start: { dateTime: '2025-06-02T10:00:00Z' },
39 | end: { dateTime: '2025-06-02T11:00:00Z' },
40 | }
41 | ]
42 | }
43 | })
44 | },
45 | calendarList: {
46 | get: vi.fn().mockResolvedValue({
47 | data: { timeZone: 'UTC' }
48 | }),
49 | list: vi.fn().mockResolvedValue({
50 | data: {
51 | items: [
52 | { id: 'primary', summary: 'Primary Calendar' },
53 | { id: '[email protected]', summary: 'Work Calendar' },
54 | { id: '[email protected]', summary: 'Personal Calendar' }
55 | ]
56 | }
57 | })
58 | }
59 | };
60 | vi.mocked(google.calendar).mockReturnValue(mockCalendar);
61 | });
62 |
63 | // Mock fetch for batch requests
64 | global.fetch = vi.fn().mockResolvedValue({
65 | ok: true,
66 | status: 200,
67 | text: () => Promise.resolve(`--batch_boundary
68 | Content-Type: application/http
69 | Content-ID: <item1>
70 |
71 | HTTP/1.1 200 OK
72 | Content-Type: application/json
73 |
74 | {"items": [{"id": "test-event", "summary": "Test Event", "start": {"dateTime": "2025-06-02T10:00:00Z"}, "end": {"dateTime": "2025-06-02T11:00:00Z"}}]}
75 |
76 | --batch_boundary--`)
77 | });
78 |
79 | it('should handle single calendar ID as string', async () => {
80 | const args = {
81 | calendarId: 'primary',
82 | timeMin: '2025-06-02T00:00:00Z',
83 | timeMax: '2025-06-09T23:59:59Z'
84 | };
85 |
86 | const result = await handler.runTool(args, mockOAuth2Client);
87 | expect(result.content).toHaveLength(1);
88 | expect(result.content[0].type).toBe('text');
89 | const response = JSON.parse((result.content[0] as any).text);
90 | expect(response.events).toBeDefined();
91 | expect(response.totalCount).toBeGreaterThanOrEqual(0);
92 | });
93 |
94 | it('should handle multiple calendar IDs as array', async () => {
95 | const args = {
96 | calendarId: ['primary', '[email protected]'],
97 | timeMin: '2025-06-02T00:00:00Z',
98 | timeMax: '2025-06-09T23:59:59Z'
99 | };
100 |
101 | const result = await handler.runTool(args, mockOAuth2Client);
102 | expect(result.content).toHaveLength(1);
103 | expect(result.content[0].type).toBe('text');
104 | const response = JSON.parse((result.content[0] as any).text);
105 | expect(response.events).toBeDefined();
106 | expect(response.totalCount).toBeGreaterThanOrEqual(0);
107 | });
108 |
109 | it('should handle calendar IDs passed as JSON string', async () => {
110 | // This simulates the problematic case from the user
111 | const args = {
112 | calendarId: '["primary", "[email protected]"]',
113 | timeMin: '2025-06-02T00:00:00Z',
114 | timeMax: '2025-06-09T23:59:59Z'
115 | };
116 |
117 | // This would be parsed by the Zod transform before reaching the handler
118 | // For testing, we'll manually simulate what the transform should do
119 | let processedArgs = { ...args };
120 | if (typeof args.calendarId === 'string' && args.calendarId.startsWith('[')) {
121 | processedArgs.calendarId = JSON.parse(args.calendarId);
122 | }
123 |
124 | const result = await handler.runTool(processedArgs, mockOAuth2Client);
125 | expect(result.content).toHaveLength(1);
126 | expect(result.content[0].type).toBe('text');
127 | const response = JSON.parse((result.content[0] as any).text);
128 | expect(response.events).toBeDefined();
129 | expect(response.totalCount).toBeGreaterThanOrEqual(0);
130 | expect(response.calendars).toEqual(['primary', '[email protected]']);
131 | });
132 | });
133 |
134 | describe('ListEventsHandler - Timezone Handling', () => {
135 | let handler: ListEventsHandler;
136 | let mockOAuth2Client: OAuth2Client;
137 | let mockCalendar: any;
138 |
139 | beforeEach(() => {
140 | handler = new ListEventsHandler();
141 | mockOAuth2Client = {} as OAuth2Client;
142 | mockCalendar = {
143 | events: {
144 | list: vi.fn()
145 | },
146 | calendarList: {
147 | get: vi.fn(),
148 | list: vi.fn().mockResolvedValue({
149 | data: {
150 | items: [
151 | { id: 'primary', summary: 'Primary Calendar' },
152 | { id: '[email protected]', summary: 'Work Calendar' }
153 | ]
154 | }
155 | })
156 | }
157 | };
158 | vi.mocked(google.calendar).mockReturnValue(mockCalendar);
159 | });
160 |
161 | describe('convertToRFC3339 timezone interpretation', () => {
162 | it('should correctly convert timezone-naive datetime to Los Angeles time', () => {
163 | // Test the core issue: timezone-naive datetime should be interpreted in the target timezone
164 | const datetime = '2025-01-01T10:00:00';
165 | const timezone = 'America/Los_Angeles';
166 |
167 | const result = convertToRFC3339(datetime, timezone);
168 |
169 | // In January 2025, Los Angeles is UTC-8 (PST)
170 | // 10:00 AM PST = 18:00 UTC
171 | // The result should be '2025-01-01T18:00:00Z'
172 | expect(result).toBe('2025-01-01T18:00:00Z');
173 | });
174 |
175 | it('should correctly convert timezone-naive datetime to New York time', () => {
176 | const datetime = '2025-01-01T10:00:00';
177 | const timezone = 'America/New_York';
178 |
179 | const result = convertToRFC3339(datetime, timezone);
180 |
181 | // In January 2025, New York is UTC-5 (EST)
182 | // 10:00 AM EST = 15:00 UTC
183 | expect(result).toBe('2025-01-01T15:00:00Z');
184 | });
185 |
186 | it('should correctly convert timezone-naive datetime to London time', () => {
187 | const datetime = '2025-01-01T10:00:00';
188 | const timezone = 'Europe/London';
189 |
190 | const result = convertToRFC3339(datetime, timezone);
191 |
192 | // In January 2025, London is UTC+0 (GMT)
193 | // 10:00 AM GMT = 10:00 UTC
194 | expect(result).toBe('2025-01-01T10:00:00Z');
195 | });
196 |
197 | it('should handle DST transitions correctly', () => {
198 | // Test during DST period
199 | const datetime = '2025-07-01T10:00:00';
200 | const timezone = 'America/Los_Angeles';
201 |
202 | const result = convertToRFC3339(datetime, timezone);
203 |
204 | // In July 2025, Los Angeles is UTC-7 (PDT)
205 | // 10:00 AM PDT = 17:00 UTC
206 | expect(result).toBe('2025-07-01T17:00:00Z');
207 | });
208 |
209 | it('should leave timezone-aware datetime unchanged', () => {
210 | const datetime = '2025-01-01T10:00:00-08:00';
211 | const timezone = 'America/Los_Angeles';
212 |
213 | const result = convertToRFC3339(datetime, timezone);
214 |
215 | // Should remain unchanged since it already has timezone info
216 | expect(result).toBe('2025-01-01T10:00:00-08:00');
217 | });
218 | });
219 |
220 | describe('ListEventsHandler timezone parameter usage', () => {
221 | beforeEach(() => {
222 | // Mock successful calendar list response
223 | mockCalendar.calendarList.get.mockResolvedValue({
224 | data: { timeZone: 'UTC' }
225 | });
226 |
227 | // Mock successful events list response
228 | mockCalendar.events.list.mockResolvedValue({
229 | data: { items: [] }
230 | });
231 | });
232 |
233 | it('should use timeZone parameter to interpret timezone-naive timeMin/timeMax', async () => {
234 | const args = {
235 | calendarId: 'primary',
236 | timeMin: '2025-01-01T10:00:00',
237 | timeMax: '2025-01-01T18:00:00',
238 | timeZone: 'America/Los_Angeles'
239 | };
240 |
241 | await handler.runTool(args, mockOAuth2Client);
242 |
243 | // Verify that the calendar.events.list was called with correctly converted times
244 | expect(mockCalendar.events.list).toHaveBeenCalledWith({
245 | calendarId: 'primary',
246 | timeMin: '2025-01-01T18:00:00Z', // 10:00 AM PST = 18:00 UTC
247 | timeMax: '2025-01-02T02:00:00Z', // 18:00 PM PST = 02:00 UTC next day
248 | singleEvents: true,
249 | orderBy: 'startTime'
250 | });
251 | });
252 |
253 | it('should preserve timezone-aware timeMin/timeMax regardless of timeZone parameter', async () => {
254 | const args = {
255 | calendarId: 'primary',
256 | timeMin: '2025-01-01T10:00:00-08:00',
257 | timeMax: '2025-01-01T18:00:00-08:00',
258 | timeZone: 'America/New_York' // Different timezone, should be ignored
259 | };
260 |
261 | await handler.runTool(args, mockOAuth2Client);
262 |
263 | // Verify that the original timezone-aware times are preserved
264 | expect(mockCalendar.events.list).toHaveBeenCalledWith({
265 | calendarId: 'primary',
266 | timeMin: '2025-01-01T10:00:00-08:00',
267 | timeMax: '2025-01-01T18:00:00-08:00',
268 | singleEvents: true,
269 | orderBy: 'startTime'
270 | });
271 | });
272 |
273 | it('should fall back to calendar timezone when timeZone parameter not provided', async () => {
274 | // Mock calendar with Los Angeles timezone
275 | mockCalendar.calendarList.get.mockResolvedValue({
276 | data: { timeZone: 'America/Los_Angeles' }
277 | });
278 |
279 | const args = {
280 | calendarId: 'primary',
281 | timeMin: '2025-01-01T10:00:00',
282 | timeMax: '2025-01-01T18:00:00'
283 | // No timeZone parameter
284 | };
285 |
286 | await handler.runTool(args, mockOAuth2Client);
287 |
288 | // Verify that the calendar's timezone is used for conversion
289 | expect(mockCalendar.events.list).toHaveBeenCalledWith({
290 | calendarId: 'primary',
291 | timeMin: '2025-01-01T18:00:00Z', // 10:00 AM PST = 18:00 UTC
292 | timeMax: '2025-01-02T02:00:00Z', // 18:00 PM PST = 02:00 UTC next day
293 | singleEvents: true,
294 | orderBy: 'startTime'
295 | });
296 | });
297 |
298 | it('should handle UTC timezone correctly', async () => {
299 | const args = {
300 | calendarId: 'primary',
301 | timeMin: '2025-01-01T10:00:00',
302 | timeMax: '2025-01-01T18:00:00',
303 | timeZone: 'UTC'
304 | };
305 |
306 | await handler.runTool(args, mockOAuth2Client);
307 |
308 | // Verify that UTC times are handled correctly
309 | expect(mockCalendar.events.list).toHaveBeenCalledWith({
310 | calendarId: 'primary',
311 | timeMin: '2025-01-01T10:00:00Z',
312 | timeMax: '2025-01-01T18:00:00Z',
313 | singleEvents: true,
314 | orderBy: 'startTime'
315 | });
316 | });
317 | });
318 | });
```
--------------------------------------------------------------------------------
/src/services/conflict-detection/ConflictDetectionService.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OAuth2Client } from "google-auth-library";
2 | import { google, calendar_v3 } from "googleapis";
3 | import {
4 | ConflictCheckResult,
5 | InternalConflictInfo,
6 | InternalDuplicateInfo,
7 | ConflictDetectionOptions
8 | } from "./types.js";
9 | import { EventSimilarityChecker } from "./EventSimilarityChecker.js";
10 | import { ConflictAnalyzer } from "./ConflictAnalyzer.js";
11 | import { CONFLICT_DETECTION_CONFIG } from "./config.js";
12 | import { getEventUrl } from "../../handlers/utils.js";
13 | import { convertToRFC3339 } from "../../handlers/utils/datetime.js";
14 |
15 | /**
16 | * Service for detecting event conflicts and duplicates.
17 | *
18 | * IMPORTANT: This service relies on Google Calendar's list API to find existing events.
19 | * Due to eventual consistency in Google Calendar, recently created events may not
20 | * immediately appear in list queries. This is a known limitation of the Google Calendar API
21 | * and affects duplicate detection for events created in quick succession.
22 | *
23 | * In real-world usage, this is rarely an issue as there's natural time between event creation.
24 | */
25 | export class ConflictDetectionService {
26 | private similarityChecker: EventSimilarityChecker;
27 | private conflictAnalyzer: ConflictAnalyzer;
28 |
29 | constructor() {
30 | this.similarityChecker = new EventSimilarityChecker();
31 | this.conflictAnalyzer = new ConflictAnalyzer();
32 | }
33 |
34 | /**
35 | * Check for conflicts and duplicates when creating or updating an event
36 | */
37 | async checkConflicts(
38 | oauth2Client: OAuth2Client,
39 | event: calendar_v3.Schema$Event,
40 | calendarId: string,
41 | options: ConflictDetectionOptions = {}
42 | ): Promise<ConflictCheckResult> {
43 | const {
44 | checkDuplicates = true,
45 | checkConflicts = true,
46 | calendarsToCheck = [calendarId],
47 | duplicateSimilarityThreshold = CONFLICT_DETECTION_CONFIG.DEFAULT_DUPLICATE_THRESHOLD,
48 | includeDeclinedEvents = false
49 | } = options;
50 |
51 | const result: ConflictCheckResult = {
52 | hasConflicts: false,
53 | conflicts: [],
54 | duplicates: []
55 | };
56 |
57 | if (!event.start || !event.end) {
58 | return result;
59 | }
60 |
61 | // Get the time range for checking
62 | let timeMin = event.start.dateTime || event.start.date;
63 | let timeMax = event.end.dateTime || event.end.date;
64 |
65 | if (!timeMin || !timeMax) {
66 | return result;
67 | }
68 |
69 | // Extract timezone if present (prefer start time's timezone)
70 | const timezone = event.start.timeZone || event.end.timeZone;
71 |
72 |
73 | // The Google Calendar API requires RFC3339 format for timeMin/timeMax
74 | // If we have timezone-naive datetimes with a timezone field, convert them to proper RFC3339
75 | // Check for minus but exclude the date separator (e.g., 2025-09-05)
76 | const needsConversion = timezone && timeMin &&
77 | !timeMin.includes('Z') &&
78 | !timeMin.includes('+') &&
79 | !timeMin.substring(10).includes('-'); // Only check for minus after the date part
80 |
81 | if (needsConversion) {
82 | timeMin = convertToRFC3339(timeMin, timezone);
83 | timeMax = convertToRFC3339(timeMax, timezone);
84 | }
85 |
86 |
87 | // Use the exact time range provided for searching
88 | // This ensures duplicate detection only flags events that actually overlap
89 | const searchTimeMin = timeMin;
90 | const searchTimeMax = timeMax;
91 |
92 | // Check each calendar
93 | for (const checkCalendarId of calendarsToCheck) {
94 | try {
95 | // Get events in the search time range, passing timezone for proper interpretation
96 | const events = await this.getEventsInTimeRange(
97 | oauth2Client,
98 | checkCalendarId,
99 | searchTimeMin,
100 | searchTimeMax,
101 | timezone || undefined
102 | );
103 |
104 | // Check for duplicates
105 | if (checkDuplicates) {
106 | const duplicates = this.findDuplicates(
107 | event,
108 | events,
109 | checkCalendarId,
110 | duplicateSimilarityThreshold
111 | );
112 | result.duplicates.push(...duplicates);
113 | }
114 |
115 | // Check for conflicts
116 | if (checkConflicts) {
117 | const conflicts = this.findConflicts(
118 | event,
119 | events,
120 | checkCalendarId,
121 | includeDeclinedEvents
122 | );
123 | result.conflicts.push(...conflicts);
124 | }
125 | } catch (error) {
126 | // If we can't access a calendar, skip it silently
127 | // Errors are expected for calendars without access permissions
128 | }
129 | }
130 |
131 | result.hasConflicts = result.conflicts.length > 0 || result.duplicates.length > 0;
132 | return result;
133 | }
134 |
135 | /**
136 | * Get events in a specific time range from a calendar
137 | */
138 | private async getEventsInTimeRange(
139 | oauth2Client: OAuth2Client,
140 | calendarId: string,
141 | timeMin: string,
142 | timeMax: string,
143 | timeZone?: string
144 | ): Promise<calendar_v3.Schema$Event[]> {
145 | // Fetch from API
146 | const calendar = google.calendar({ version: "v3", auth: oauth2Client });
147 |
148 | // Build list parameters
149 | const listParams: any = {
150 | calendarId,
151 | timeMin,
152 | timeMax,
153 | singleEvents: true,
154 | orderBy: 'startTime',
155 | maxResults: 250
156 | };
157 |
158 | // The Google Calendar API accepts both:
159 | // 1. Timezone-aware datetimes (with Z or offset)
160 | // 2. Timezone-naive datetimes with a timeZone parameter
161 | // We pass the timeZone parameter when available for consistency
162 | if (timeZone) {
163 | listParams.timeZone = timeZone;
164 | }
165 |
166 |
167 | // Use exact time range without extension to avoid false positives
168 | const response = await calendar.events.list(listParams);
169 |
170 | const events = response?.data?.items || [];
171 |
172 | return events;
173 | }
174 |
175 | /**
176 | * Find duplicate events based on similarity
177 | */
178 | private findDuplicates(
179 | newEvent: calendar_v3.Schema$Event,
180 | existingEvents: calendar_v3.Schema$Event[],
181 | calendarId: string,
182 | threshold: number
183 | ): InternalDuplicateInfo[] {
184 | const duplicates: InternalDuplicateInfo[] = [];
185 |
186 |
187 | for (const existingEvent of existingEvents) {
188 | // Skip if it's the same event (for updates)
189 | if (existingEvent.id === newEvent.id) continue;
190 |
191 | // Skip cancelled events
192 | if (existingEvent.status === 'cancelled') continue;
193 |
194 | const similarity = this.similarityChecker.checkSimilarity(newEvent, existingEvent);
195 |
196 |
197 | if (similarity >= threshold) {
198 | duplicates.push({
199 | event: {
200 | id: existingEvent.id!,
201 | title: existingEvent.summary || 'Untitled Event',
202 | url: getEventUrl(existingEvent, calendarId) || undefined,
203 | similarity: Math.round(similarity * 100) / 100
204 | },
205 | fullEvent: existingEvent,
206 | calendarId: calendarId,
207 | suggestion: similarity >= CONFLICT_DETECTION_CONFIG.DUPLICATE_THRESHOLDS.BLOCKING
208 | ? 'This appears to be a duplicate. Consider updating the existing event instead.'
209 | : 'This event is very similar to an existing one. Is this intentional?'
210 | });
211 | }
212 | }
213 |
214 |
215 | return duplicates;
216 | }
217 |
218 | /**
219 | * Find conflicting events based on time overlap
220 | */
221 | private findConflicts(
222 | newEvent: calendar_v3.Schema$Event,
223 | existingEvents: calendar_v3.Schema$Event[],
224 | calendarId: string,
225 | includeDeclinedEvents: boolean
226 | ): InternalConflictInfo[] {
227 | const conflicts: InternalConflictInfo[] = [];
228 | const overlappingEvents = this.conflictAnalyzer.findOverlappingEvents(existingEvents, newEvent);
229 |
230 | for (const conflictingEvent of overlappingEvents) {
231 | // Skip declined events if configured
232 | if (!includeDeclinedEvents && this.isEventDeclined(conflictingEvent)) {
233 | continue;
234 | }
235 |
236 | const overlap = this.conflictAnalyzer.analyzeOverlap(newEvent, conflictingEvent);
237 |
238 | if (overlap.hasOverlap) {
239 | conflicts.push({
240 | type: 'overlap',
241 | calendar: calendarId,
242 | event: {
243 | id: conflictingEvent.id!,
244 | title: conflictingEvent.summary || 'Untitled Event',
245 | url: getEventUrl(conflictingEvent, calendarId) || undefined,
246 | start: conflictingEvent.start?.dateTime || conflictingEvent.start?.date || undefined,
247 | end: conflictingEvent.end?.dateTime || conflictingEvent.end?.date || undefined
248 | },
249 | fullEvent: conflictingEvent,
250 | overlap: {
251 | duration: overlap.duration!,
252 | percentage: overlap.percentage!,
253 | startTime: overlap.startTime!,
254 | endTime: overlap.endTime!
255 | }
256 | });
257 | }
258 | }
259 |
260 | return conflicts;
261 | }
262 |
263 | /**
264 | * Check if the current user has declined an event
265 | */
266 | private isEventDeclined(_event: calendar_v3.Schema$Event): boolean {
267 | // For now, we'll skip this check since we don't have easy access to the user's email
268 | // This could be enhanced later by passing the user email through the service
269 | return false;
270 | }
271 |
272 | /**
273 | * Check for conflicts using free/busy data (alternative method)
274 | */
275 | async checkConflictsWithFreeBusy(
276 | oauth2Client: OAuth2Client,
277 | eventToCheck: calendar_v3.Schema$Event,
278 | calendarsToCheck: string[]
279 | ): Promise<InternalConflictInfo[]> {
280 | const conflicts: InternalConflictInfo[] = [];
281 |
282 | if (!eventToCheck.start || !eventToCheck.end) return conflicts;
283 |
284 | const timeMin = eventToCheck.start.dateTime || eventToCheck.start.date;
285 | const timeMax = eventToCheck.end.dateTime || eventToCheck.end.date;
286 |
287 | if (!timeMin || !timeMax) return conflicts;
288 |
289 | const calendar = google.calendar({ version: "v3", auth: oauth2Client });
290 |
291 | try {
292 | const freeBusyResponse = await calendar.freebusy.query({
293 | requestBody: {
294 | timeMin,
295 | timeMax,
296 | items: calendarsToCheck.map(id => ({ id }))
297 | }
298 | });
299 |
300 | for (const [calendarId, calendarInfo] of Object.entries(freeBusyResponse.data.calendars || {})) {
301 | if (calendarInfo.busy && calendarInfo.busy.length > 0) {
302 | for (const busySlot of calendarInfo.busy) {
303 | if (this.conflictAnalyzer.checkBusyConflict(eventToCheck, busySlot)) {
304 | conflicts.push({
305 | type: 'overlap',
306 | calendar: calendarId,
307 | event: {
308 | id: 'busy-time',
309 | title: 'Busy (details unavailable)',
310 | start: busySlot.start || undefined,
311 | end: busySlot.end || undefined
312 | }
313 | });
314 | }
315 | }
316 | }
317 | }
318 | } catch (error) {
319 | console.error('Failed to check free/busy:', error);
320 | }
321 |
322 | return conflicts;
323 | }
324 | }
```
--------------------------------------------------------------------------------
/src/tests/integration/test-data-factory.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Test data factory utilities for integration tests
2 |
3 | export interface TestEvent {
4 | id?: string;
5 | summary: string;
6 | description?: string;
7 | start: string;
8 | end: string;
9 | timeZone?: string; // Optional for all-day events
10 | location?: string;
11 | attendees?: Array<{ email: string }>;
12 | colorId?: string;
13 | reminders?: {
14 | useDefault: boolean;
15 | overrides?: Array<{ method: "email" | "popup"; minutes: number }>;
16 | };
17 | recurrence?: string[];
18 | modificationScope?: "thisAndFollowing" | "all" | "thisEventOnly";
19 | originalStartTime?: string;
20 | futureStartDate?: string;
21 | calendarId?: string;
22 | sendUpdates?: "all" | "externalOnly" | "none";
23 | }
24 |
25 | export interface PerformanceMetric {
26 | operation: string;
27 | startTime: number;
28 | endTime: number;
29 | duration: number;
30 | success: boolean;
31 | error?: string;
32 | }
33 |
34 | export class TestDataFactory {
35 | private static readonly TEST_CALENDAR_ID = process.env.TEST_CALENDAR_ID || 'primary';
36 |
37 | private createdEventIds: string[] = [];
38 | private performanceMetrics: PerformanceMetric[] = [];
39 |
40 | static getTestCalendarId(): string {
41 | return TestDataFactory.TEST_CALENDAR_ID;
42 | }
43 |
44 | // Helper method to format dates in RFC3339 format without milliseconds
45 | // For events with a timeZone field, use timezone-naive format to avoid conflicts
46 | public static formatDateTimeRFC3339(date: Date): string {
47 | const isoString = date.toISOString();
48 | // Return timezone-naive format (without Z suffix) to work better with timeZone field
49 | return isoString.replace(/\.\d{3}Z$/, '');
50 | }
51 |
52 | // Helper method to format dates in RFC3339 format with timezone (for search operations)
53 | public static formatDateTimeRFC3339WithTimezone(date: Date): string {
54 | return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
55 | }
56 |
57 | // Event data generators
58 | static createSingleEvent(overrides: Partial<TestEvent> = {}): TestEvent {
59 | const now = new Date();
60 | const start = new Date(now.getTime() + 2 * 60 * 60 * 1000); // 2 hours from now
61 | const end = new Date(start.getTime() + 60 * 60 * 1000); // 1 hour duration
62 |
63 | return {
64 | summary: 'Test Integration Event',
65 | description: 'Created by integration test suite',
66 | start: this.formatDateTimeRFC3339(start),
67 | end: this.formatDateTimeRFC3339(end),
68 | timeZone: 'America/Los_Angeles',
69 | location: 'Test Conference Room',
70 | reminders: {
71 | useDefault: false,
72 | overrides: [{ method: 'popup', minutes: 15 }]
73 | },
74 | ...overrides
75 | };
76 | }
77 |
78 | static createAllDayEvent(overrides: Partial<TestEvent> = {}): TestEvent {
79 | const tomorrow = new Date();
80 | tomorrow.setDate(tomorrow.getDate() + 1);
81 |
82 | const dayAfter = new Date(tomorrow);
83 | dayAfter.setDate(dayAfter.getDate() + 1);
84 |
85 | // For all-day events, use date-only format (YYYY-MM-DD)
86 | const startDate = tomorrow.toISOString().split('T')[0];
87 | const endDate = dayAfter.toISOString().split('T')[0];
88 |
89 | return {
90 | summary: 'Test All-Day Event',
91 | description: 'All-day test event',
92 | start: startDate,
93 | end: endDate,
94 | // Note: timeZone is not used for all-day events (they're date-only)
95 | ...overrides
96 | };
97 | }
98 |
99 | static createRecurringEvent(overrides: Partial<TestEvent> = {}): TestEvent {
100 | const start = new Date();
101 | start.setDate(start.getDate() + 1); // Tomorrow
102 | start.setHours(10, 0, 0, 0); // 10 AM
103 |
104 | const end = new Date(start);
105 | end.setHours(11, 0, 0, 0); // 11 AM
106 |
107 | return {
108 | summary: 'Test Recurring Meeting',
109 | description: 'Weekly recurring test meeting',
110 | start: this.formatDateTimeRFC3339(start),
111 | end: this.formatDateTimeRFC3339(end),
112 | timeZone: 'America/Los_Angeles',
113 | location: 'Recurring Meeting Room',
114 | recurrence: ['RRULE:FREQ=WEEKLY;COUNT=5'], // 5 weeks
115 | reminders: {
116 | useDefault: false,
117 | overrides: [{ method: 'email', minutes: 1440 }] // 1 day before
118 | },
119 | ...overrides
120 | };
121 | }
122 |
123 | static createEventWithAttendees(overrides: Partial<TestEvent> = {}): TestEvent {
124 | const invitee1 = process.env.INVITEE_1;
125 | const invitee2 = process.env.INVITEE_2;
126 |
127 | if (!invitee1 || !invitee2) {
128 | throw new Error('INVITEE_1 and INVITEE_2 environment variables are required for creating events with attendees');
129 | }
130 |
131 | return this.createSingleEvent({
132 | summary: 'Test Meeting with Attendees',
133 | attendees: [
134 | { email: invitee1 },
135 | { email: invitee2 }
136 | ],
137 | ...overrides
138 | });
139 | }
140 |
141 | static createColoredEvent(colorId: string, overrides: Partial<TestEvent> = {}): TestEvent {
142 | return this.createSingleEvent({
143 | summary: `Test Event - Color ${colorId}`,
144 | colorId,
145 | ...overrides
146 | });
147 | }
148 |
149 | // Time range generators
150 | static getTimeRanges() {
151 | const now = new Date();
152 |
153 | return {
154 | // Past week
155 | pastWeek: {
156 | timeMin: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)),
157 | timeMax: this.formatDateTimeRFC3339WithTimezone(now)
158 | },
159 | // Next week
160 | nextWeek: {
161 | timeMin: this.formatDateTimeRFC3339WithTimezone(now),
162 | timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000))
163 | },
164 | // Next month
165 | nextMonth: {
166 | timeMin: this.formatDateTimeRFC3339WithTimezone(now),
167 | timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000))
168 | },
169 | // Large range (3 months)
170 | threeMonths: {
171 | timeMin: this.formatDateTimeRFC3339WithTimezone(now),
172 | timeMax: this.formatDateTimeRFC3339WithTimezone(new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000))
173 | }
174 | };
175 | }
176 |
177 | // Performance tracking
178 | startTimer(_operation: string): number {
179 | return Date.now();
180 | }
181 |
182 | endTimer(operation: string, startTime: number, success: boolean, error?: string): void {
183 | const endTime = Date.now();
184 | const duration = endTime - startTime;
185 |
186 | this.performanceMetrics.push({
187 | operation,
188 | startTime,
189 | endTime,
190 | duration,
191 | success,
192 | error
193 | });
194 | }
195 |
196 | getPerformanceMetrics(): PerformanceMetric[] {
197 | return [...this.performanceMetrics];
198 | }
199 |
200 | clearPerformanceMetrics(): void {
201 | this.performanceMetrics = [];
202 | }
203 |
204 | // Event tracking for cleanup
205 | addCreatedEventId(eventId: string): void {
206 | this.createdEventIds.push(eventId);
207 | }
208 |
209 | getCreatedEventIds(): string[] {
210 | return [...this.createdEventIds];
211 | }
212 |
213 | clearCreatedEventIds(): void {
214 | this.createdEventIds = [];
215 | }
216 |
217 | // Search queries
218 | static getSearchQueries() {
219 | return [
220 | 'Test Integration',
221 | 'meeting',
222 | 'recurring',
223 | 'attendees',
224 | 'Conference Room',
225 | 'nonexistent_query_should_return_empty'
226 | ];
227 | }
228 |
229 | // Validation helpers
230 | static validateEventResponse(response: any): boolean {
231 | if (!response || !response.content || !Array.isArray(response.content)) {
232 | return false;
233 | }
234 |
235 | const text = response.content[0]?.text;
236 | // Accept empty strings for search operations - they indicate "no results found"
237 | return typeof text === 'string';
238 | }
239 |
240 | static extractEventIdFromResponse(response: any): string | null {
241 | const text = response.content[0]?.text;
242 | if (!text) return null;
243 |
244 | // Try to parse as JSON first (v2.0 structured response)
245 | try {
246 | const parsed = JSON.parse(text);
247 | if (parsed.event?.id) {
248 | return parsed.event.id;
249 | }
250 | } catch {
251 | // Fall back to legacy text parsing
252 | }
253 |
254 | // Look for various event ID patterns in the response (legacy)
255 | // Google Calendar event IDs can contain letters, numbers, underscores, and special characters
256 | const patterns = [
257 | /Event created: .* \(([^)]+)\)/, // Legacy format - Match anything within parentheses after "Event created:"
258 | /Event updated: .* \(([^)]+)\)/, // Legacy format - Match anything within parentheses after "Event updated:"
259 | /✅ Event created successfully[\s\S]*?([^\s\(]+) \(([^)]+)\)/, // New format - Extract ID from parentheses in event details
260 | /✅ Event updated successfully[\s\S]*?([^\s\(]+) \(([^)]+)\)/, // New format - Extract ID from parentheses in event details
261 | /Event ID: ([^\s]+)/, // Match non-whitespace characters after "Event ID:"
262 | /Created event: .* \(ID: ([^)]+)\)/, // Match anything within parentheses after "ID:"
263 | /\(([[email protected]]{10,})\)/, // Specific pattern for Google Calendar IDs with common characters
264 | ];
265 |
266 | for (const pattern of patterns) {
267 | const match = text.match(pattern);
268 | if (match) {
269 | // For patterns with multiple capture groups, we want the event ID
270 | // which is typically in the last parentheses
271 | let eventId = match[match.length - 1] || match[1];
272 | if (eventId) {
273 | // Clean up the captured ID (trim whitespace)
274 | eventId = eventId.trim();
275 | // Basic validation - should be at least 10 characters
276 | if (eventId.length >= 10) {
277 | return eventId;
278 | }
279 | }
280 | }
281 | }
282 |
283 | return null;
284 | }
285 |
286 | static extractAllEventIds(response: any): string[] {
287 | const text = response.content[0]?.text;
288 | if (!text) return [];
289 |
290 | const eventIds: string[] = [];
291 |
292 | // Look for event IDs in list format - they appear in parentheses after event titles
293 | // Pattern: anything that looks like an event ID in parentheses
294 | const pattern = /\(([[email protected]]{10,})\)/g;
295 |
296 | let match;
297 | while ((match = pattern.exec(text)) !== null) {
298 | const eventId = match[1].trim();
299 | // Basic validation - should be at least 10 characters and not contain spaces
300 | if (eventId.length >= 10 && !eventId.includes(' ')) {
301 | eventIds.push(eventId);
302 | }
303 | }
304 |
305 | // Also look for Event ID: patterns
306 | const idPattern = /Event ID:\s*([[email protected]]+)/g;
307 | while ((match = idPattern.exec(text)) !== null) {
308 | const eventId = match[1].trim();
309 | if (eventId.length >= 10 && !eventIds.includes(eventId)) {
310 | eventIds.push(eventId);
311 | }
312 | }
313 |
314 | return eventIds;
315 | }
316 |
317 | // Error simulation helpers
318 | static getInvalidTestData() {
319 | return {
320 | invalidCalendarId: 'invalid_calendar_id',
321 | invalidEventId: 'invalid_event_id',
322 | invalidTimeFormat: '2024-13-45T25:99:99Z',
323 | invalidTimezone: 'Invalid/Timezone',
324 | invalidEmail: 'not-an-email',
325 | invalidColorId: '999',
326 | malformedRecurrence: ['INVALID:RRULE'],
327 | futureDateInPast: '2020-01-01T10:00:00Z'
328 | };
329 | }
330 | }
```
--------------------------------------------------------------------------------
/src/handlers/core/UpdateEventHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2 | import { OAuth2Client } from "google-auth-library";
3 | import { UpdateEventInput } from "../../tools/registry.js";
4 | import { BaseToolHandler } from "./BaseToolHandler.js";
5 | import { calendar_v3 } from 'googleapis';
6 | import { RecurringEventHelpers, RecurringEventError, RECURRING_EVENT_ERRORS } from './RecurringEventHelpers.js';
7 | import { ConflictDetectionService } from "../../services/conflict-detection/index.js";
8 | import { createTimeObject } from "../utils/datetime.js";
9 | import {
10 | createStructuredResponse,
11 | convertConflictsToStructured,
12 | createWarningsArray
13 | } from "../../utils/response-builder.js";
14 | import {
15 | UpdateEventResponse,
16 | convertGoogleEventToStructured
17 | } from "../../types/structured-responses.js";
18 |
19 | export class UpdateEventHandler extends BaseToolHandler {
20 | private conflictDetectionService: ConflictDetectionService;
21 |
22 | constructor() {
23 | super();
24 | this.conflictDetectionService = new ConflictDetectionService();
25 | }
26 |
27 | async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
28 | const validArgs = args as UpdateEventInput;
29 |
30 | // Check for conflicts if enabled
31 | let conflicts = null;
32 | if (validArgs.checkConflicts !== false && (validArgs.start || validArgs.end)) {
33 | // Get the existing event to merge with updates
34 | const calendar = this.getCalendar(oauth2Client);
35 | const existingEvent = await calendar.events.get({
36 | calendarId: validArgs.calendarId,
37 | eventId: validArgs.eventId
38 | });
39 |
40 | if (!existingEvent.data) {
41 | throw new Error('Event not found');
42 | }
43 |
44 | // Create updated event object for conflict checking
45 | const timezone = validArgs.timeZone || await this.getCalendarTimezone(oauth2Client, validArgs.calendarId);
46 | const eventToCheck: calendar_v3.Schema$Event = {
47 | ...existingEvent.data,
48 | id: validArgs.eventId,
49 | summary: validArgs.summary || existingEvent.data.summary,
50 | description: validArgs.description || existingEvent.data.description,
51 | start: validArgs.start ? createTimeObject(validArgs.start, timezone) : existingEvent.data.start,
52 | end: validArgs.end ? createTimeObject(validArgs.end, timezone) : existingEvent.data.end,
53 | location: validArgs.location || existingEvent.data.location,
54 | };
55 |
56 | // Check for conflicts
57 | conflicts = await this.conflictDetectionService.checkConflicts(
58 | oauth2Client,
59 | eventToCheck,
60 | validArgs.calendarId,
61 | {
62 | checkDuplicates: false, // Don't check duplicates for updates
63 | checkConflicts: true,
64 | calendarsToCheck: validArgs.calendarsToCheck || [validArgs.calendarId]
65 | }
66 | );
67 | }
68 |
69 | // Update the event
70 | const event = await this.updateEventWithScope(oauth2Client, validArgs);
71 |
72 | // Create structured response
73 | const response: UpdateEventResponse = {
74 | event: convertGoogleEventToStructured(event, validArgs.calendarId)
75 | };
76 |
77 | // Add conflict information if present
78 | if (conflicts && conflicts.hasConflicts) {
79 | const structuredConflicts = convertConflictsToStructured(conflicts);
80 | if (structuredConflicts.conflicts) {
81 | response.conflicts = structuredConflicts.conflicts;
82 | }
83 | response.warnings = createWarningsArray(conflicts);
84 | }
85 |
86 | return createStructuredResponse(response);
87 | }
88 |
89 | private async updateEventWithScope(
90 | client: OAuth2Client,
91 | args: UpdateEventInput
92 | ): Promise<calendar_v3.Schema$Event> {
93 | try {
94 | const calendar = this.getCalendar(client);
95 | const helpers = new RecurringEventHelpers(calendar);
96 |
97 | // Get calendar's default timezone if not provided
98 | const defaultTimeZone = await this.getCalendarTimezone(client, args.calendarId);
99 |
100 | // Detect event type and validate scope usage
101 | const eventType = await helpers.detectEventType(args.eventId, args.calendarId);
102 |
103 | if (args.modificationScope && args.modificationScope !== 'all' && eventType !== 'recurring') {
104 | throw new RecurringEventError(
105 | 'Scope other than "all" only applies to recurring events',
106 | RECURRING_EVENT_ERRORS.NON_RECURRING_SCOPE
107 | );
108 | }
109 |
110 | switch (args.modificationScope) {
111 | case 'thisEventOnly':
112 | return this.updateSingleInstance(helpers, args, defaultTimeZone);
113 | case 'all':
114 | case undefined:
115 | return this.updateAllInstances(helpers, args, defaultTimeZone);
116 | case 'thisAndFollowing':
117 | return this.updateFutureInstances(helpers, args, defaultTimeZone);
118 | default:
119 | throw new RecurringEventError(
120 | `Invalid modification scope: ${args.modificationScope}`,
121 | RECURRING_EVENT_ERRORS.INVALID_SCOPE
122 | );
123 | }
124 | } catch (error) {
125 | if (error instanceof RecurringEventError) {
126 | throw error;
127 | }
128 | throw this.handleGoogleApiError(error);
129 | }
130 | }
131 |
132 | private async updateSingleInstance(
133 | helpers: RecurringEventHelpers,
134 | args: UpdateEventInput,
135 | defaultTimeZone: string
136 | ): Promise<calendar_v3.Schema$Event> {
137 | if (!args.originalStartTime) {
138 | throw new RecurringEventError(
139 | 'originalStartTime is required for single instance updates',
140 | RECURRING_EVENT_ERRORS.MISSING_ORIGINAL_TIME
141 | );
142 | }
143 |
144 | const calendar = helpers.getCalendar();
145 | const instanceId = helpers.formatInstanceId(args.eventId, args.originalStartTime);
146 |
147 | const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
148 | const conferenceDataVersion = requestBody.conferenceData !== undefined ? 1 : undefined;
149 | const supportsAttachments = requestBody.attachments !== undefined ? true : undefined;
150 |
151 | const response = await calendar.events.patch({
152 | calendarId: args.calendarId,
153 | eventId: instanceId,
154 | requestBody,
155 | ...(conferenceDataVersion && { conferenceDataVersion }),
156 | ...(supportsAttachments && { supportsAttachments })
157 | });
158 |
159 | if (!response.data) throw new Error('Failed to update event instance');
160 | return response.data;
161 | }
162 |
163 | private async updateAllInstances(
164 | helpers: RecurringEventHelpers,
165 | args: UpdateEventInput,
166 | defaultTimeZone: string
167 | ): Promise<calendar_v3.Schema$Event> {
168 | const calendar = helpers.getCalendar();
169 |
170 | const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
171 | const conferenceDataVersion = requestBody.conferenceData !== undefined ? 1 : undefined;
172 | const supportsAttachments = requestBody.attachments !== undefined ? true : undefined;
173 |
174 | const response = await calendar.events.patch({
175 | calendarId: args.calendarId,
176 | eventId: args.eventId,
177 | requestBody,
178 | ...(conferenceDataVersion && { conferenceDataVersion }),
179 | ...(supportsAttachments && { supportsAttachments })
180 | });
181 |
182 | if (!response.data) throw new Error('Failed to update event');
183 | return response.data;
184 | }
185 |
186 | private async updateFutureInstances(
187 | helpers: RecurringEventHelpers,
188 | args: UpdateEventInput,
189 | defaultTimeZone: string
190 | ): Promise<calendar_v3.Schema$Event> {
191 | if (!args.futureStartDate) {
192 | throw new RecurringEventError(
193 | 'futureStartDate is required for future instance updates',
194 | RECURRING_EVENT_ERRORS.MISSING_FUTURE_DATE
195 | );
196 | }
197 |
198 | const calendar = helpers.getCalendar();
199 | const effectiveTimeZone = args.timeZone || defaultTimeZone;
200 |
201 | // 1. Get original event
202 | const originalResponse = await calendar.events.get({
203 | calendarId: args.calendarId,
204 | eventId: args.eventId
205 | });
206 | const originalEvent = originalResponse.data;
207 |
208 | if (!originalEvent.recurrence) {
209 | throw new Error('Event does not have recurrence rules');
210 | }
211 |
212 | // 2. Calculate UNTIL date and update original event
213 | const untilDate = helpers.calculateUntilDate(args.futureStartDate);
214 | const updatedRecurrence = helpers.updateRecurrenceWithUntil(originalEvent.recurrence, untilDate);
215 |
216 | await calendar.events.patch({
217 | calendarId: args.calendarId,
218 | eventId: args.eventId,
219 | requestBody: { recurrence: updatedRecurrence }
220 | });
221 |
222 | // 3. Create new recurring event starting from future date
223 | const requestBody = helpers.buildUpdateRequestBody(args, defaultTimeZone);
224 |
225 | // Calculate end time if start time is changing
226 | let endTime = args.end;
227 | if (args.start || args.futureStartDate) {
228 | const newStartTime = args.start || args.futureStartDate;
229 | endTime = endTime || helpers.calculateEndTime(newStartTime, originalEvent);
230 | }
231 |
232 | const newEvent = {
233 | ...helpers.cleanEventForDuplication(originalEvent),
234 | ...requestBody,
235 | start: {
236 | dateTime: args.start || args.futureStartDate,
237 | timeZone: effectiveTimeZone
238 | },
239 | end: {
240 | dateTime: endTime,
241 | timeZone: effectiveTimeZone
242 | }
243 | };
244 |
245 | const conferenceDataVersion = newEvent.conferenceData !== undefined ? 1 : undefined;
246 | const supportsAttachments = newEvent.attachments !== undefined ? true : undefined;
247 |
248 | const response = await calendar.events.insert({
249 | calendarId: args.calendarId,
250 | requestBody: newEvent,
251 | ...(conferenceDataVersion && { conferenceDataVersion }),
252 | ...(supportsAttachments && { supportsAttachments })
253 | });
254 |
255 | if (!response.data) throw new Error('Failed to create new recurring event');
256 | return response.data;
257 | }
258 |
259 | }
260 |
```
--------------------------------------------------------------------------------
/src/handlers/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { calendar_v3 } from "googleapis";
2 | import { ConflictCheckResult } from "../services/conflict-detection/types.js";
3 |
4 | /**
5 | * Generates a Google Calendar event view URL
6 | */
7 | export function generateEventUrl(calendarId: string, eventId: string): string {
8 | const encodedCalendarId = encodeURIComponent(calendarId);
9 | const encodedEventId = encodeURIComponent(eventId);
10 | return `https://calendar.google.com/calendar/event?eid=${encodedEventId}&cid=${encodedCalendarId}`;
11 | }
12 |
13 | /**
14 | * Gets the URL for a calendar event
15 | */
16 | export function getEventUrl(event: calendar_v3.Schema$Event, calendarId?: string): string | null {
17 | if (event.htmlLink) {
18 | return event.htmlLink;
19 | } else if (calendarId && event.id) {
20 | return generateEventUrl(calendarId, event.id);
21 | }
22 | return null;
23 | }
24 |
25 | /**
26 | * Formats a date/time with timezone abbreviation
27 | */
28 | function formatDateTime(dateTime?: string | null, date?: string | null, timeZone?: string): string {
29 | if (!dateTime && !date) return "unspecified";
30 |
31 | try {
32 | const dt = dateTime || date;
33 | if (!dt) return "unspecified";
34 |
35 | // If it's a date-only event (all-day), handle it specially
36 | if (date && !dateTime) {
37 | // For all-day events, just format the date string directly
38 | // Date-only strings like "2025-03-15" should be displayed as-is
39 | const [year, month, day] = date.split('-').map(Number);
40 |
41 | // Create a date string without any timezone conversion
42 | const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
43 | 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
44 | const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
45 |
46 | // Calculate day of week using Zeller's congruence (timezone-independent)
47 | const q = day;
48 | const m = month <= 2 ? month + 12 : month;
49 | const y = month <= 2 ? year - 1 : year;
50 | const k = y % 100;
51 | const j = Math.floor(y / 100);
52 | const h = (q + Math.floor((13 * (m + 1)) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) - 2 * j) % 7;
53 | const dayOfWeek = (h + 6) % 7; // Convert to 0=Sunday format
54 |
55 | return `${dayNames[dayOfWeek]}, ${monthNames[month - 1]} ${day}, ${year}`;
56 | }
57 |
58 | const parsedDate = new Date(dt);
59 | if (isNaN(parsedDate.getTime())) return dt;
60 |
61 | // For timed events, include timezone
62 | const options: Intl.DateTimeFormatOptions = {
63 | weekday: 'short',
64 | year: 'numeric',
65 | month: 'short',
66 | day: 'numeric',
67 | hour: 'numeric',
68 | minute: '2-digit',
69 | timeZoneName: 'short'
70 | };
71 |
72 | if (timeZone) {
73 | options.timeZone = timeZone;
74 | }
75 |
76 | return parsedDate.toLocaleString('en-US', options);
77 | } catch (error) {
78 | return dateTime || date || "unspecified";
79 | }
80 | }
81 |
82 | /**
83 | * Formats attendees with their response status
84 | */
85 | function formatAttendees(attendees?: calendar_v3.Schema$EventAttendee[]): string {
86 | if (!attendees || attendees.length === 0) return "";
87 |
88 | const formatted = attendees.map(attendee => {
89 | const email = attendee.email || "unknown";
90 | const name = attendee.displayName || email;
91 | const status = attendee.responseStatus || "unknown";
92 |
93 | const statusText = {
94 | 'accepted': 'accepted',
95 | 'declined': 'declined',
96 | 'tentative': 'tentative',
97 | 'needsAction': 'pending'
98 | }[status] || 'unknown';
99 |
100 | return `${name} (${statusText})`;
101 | }).join(", ");
102 |
103 | return `\nGuests: ${formatted}`;
104 | }
105 |
106 | /**
107 | * Formats a single event with rich details
108 | */
109 | export function formatEventWithDetails(event: calendar_v3.Schema$Event, calendarId?: string): string {
110 | const title = event.summary ? `Event: ${event.summary}` : "Untitled Event";
111 | const eventId = event.id ? `\nEvent ID: ${event.id}` : "";
112 | const description = event.description ? `\nDescription: ${event.description}` : "";
113 | const location = event.location ? `\nLocation: ${event.location}` : "";
114 | const colorId = event.colorId ? `\nColor ID: ${event.colorId}` : "";
115 |
116 | // Format start and end times with timezone
117 | const startTime = formatDateTime(event.start?.dateTime, event.start?.date, event.start?.timeZone || undefined);
118 | const endTime = formatDateTime(event.end?.dateTime, event.end?.date, event.end?.timeZone || undefined);
119 |
120 | let timeInfo: string;
121 | if (event.start?.date) {
122 | // All-day event
123 | if (event.start.date === event.end?.date) {
124 | // Single day all-day event
125 | timeInfo = `\nDate: ${startTime}`;
126 | } else {
127 | // Multi-day all-day event - end date is exclusive, so subtract 1 day for display
128 | if (event.end?.date) {
129 | // Parse the end date properly without timezone conversion
130 | const [year, month, day] = event.end.date.split('-').map(Number);
131 |
132 | // Subtract 1 day since end is exclusive, handling month/year boundaries
133 | let adjustedDay = day - 1;
134 | let adjustedMonth = month;
135 | let adjustedYear = year;
136 |
137 | if (adjustedDay < 1) {
138 | adjustedMonth--;
139 | if (adjustedMonth < 1) {
140 | adjustedMonth = 12;
141 | adjustedYear--;
142 | }
143 | // Get days in the previous month
144 | const daysInMonth = new Date(adjustedYear, adjustedMonth, 0).getDate();
145 | adjustedDay = daysInMonth;
146 | }
147 |
148 | // Format without using Date object to avoid timezone issues
149 | const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
150 | 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
151 | const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
152 |
153 | // Calculate day of week using Zeller's congruence
154 | const q = adjustedDay;
155 | const m = adjustedMonth <= 2 ? adjustedMonth + 12 : adjustedMonth;
156 | const y = adjustedMonth <= 2 ? adjustedYear - 1 : adjustedYear;
157 | const k = y % 100;
158 | const j = Math.floor(y / 100);
159 | const h = (q + Math.floor((13 * (m + 1)) / 5) + k + Math.floor(k / 4) + Math.floor(j / 4) - 2 * j) % 7;
160 | const dayOfWeek = (h + 6) % 7; // Convert to 0=Sunday format
161 |
162 | const adjustedEndTime = `${dayNames[dayOfWeek]}, ${monthNames[adjustedMonth - 1]} ${adjustedDay}, ${adjustedYear}`;
163 | timeInfo = `\nStart Date: ${startTime}\nEnd Date: ${adjustedEndTime}`;
164 | } else {
165 | timeInfo = `\nStart Date: ${startTime}`;
166 | }
167 | }
168 | } else {
169 | // Timed event
170 | timeInfo = `\nStart: ${startTime}\nEnd: ${endTime}`;
171 | }
172 |
173 | const attendeeInfo = formatAttendees(event.attendees);
174 |
175 | const eventUrl = getEventUrl(event, calendarId);
176 | const urlInfo = eventUrl ? `\nView: ${eventUrl}` : "";
177 |
178 | return `${title}${eventId}${description}${timeInfo}${location}${colorId}${attendeeInfo}${urlInfo}`;
179 | }
180 |
181 | /**
182 | * Formats conflict check results for display
183 | */
184 | export function formatConflictWarnings(conflicts: ConflictCheckResult): string {
185 | if (!conflicts.hasConflicts) return "";
186 |
187 | let warnings = "";
188 |
189 | // Format duplicate warnings
190 | if (conflicts.duplicates.length > 0) {
191 | warnings += "\n\n⚠️ POTENTIAL DUPLICATES DETECTED:";
192 | for (const dup of conflicts.duplicates) {
193 | warnings += `\n\n━━━ Duplicate Event (${Math.round(dup.event.similarity * 100)}% similar) ━━━`;
194 | warnings += `\n${dup.suggestion}`;
195 |
196 | // Show full event details if available
197 | if (dup.fullEvent) {
198 | warnings += `\n\nExisting event details:`;
199 | warnings += `\n${formatEventWithDetails(dup.fullEvent, dup.calendarId)}`;
200 | } else {
201 | // Fallback to basic info
202 | warnings += `\n• "${dup.event.title}"`;
203 | if (dup.event.url) {
204 | warnings += `\n View existing event: ${dup.event.url}`;
205 | }
206 | }
207 | }
208 | }
209 |
210 | // Format conflict warnings
211 | if (conflicts.conflicts.length > 0) {
212 | warnings += "\n\n⚠️ SCHEDULING CONFLICTS DETECTED:";
213 | const conflictsByCalendar = conflicts.conflicts.reduce((acc, conflict) => {
214 | if (!acc[conflict.calendar]) acc[conflict.calendar] = [];
215 | acc[conflict.calendar].push(conflict);
216 | return acc;
217 | }, {} as Record<string, typeof conflicts.conflicts>);
218 |
219 | for (const [calendar, calendarConflicts] of Object.entries(conflictsByCalendar)) {
220 | warnings += `\n\nCalendar: ${calendar}`;
221 | for (const conflict of calendarConflicts) {
222 | warnings += `\n\n━━━ Conflicting Event ━━━`;
223 | if (conflict.overlap) {
224 | warnings += `\n⚠️ Overlap: ${conflict.overlap.duration} (${conflict.overlap.percentage}% of your event)`;
225 | }
226 |
227 | // Show full event details if available
228 | if (conflict.fullEvent) {
229 | warnings += `\n\nConflicting event details:`;
230 | warnings += `\n${formatEventWithDetails(conflict.fullEvent, calendar)}`;
231 | } else {
232 | // Fallback to basic info
233 | warnings += `\n• Conflicts with "${conflict.event.title}"`;
234 | if (conflict.event.start && conflict.event.end) {
235 | const start = formatDateTime(conflict.event.start);
236 | const end = formatDateTime(conflict.event.end);
237 | warnings += `\n Time: ${start} - ${end}`;
238 | }
239 | if (conflict.event.url) {
240 | warnings += `\n View event: ${conflict.event.url}`;
241 | }
242 | }
243 | }
244 | }
245 | }
246 |
247 | return warnings;
248 | }
249 |
250 | /**
251 | * Creates a response with event details and optional conflict warnings
252 | */
253 | export function createEventResponseWithConflicts(
254 | event: calendar_v3.Schema$Event,
255 | calendarId: string,
256 | conflicts?: ConflictCheckResult,
257 | actionVerb: string = "created"
258 | ): string {
259 | const eventDetails = formatEventWithDetails(event, calendarId);
260 | const conflictWarnings = conflicts ? formatConflictWarnings(conflicts) : "";
261 |
262 | const successMessage = conflicts?.hasConflicts
263 | ? `Event ${actionVerb} with warnings!`
264 | : `Event ${actionVerb} successfully!`;
265 |
266 | return `${successMessage}\n\n${eventDetails}${conflictWarnings}`;
267 | }
268 |
269 |
```
--------------------------------------------------------------------------------
/src/tests/unit/schemas/schema-compatibility.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from 'vitest';
2 | import { ToolRegistry } from '../../../tools/registry.js';
3 |
4 | /**
5 | * Provider-Specific Schema Compatibility Tests
6 | *
7 | * These tests ensure that schemas are compatible with different MCP clients
8 | * by testing what each provider actually receives, not internal implementation.
9 | *
10 | * - OpenAI: Receives converted schemas (anyOf flattened to string)
11 | * - Python MCP: Receives raw schemas (anyOf preserved for native array support)
12 | * - Claude: Uses raw MCP schemas
13 | */
14 |
15 | // Type for JSON Schema objects (subset of what zod-to-json-schema returns)
16 | interface JSONSchemaObject {
17 | type?: string;
18 | properties?: Record<string, any>;
19 | required?: string[];
20 | anyOf?: any[];
21 | [key: string]: any;
22 | }
23 |
24 | describe('Provider-Specific Schema Compatibility', () => {
25 | describe('OpenAI Schema Compatibility', () => {
26 | // Helper function that mimics OpenAI schema conversion from openai-mcp-integration.test.ts
27 | const convertMCPSchemaToOpenAI = (mcpSchema: any): any => {
28 | if (!mcpSchema) {
29 | return {
30 | type: 'object',
31 | properties: {},
32 | required: []
33 | };
34 | }
35 |
36 | return {
37 | type: 'object',
38 | properties: enhancePropertiesForOpenAI(mcpSchema.properties || {}),
39 | required: mcpSchema.required || []
40 | };
41 | };
42 |
43 | const enhancePropertiesForOpenAI = (properties: any): any => {
44 | const enhanced: any = {};
45 |
46 | for (const [key, value] of Object.entries(properties)) {
47 | const prop = value as any;
48 | enhanced[key] = { ...prop };
49 |
50 | // Handle anyOf union types (OpenAI doesn't support these well)
51 | if (prop.anyOf && Array.isArray(prop.anyOf)) {
52 | const stringType = prop.anyOf.find((t: any) => t.type === 'string');
53 | if (stringType) {
54 | enhanced[key] = {
55 | type: 'string',
56 | description: `${stringType.description || prop.description || ''} Note: For multiple values, use JSON array string format: '["id1", "id2"]'`.trim()
57 | };
58 | } else {
59 | enhanced[key] = { ...prop.anyOf[0] };
60 | }
61 | delete enhanced[key].anyOf;
62 | }
63 |
64 | // Recursively enhance nested objects
65 | if (enhanced[key].type === 'object' && enhanced[key].properties) {
66 | enhanced[key].properties = enhancePropertiesForOpenAI(enhanced[key].properties);
67 | }
68 |
69 | // Enhance array items if they contain objects
70 | if (enhanced[key].type === 'array' && enhanced[key].items && enhanced[key].items.properties) {
71 | enhanced[key].items = {
72 | ...enhanced[key].items,
73 | properties: enhancePropertiesForOpenAI(enhanced[key].items.properties)
74 | };
75 | }
76 | }
77 |
78 | return enhanced;
79 | };
80 |
81 | it('should ensure ALL tools (including list-events) have no problematic features after OpenAI conversion', () => {
82 | const tools = ToolRegistry.getToolsWithSchemas();
83 | const problematicFeatures = ['oneOf', 'anyOf', 'allOf', 'not'];
84 | const issues: string[] = [];
85 |
86 | for (const tool of tools) {
87 | // Convert to OpenAI format (this is what OpenAI actually sees)
88 | const openaiSchema = convertMCPSchemaToOpenAI(tool.inputSchema);
89 | const schemaStr = JSON.stringify(openaiSchema);
90 |
91 | for (const feature of problematicFeatures) {
92 | if (schemaStr.includes(`"${feature}"`)) {
93 | issues.push(`Tool "${tool.name}" contains "${feature}" after OpenAI conversion - this will break OpenAI function calling`);
94 | }
95 | }
96 | }
97 |
98 | if (issues.length > 0) {
99 | throw new Error(`OpenAI schema compatibility issues found:\n${issues.join('\n')}`);
100 | }
101 | });
102 |
103 | it('should convert list-events calendarId anyOf to string for OpenAI', () => {
104 | const tools = ToolRegistry.getToolsWithSchemas();
105 | const listEventsTool = tools.find(t => t.name === 'list-events');
106 |
107 | expect(listEventsTool).toBeDefined();
108 |
109 | // Convert to OpenAI format
110 | const openaiSchema = convertMCPSchemaToOpenAI(listEventsTool!.inputSchema);
111 |
112 | // OpenAI should see a simple string type, not anyOf
113 | expect(openaiSchema.properties.calendarId.type).toBe('string');
114 | expect(openaiSchema.properties.calendarId.anyOf).toBeUndefined();
115 |
116 | // Description should mention JSON array format
117 | expect(openaiSchema.properties.calendarId.description).toContain('JSON array string format');
118 | expect(openaiSchema.properties.calendarId.description).toMatch(/\[".*"\]/);
119 | });
120 |
121 | it('should ensure all converted schemas are valid objects', () => {
122 | const tools = ToolRegistry.getToolsWithSchemas();
123 |
124 | for (const tool of tools) {
125 | const openaiSchema = convertMCPSchemaToOpenAI(tool.inputSchema);
126 |
127 | expect(openaiSchema.type).toBe('object');
128 | expect(openaiSchema.properties).toBeDefined();
129 | expect(openaiSchema.required).toBeDefined();
130 | }
131 | });
132 | });
133 |
134 | describe('Python MCP Client Compatibility', () => {
135 | it('should ensure list-events supports native arrays via anyOf', () => {
136 | const tools = ToolRegistry.getToolsWithSchemas();
137 | const listEventsTool = tools.find(t => t.name === 'list-events');
138 |
139 | expect(listEventsTool).toBeDefined();
140 |
141 | // Raw MCP schema should have anyOf for Python clients
142 | const schema = listEventsTool!.inputSchema as JSONSchemaObject;
143 | expect(schema.properties).toBeDefined();
144 |
145 | const calendarIdProp = schema.properties!.calendarId;
146 | expect(calendarIdProp.anyOf).toBeDefined();
147 | expect(Array.isArray(calendarIdProp.anyOf)).toBe(true);
148 | expect(calendarIdProp.anyOf.length).toBe(2);
149 |
150 | // Verify it has both string and array options
151 | const types = calendarIdProp.anyOf.map((t: any) => t.type);
152 | expect(types).toContain('string');
153 | expect(types).toContain('array');
154 | });
155 |
156 | it('should ensure all other tools do NOT use anyOf/oneOf/allOf', () => {
157 | const tools = ToolRegistry.getToolsWithSchemas();
158 | const problematicFeatures = ['oneOf', 'anyOf', 'allOf', 'not'];
159 | const issues: string[] = [];
160 |
161 | for (const tool of tools) {
162 | // Skip list-events - it's explicitly allowed to use anyOf
163 | if (tool.name === 'list-events') {
164 | continue;
165 | }
166 |
167 | const schemaStr = JSON.stringify(tool.inputSchema);
168 |
169 | for (const feature of problematicFeatures) {
170 | if (schemaStr.includes(`"${feature}"`)) {
171 | issues.push(`Tool "${tool.name}" contains problematic feature: ${feature}`);
172 | }
173 | }
174 | }
175 |
176 | if (issues.length > 0) {
177 | throw new Error(`Raw MCP schema compatibility issues found:\n${issues.join('\n')}`);
178 | }
179 | });
180 | });
181 |
182 | describe('General Schema Structure', () => {
183 | it('should have tools available', () => {
184 | const tools = ToolRegistry.getToolsWithSchemas();
185 | expect(tools).toBeDefined();
186 | expect(tools.length).toBeGreaterThan(0);
187 | });
188 |
189 | it('should have proper schema structure for all tools', () => {
190 | const tools = ToolRegistry.getToolsWithSchemas();
191 | expect(tools).toBeDefined();
192 | expect(tools.length).toBeGreaterThan(0);
193 |
194 | for (const tool of tools) {
195 | const schema = tool.inputSchema as JSONSchemaObject;
196 |
197 | // All schemas should be objects at the top level
198 | expect(schema.type).toBe('object');
199 | }
200 | });
201 |
202 | it('should validate specific known tool schemas exist', () => {
203 | const tools = ToolRegistry.getToolsWithSchemas();
204 | const toolSchemas = new Map();
205 | for (const tool of tools) {
206 | toolSchemas.set(tool.name, tool.inputSchema);
207 | }
208 |
209 | // Validate that key tools exist and have the proper basic structure
210 | const listEventsSchema = toolSchemas.get('list-events') as JSONSchemaObject;
211 | expect(listEventsSchema).toBeDefined();
212 | expect(listEventsSchema.type).toBe('object');
213 |
214 | if (listEventsSchema.properties) {
215 | expect(listEventsSchema.properties.calendarId).toBeDefined();
216 | expect(listEventsSchema.properties.timeMin).toBeDefined();
217 | expect(listEventsSchema.properties.timeMax).toBeDefined();
218 | }
219 |
220 | // Check other important tools exist
221 | expect(toolSchemas.get('create-event')).toBeDefined();
222 | expect(toolSchemas.get('update-event')).toBeDefined();
223 | expect(toolSchemas.get('delete-event')).toBeDefined();
224 | });
225 |
226 | it('should test that all datetime fields have proper format', () => {
227 | const tools = ToolRegistry.getToolsWithSchemas();
228 |
229 | const toolsWithDateTimeFields = ['list-events', 'search-events', 'create-event', 'update-event', 'get-freebusy'];
230 |
231 | for (const tool of tools) {
232 | if (toolsWithDateTimeFields.includes(tool.name)) {
233 | // These tools should exist and be properly typed
234 | const schema = tool.inputSchema as JSONSchemaObject;
235 | expect(schema.type).toBe('object');
236 | }
237 | }
238 | });
239 |
240 | it('should ensure enum fields are properly structured', () => {
241 | const tools = ToolRegistry.getToolsWithSchemas();
242 |
243 | const toolsWithEnums = ['update-event', 'delete-event'];
244 |
245 | for (const tool of tools) {
246 | if (toolsWithEnums.includes(tool.name)) {
247 | // These tools should exist and be properly typed
248 | const schema = tool.inputSchema as JSONSchemaObject;
249 | expect(schema.type).toBe('object');
250 | }
251 | }
252 | });
253 |
254 | it('should validate array fields have proper items definition', () => {
255 | const tools = ToolRegistry.getToolsWithSchemas();
256 |
257 | const toolsWithArrays = ['create-event', 'update-event', 'get-freebusy'];
258 |
259 | for (const tool of tools) {
260 | if (toolsWithArrays.includes(tool.name)) {
261 | // These tools should exist and be properly typed
262 | const schema = tool.inputSchema as JSONSchemaObject;
263 | expect(schema.type).toBe('object');
264 | }
265 | }
266 | });
267 | });
268 | });
269 |
270 | /**
271 | * Schema Validation Rules Documentation
272 | *
273 | * This test documents the rules that our schemas must follow
274 | * to be compatible with various MCP clients.
275 | */
276 | describe('Schema Validation Rules Documentation', () => {
277 | it('should document provider-specific compatibility requirements', () => {
278 | const rules = {
279 | 'OpenAI': 'Schemas are converted to remove anyOf/oneOf/allOf. Union types flattened to primary type with usage notes in description.',
280 | 'Python MCP': 'Native array support via anyOf for list-events.calendarId. Accepts both string and array types directly.',
281 | 'Claude/Generic MCP': 'Uses raw schemas. list-events has anyOf for flexibility, but most tools avoid union types for broad compatibility.',
282 | 'Top-level schema': 'All schemas must be type: "object" at root level.',
283 | 'DateTime fields': 'Support both RFC3339 with timezone and timezone-naive formats.',
284 | 'Array fields': 'Must have items schema defined for proper validation.',
285 | 'Enum fields': 'Must include type information alongside enum values.'
286 | };
287 |
288 | // This test documents the rules - it always passes but serves as documentation
289 | expect(Object.keys(rules).length).toBeGreaterThan(0);
290 | });
291 | });
292 |
```
--------------------------------------------------------------------------------
/src/auth/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OAuth2Client } from 'google-auth-library';
2 | import { TokenManager } from './tokenManager.js';
3 | import http from 'http';
4 | import { URL } from 'url';
5 | import open from 'open';
6 | import { loadCredentials } from './client.js';
7 | import { getAccountMode } from './utils.js';
8 |
9 | export class AuthServer {
10 | private baseOAuth2Client: OAuth2Client; // Used by TokenManager for validation/refresh
11 | private flowOAuth2Client: OAuth2Client | null = null; // Used specifically for the auth code flow
12 | private server: http.Server | null = null;
13 | private tokenManager: TokenManager;
14 | private portRange: { start: number; end: number };
15 | private activeConnections: Set<import('net').Socket> = new Set(); // Track active socket connections
16 | public authCompletedSuccessfully = false; // Flag for standalone script
17 |
18 | constructor(oauth2Client: OAuth2Client) {
19 | this.baseOAuth2Client = oauth2Client;
20 | this.tokenManager = new TokenManager(oauth2Client);
21 | this.portRange = { start: 3500, end: 3505 };
22 | }
23 |
24 | private createServer(): http.Server {
25 | const server = http.createServer(async (req, res) => {
26 | const url = new URL(req.url || '/', `http://${req.headers.host}`);
27 |
28 | if (url.pathname === '/') {
29 | // Root route - show auth link
30 | const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client;
31 | const scopes = ['https://www.googleapis.com/auth/calendar'];
32 | const authUrl = clientForUrl.generateAuthUrl({
33 | access_type: 'offline',
34 | scope: scopes,
35 | prompt: 'consent'
36 | });
37 |
38 | const accountMode = getAccountMode();
39 |
40 | res.writeHead(200, { 'Content-Type': 'text/html' });
41 | res.end(`
42 | <h1>Google Calendar Authentication</h1>
43 | <p><strong>Account Mode:</strong> <code>${accountMode}</code></p>
44 | <p>You are authenticating for the <strong>${accountMode}</strong> account.</p>
45 | <a href="${authUrl}">Authenticate with Google</a>
46 | `);
47 |
48 | } else if (url.pathname === '/oauth2callback') {
49 | // OAuth callback route
50 | const code = url.searchParams.get('code');
51 | if (!code) {
52 | res.writeHead(400, { 'Content-Type': 'text/plain' });
53 | res.end('Authorization code missing');
54 | return;
55 | }
56 |
57 | if (!this.flowOAuth2Client) {
58 | res.writeHead(500, { 'Content-Type': 'text/plain' });
59 | res.end('Authentication flow not properly initiated.');
60 | return;
61 | }
62 |
63 | try {
64 | const { tokens } = await this.flowOAuth2Client.getToken(code);
65 | await this.tokenManager.saveTokens(tokens);
66 | this.authCompletedSuccessfully = true;
67 |
68 | const tokenPath = this.tokenManager.getTokenPath();
69 | const accountMode = this.tokenManager.getAccountMode();
70 |
71 | res.writeHead(200, { 'Content-Type': 'text/html' });
72 | res.end(`
73 | <!DOCTYPE html>
74 | <html lang="en">
75 | <head>
76 | <meta charset="UTF-8">
77 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
78 | <title>Authentication Successful</title>
79 | <style>
80 | body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; }
81 | .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
82 | h1 { color: #4CAF50; }
83 | p { color: #333; margin-bottom: 0.5em; }
84 | code { background-color: #eee; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
85 | .account-mode { background-color: #e3f2fd; padding: 1em; border-radius: 5px; margin: 1em 0; }
86 | </style>
87 | </head>
88 | <body>
89 | <div class="container">
90 | <h1>Authentication Successful!</h1>
91 | <div class="account-mode">
92 | <p><strong>Account Mode:</strong> <code>${accountMode}</code></p>
93 | <p>Your authentication tokens have been saved for the <strong>${accountMode}</strong> account.</p>
94 | </div>
95 | <p>Tokens saved to:</p>
96 | <p><code>${tokenPath}</code></p>
97 | <p>You can now close this browser window.</p>
98 | </div>
99 | </body>
100 | </html>
101 | `);
102 | } catch (error: unknown) {
103 | this.authCompletedSuccessfully = false;
104 | const message = error instanceof Error ? error.message : 'Unknown error';
105 | process.stderr.write(`✗ Token save failed: ${message}\n`);
106 |
107 | res.writeHead(500, { 'Content-Type': 'text/html' });
108 | res.end(`
109 | <!DOCTYPE html>
110 | <html lang="en">
111 | <head>
112 | <meta charset="UTF-8">
113 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
114 | <title>Authentication Failed</title>
115 | <style>
116 | body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; }
117 | .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
118 | h1 { color: #F44336; }
119 | p { color: #333; }
120 | </style>
121 | </head>
122 | <body>
123 | <div class="container">
124 | <h1>Authentication Failed</h1>
125 | <p>An error occurred during authentication:</p>
126 | <p><code>${message}</code></p>
127 | <p>Please try again or check the server logs.</p>
128 | </div>
129 | </body>
130 | </html>
131 | `);
132 | }
133 | } else {
134 | // 404 for other routes
135 | res.writeHead(404, { 'Content-Type': 'text/plain' });
136 | res.end('Not Found');
137 | }
138 | });
139 |
140 | // Track connections at server level
141 | server.on('connection', (socket) => {
142 | this.activeConnections.add(socket);
143 | socket.on('close', () => {
144 | this.activeConnections.delete(socket);
145 | });
146 | });
147 |
148 | return server;
149 | }
150 |
151 | async start(openBrowser = true): Promise<boolean> {
152 | // Add timeout wrapper to prevent hanging
153 | return Promise.race([
154 | this.startWithTimeout(openBrowser),
155 | new Promise<boolean>((_, reject) => {
156 | setTimeout(() => reject(new Error('Auth server start timed out after 10 seconds')), 10000);
157 | })
158 | ]).catch(() => false); // Return false on timeout instead of throwing
159 | }
160 |
161 | private async startWithTimeout(openBrowser = true): Promise<boolean> {
162 | if (await this.tokenManager.validateTokens()) {
163 | this.authCompletedSuccessfully = true;
164 | return true;
165 | }
166 |
167 | // Try to start the server and get the port
168 | const port = await this.startServerOnAvailablePort();
169 | if (port === null) {
170 | process.stderr.write(`Could not start auth server on available port. Please check port availability (${this.portRange.start}-${this.portRange.end}) and try again.\n`);
171 |
172 | this.authCompletedSuccessfully = false;
173 | return false;
174 | }
175 |
176 | // Successfully started server on `port`. Now create the flow-specific OAuth client.
177 | try {
178 | const { client_id, client_secret } = await loadCredentials();
179 | this.flowOAuth2Client = new OAuth2Client(
180 | client_id,
181 | client_secret,
182 | `http://localhost:${port}/oauth2callback`
183 | );
184 | } catch (error) {
185 | // Could not load credentials, cannot proceed with auth flow
186 | this.authCompletedSuccessfully = false;
187 | await this.stop(); // Stop the server we just started
188 | return false;
189 | }
190 |
191 | // Generate Auth URL using the newly created flow client
192 | const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({
193 | access_type: 'offline',
194 | scope: ['https://www.googleapis.com/auth/calendar'],
195 | prompt: 'consent'
196 | });
197 |
198 | // Always show the URL in console for easy access
199 | process.stderr.write(`\n🔗 Authentication URL: ${authorizeUrl}\n\n`);
200 | process.stderr.write(`Or visit: http://localhost:${port}\n\n`);
201 |
202 | if (openBrowser) {
203 | try {
204 | await open(authorizeUrl);
205 | process.stderr.write(`Browser opened automatically. If it didn't open, use the URL above.\n`);
206 | } catch (error) {
207 | process.stderr.write(`Could not open browser automatically. Please use the URL above.\n`);
208 | }
209 | } else {
210 | process.stderr.write(`Please visit the URL above to complete authentication.\n`);
211 | }
212 |
213 | return true; // Auth flow initiated
214 | }
215 |
216 | private async startServerOnAvailablePort(): Promise<number | null> {
217 | for (let port = this.portRange.start; port <= this.portRange.end; port++) {
218 | try {
219 | await new Promise<void>((resolve, reject) => {
220 | const testServer = this.createServer();
221 | testServer.listen(port, () => {
222 | this.server = testServer; // Assign to class property *only* if successful
223 | resolve();
224 | });
225 | testServer.on('error', (err: NodeJS.ErrnoException) => {
226 | if (err.code === 'EADDRINUSE') {
227 | // Port is in use, close the test server and reject
228 | testServer.close(() => reject(err));
229 | } else {
230 | // Other error, reject
231 | reject(err);
232 | }
233 | });
234 | });
235 | return port; // Port successfully bound
236 | } catch (error: unknown) {
237 | // Check if it's EADDRINUSE, otherwise rethrow or handle
238 | if (!(error instanceof Error && 'code' in error && error.code === 'EADDRINUSE')) {
239 | // An unexpected error occurred during server start
240 | return null;
241 | }
242 | // EADDRINUSE occurred, loop continues
243 | }
244 | }
245 | return null; // No port found
246 | }
247 |
248 | public getRunningPort(): number | null {
249 | if (this.server) {
250 | const address = this.server.address();
251 | if (typeof address === 'object' && address !== null) {
252 | return address.port;
253 | }
254 | }
255 | return null;
256 | }
257 |
258 | async stop(): Promise<void> {
259 | return new Promise((resolve, reject) => {
260 | if (this.server) {
261 | // Force close all active connections
262 | for (const connection of this.activeConnections) {
263 | connection.destroy();
264 | }
265 | this.activeConnections.clear();
266 |
267 | // Add a timeout to force close if server doesn't close gracefully
268 | const timeout = setTimeout(() => {
269 | process.stderr.write('Server close timeout, forcing exit...\n');
270 | this.server = null;
271 | resolve();
272 | }, 2000); // 2 second timeout
273 |
274 | this.server.close((err) => {
275 | clearTimeout(timeout);
276 | if (err) {
277 | reject(err);
278 | } else {
279 | this.server = null;
280 | resolve();
281 | }
282 | });
283 | } else {
284 | resolve();
285 | }
286 | });
287 | }
288 | }
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/BatchRequestHandler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @jest-environment node
3 | */
4 | import { describe, it, expect, vi, beforeEach } from 'vitest';
5 | import { OAuth2Client } from 'google-auth-library';
6 | import { BatchRequestHandler, BatchRequest, BatchResponse } from '../../../handlers/core/BatchRequestHandler.js';
7 |
8 | describe('BatchRequestHandler', () => {
9 | let mockOAuth2Client: OAuth2Client;
10 | let batchHandler: BatchRequestHandler;
11 |
12 | beforeEach(() => {
13 | vi.clearAllMocks();
14 | mockOAuth2Client = {
15 | getAccessToken: vi.fn().mockResolvedValue({ token: 'mock_access_token' })
16 | } as any;
17 | batchHandler = new BatchRequestHandler(mockOAuth2Client);
18 | });
19 |
20 | describe('Batch Request Creation', () => {
21 | it('should create proper multipart request body with single request', () => {
22 | const requests: BatchRequest[] = [
23 | {
24 | method: 'GET',
25 | path: '/calendar/v3/calendars/primary/events?singleEvents=true&orderBy=startTime'
26 | }
27 | ];
28 |
29 | const result = (batchHandler as any).createBatchBody(requests);
30 | const boundary = (batchHandler as any).boundary;
31 |
32 | expect(result).toContain(`--${boundary}`);
33 | expect(result).toContain('Content-Type: application/http');
34 | expect(result).toContain('Content-ID: <item1>');
35 | expect(result).toContain('GET /calendar/v3/calendars/primary/events');
36 | expect(result).toContain('singleEvents=true');
37 | expect(result).toContain('orderBy=startTime');
38 | expect(result).toContain(`--${boundary}--`);
39 | });
40 |
41 | it('should create proper multipart request body with multiple requests', () => {
42 | const requests: BatchRequest[] = [
43 | {
44 | method: 'GET',
45 | path: '/calendar/v3/calendars/primary/events'
46 | },
47 | {
48 | method: 'GET',
49 | path: '/calendar/v3/calendars/work%40example.com/events'
50 | },
51 | {
52 | method: 'GET',
53 | path: '/calendar/v3/calendars/personal%40example.com/events'
54 | }
55 | ];
56 |
57 | const result = (batchHandler as any).createBatchBody(requests);
58 | const boundary = (batchHandler as any).boundary;
59 |
60 | expect(result).toContain('Content-ID: <item1>');
61 | expect(result).toContain('Content-ID: <item2>');
62 | expect(result).toContain('Content-ID: <item3>');
63 | expect(result).toContain('calendars/primary/events');
64 | expect(result).toContain('calendars/work%40example.com/events');
65 | expect(result).toContain('calendars/personal%40example.com/events');
66 |
67 | // Should have proper boundary structure
68 | const boundaryCount = (result.match(new RegExp(`--${boundary}`, 'g')) || []).length;
69 | expect(boundaryCount).toBe(4); // 3 request boundaries + 1 end boundary
70 | });
71 |
72 | it('should handle requests with custom headers', () => {
73 | const requests: BatchRequest[] = [
74 | {
75 | method: 'POST',
76 | path: '/calendar/v3/calendars/primary/events',
77 | headers: {
78 | 'If-Match': '"etag123"',
79 | 'X-Custom-Header': 'custom-value'
80 | }
81 | }
82 | ];
83 |
84 | const result = (batchHandler as any).createBatchBody(requests);
85 |
86 | expect(result).toContain('If-Match: "etag123"');
87 | expect(result).toContain('X-Custom-Header: custom-value');
88 | });
89 |
90 | it('should handle requests with JSON body', () => {
91 | const requestBody = {
92 | summary: 'Test Event',
93 | start: { dateTime: '2024-01-15T10:00:00Z' },
94 | end: { dateTime: '2024-01-15T11:00:00Z' }
95 | };
96 |
97 | const requests: BatchRequest[] = [
98 | {
99 | method: 'POST',
100 | path: '/calendar/v3/calendars/primary/events',
101 | body: requestBody
102 | }
103 | ];
104 |
105 | const result = (batchHandler as any).createBatchBody(requests);
106 |
107 | expect(result).toContain('Content-Type: application/json');
108 | expect(result).toContain(JSON.stringify(requestBody));
109 | expect(result).toContain('"summary":"Test Event"');
110 | });
111 |
112 | it('should encode URLs properly in batch requests', () => {
113 | const requests: BatchRequest[] = [
114 | {
115 | method: 'GET',
116 | path: '/calendar/v3/calendars/test%40example.com/events?timeMin=2024-01-01T00%3A00%3A00Z'
117 | }
118 | ];
119 |
120 | const result = (batchHandler as any).createBatchBody(requests);
121 |
122 | expect(result).toContain('calendars/test%40example.com/events');
123 | expect(result).toContain('timeMin=2024-01-01T00%3A00%3A00Z');
124 | });
125 | });
126 |
127 | describe('Batch Response Parsing', () => {
128 | it('should parse successful response correctly', () => {
129 | const mockResponseText = `HTTP/1.1 200 OK
130 | Content-Length: response_total_content_length
131 | Content-Type: multipart/mixed; boundary=batch_abc123
132 |
133 | --batch_abc123
134 | Content-Type: application/http
135 | Content-ID: <response-item1>
136 |
137 | HTTP/1.1 200 OK
138 | Content-Type: application/json
139 | Content-Length: 123
140 |
141 | {
142 | "items": [
143 | {
144 | "id": "event1",
145 | "summary": "Test Event",
146 | "start": {"dateTime": "2024-01-15T10:00:00Z"},
147 | "end": {"dateTime": "2024-01-15T11:00:00Z"}
148 | }
149 | ]
150 | }
151 |
152 | --batch_abc123--`;
153 |
154 | const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
155 |
156 | expect(responses).toHaveLength(1);
157 | expect(responses[0].statusCode).toBe(200);
158 | expect(responses[0].body.items).toHaveLength(1);
159 | expect(responses[0].body.items[0].summary).toBe('Test Event');
160 | });
161 |
162 | it('should parse multiple responses correctly', () => {
163 | const mockResponseText = `HTTP/1.1 200 OK
164 | Content-Type: multipart/mixed; boundary=batch_abc123
165 |
166 | --batch_abc123
167 | Content-Type: application/http
168 | Content-ID: <response-item1>
169 |
170 | HTTP/1.1 200 OK
171 | Content-Type: application/json
172 |
173 | {"items": [{"id": "event1", "summary": "Event 1"}]}
174 |
175 | --batch_abc123
176 | Content-Type: application/http
177 | Content-ID: <response-item2>
178 |
179 | HTTP/1.1 200 OK
180 | Content-Type: application/json
181 |
182 | {"items": [{"id": "event2", "summary": "Event 2"}]}
183 |
184 | --batch_abc123--`;
185 |
186 | const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
187 |
188 | expect(responses).toHaveLength(2);
189 | expect(responses[0].body.items[0].summary).toBe('Event 1');
190 | expect(responses[1].body.items[0].summary).toBe('Event 2');
191 | });
192 |
193 | it('should handle error responses in batch', () => {
194 | const mockResponseText = `HTTP/1.1 200 OK
195 | Content-Type: multipart/mixed; boundary=batch_abc123
196 |
197 | --batch_abc123
198 | Content-Type: application/http
199 | Content-ID: <response-item1>
200 |
201 | HTTP/1.1 404 Not Found
202 | Content-Type: application/json
203 |
204 | {
205 | "error": {
206 | "code": 404,
207 | "message": "Calendar not found"
208 | }
209 | }
210 |
211 | --batch_abc123--`;
212 |
213 | const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
214 |
215 | expect(responses).toHaveLength(1);
216 | expect(responses[0].statusCode).toBe(404);
217 | expect(responses[0].body.error.code).toBe(404);
218 | expect(responses[0].body.error.message).toBe('Calendar not found');
219 | });
220 |
221 | it('should handle mixed success and error responses', () => {
222 | const mockResponseText = `HTTP/1.1 200 OK
223 | Content-Type: multipart/mixed; boundary=batch_abc123
224 |
225 | --batch_abc123
226 | Content-Type: application/http
227 | Content-ID: <response-item1>
228 |
229 | HTTP/1.1 200 OK
230 | Content-Type: application/json
231 |
232 | {"items": [{"id": "event1", "summary": "Success"}]}
233 |
234 | --batch_abc123
235 | Content-Type: application/http
236 | Content-ID: <response-item2>
237 |
238 | HTTP/1.1 403 Forbidden
239 | Content-Type: application/json
240 |
241 | {
242 | "error": {
243 | "code": 403,
244 | "message": "Access denied"
245 | }
246 | }
247 |
248 | --batch_abc123--`;
249 |
250 | const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
251 |
252 | expect(responses).toHaveLength(2);
253 | expect(responses[0].statusCode).toBe(200);
254 | expect(responses[0].body.items[0].summary).toBe('Success');
255 | expect(responses[1].statusCode).toBe(403);
256 | expect(responses[1].body.error.message).toBe('Access denied');
257 | });
258 |
259 | it('should handle empty response parts gracefully', () => {
260 | const mockResponseText = `HTTP/1.1 200 OK
261 | Content-Type: multipart/mixed; boundary=batch_abc123
262 |
263 | --batch_abc123
264 |
265 |
266 | --batch_abc123
267 | Content-Type: application/http
268 | Content-ID: <response-item1>
269 |
270 | HTTP/1.1 200 OK
271 | Content-Type: application/json
272 |
273 | {"items": []}
274 |
275 | --batch_abc123--`;
276 |
277 | const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
278 |
279 | expect(responses).toHaveLength(1);
280 | expect(responses[0].statusCode).toBe(200);
281 | expect(responses[0].body.items).toEqual([]);
282 | });
283 |
284 | it('should handle malformed JSON gracefully', () => {
285 | const mockResponseText = `HTTP/1.1 200 OK
286 | Content-Type: multipart/mixed; boundary=batch_abc123
287 |
288 | --batch_abc123
289 | Content-Type: application/http
290 | Content-ID: <response-item1>
291 |
292 | HTTP/1.1 200 OK
293 | Content-Type: application/json
294 |
295 | {invalid json here}
296 |
297 | --batch_abc123--`;
298 |
299 | const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
300 |
301 | expect(responses).toHaveLength(1);
302 | expect(responses[0].statusCode).toBe(200);
303 | expect(responses[0].body).toBe('{invalid json here}');
304 | });
305 | });
306 |
307 | describe('Integration Tests', () => {
308 | it('should execute batch request with mocked fetch', async () => {
309 | const mockResponseText = `HTTP/1.1 200 OK
310 | Content-Type: multipart/mixed; boundary=batch_abc123
311 |
312 | --batch_abc123
313 | Content-Type: application/http
314 |
315 | HTTP/1.1 200 OK
316 | Content-Type: application/json
317 |
318 | {"items": [{"id": "event1", "summary": "Test"}]}
319 |
320 | --batch_abc123--`;
321 |
322 | global.fetch = vi.fn().mockResolvedValue({
323 | ok: true,
324 | status: 200,
325 | statusText: 'OK',
326 | text: () => Promise.resolve(mockResponseText)
327 | });
328 |
329 | const requests: BatchRequest[] = [
330 | {
331 | method: 'GET',
332 | path: '/calendar/v3/calendars/primary/events'
333 | }
334 | ];
335 |
336 | const responses = await batchHandler.executeBatch(requests);
337 |
338 | expect(global.fetch).toHaveBeenCalledWith(
339 | 'https://www.googleapis.com/batch/calendar/v3',
340 | expect.objectContaining({
341 | method: 'POST',
342 | headers: expect.objectContaining({
343 | 'Authorization': 'Bearer mock_access_token',
344 | 'Content-Type': expect.stringContaining('multipart/mixed; boundary=')
345 | })
346 | })
347 | );
348 |
349 | expect(responses).toHaveLength(1);
350 | expect(responses[0].statusCode).toBe(200);
351 | });
352 |
353 | it('should handle network errors during batch execution', async () => {
354 | // Create a handler with no retries for this test
355 | const noRetryHandler = new BatchRequestHandler(mockOAuth2Client);
356 | (noRetryHandler as any).maxRetries = 0; // Override max retries
357 |
358 | global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
359 |
360 | const requests: BatchRequest[] = [
361 | {
362 | method: 'GET',
363 | path: '/calendar/v3/calendars/primary/events'
364 | }
365 | ];
366 |
367 | await expect(noRetryHandler.executeBatch(requests))
368 | .rejects.toThrow('Failed to execute batch request: Network error');
369 | });
370 |
371 | it('should handle authentication errors', async () => {
372 | mockOAuth2Client.getAccessToken = vi.fn().mockRejectedValue(
373 | new Error('Authentication failed')
374 | );
375 |
376 | const requests: BatchRequest[] = [
377 | {
378 | method: 'GET',
379 | path: '/calendar/v3/calendars/primary/events'
380 | }
381 | ];
382 |
383 | await expect(batchHandler.executeBatch(requests))
384 | .rejects.toThrow('Authentication failed');
385 | });
386 | });
387 | });
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/CalendarNameResolution.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Unit tests for calendar name resolution feature
3 | * Tests the resolveCalendarId and resolveCalendarIds methods in BaseToolHandler
4 | */
5 | import { describe, it, expect, vi, beforeEach } from 'vitest';
6 | import { ListEventsHandler } from '../../../handlers/core/ListEventsHandler.js';
7 | import { OAuth2Client } from 'google-auth-library';
8 | import { google } from 'googleapis';
9 |
10 | // Mock googleapis globally
11 | vi.mock('googleapis', () => ({
12 | google: {
13 | calendar: vi.fn(() => ({
14 | events: {
15 | list: vi.fn()
16 | },
17 | calendarList: {
18 | list: vi.fn(),
19 | get: vi.fn()
20 | }
21 | }))
22 | }
23 | }));
24 |
25 | describe('Calendar Name Resolution', () => {
26 | const mockOAuth2Client = {
27 | getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
28 | } as unknown as OAuth2Client;
29 |
30 | let handler: ListEventsHandler;
31 | let mockCalendar: any;
32 |
33 | beforeEach(() => {
34 | handler = new ListEventsHandler();
35 | mockCalendar = {
36 | events: {
37 | list: vi.fn().mockResolvedValue({
38 | data: {
39 | items: []
40 | }
41 | })
42 | },
43 | calendarList: {
44 | list: vi.fn().mockResolvedValue({
45 | data: {
46 | items: [
47 | {
48 | id: 'primary',
49 | summary: 'Primary Calendar',
50 | summaryOverride: undefined
51 | },
52 | {
53 | id: '[email protected]',
54 | summary: 'Engineering Team - Project Alpha - Q4 2024',
55 | summaryOverride: 'Work Calendar'
56 | },
57 | {
58 | id: '[email protected]',
59 | summary: 'Personal Calendar',
60 | summaryOverride: undefined
61 | },
62 | {
63 | id: '[email protected]',
64 | summary: 'Team Events',
65 | summaryOverride: 'My Team'
66 | }
67 | ]
68 | }
69 | }),
70 | get: vi.fn().mockResolvedValue({
71 | data: { timeZone: 'UTC' }
72 | })
73 | }
74 | };
75 | vi.mocked(google.calendar).mockReturnValue(mockCalendar);
76 | });
77 |
78 | describe('summaryOverride matching priority', () => {
79 | it('should match summaryOverride before summary (exact match)', async () => {
80 | const args = {
81 | calendarId: 'Work Calendar',
82 | timeMin: '2025-06-02T00:00:00Z',
83 | timeMax: '2025-06-09T23:59:59Z'
84 | };
85 |
86 | await handler.runTool(args, mockOAuth2Client);
87 |
88 | // Should have called events.list with the resolved ID
89 | expect(mockCalendar.events.list).toHaveBeenCalledWith(
90 | expect.objectContaining({
91 | calendarId: '[email protected]'
92 | })
93 | );
94 | });
95 |
96 | it('should fall back to summary if summaryOverride does not match', async () => {
97 | const args = {
98 | calendarId: 'Personal Calendar',
99 | timeMin: '2025-06-02T00:00:00Z',
100 | timeMax: '2025-06-09T23:59:59Z'
101 | };
102 |
103 | await handler.runTool(args, mockOAuth2Client);
104 |
105 | expect(mockCalendar.events.list).toHaveBeenCalledWith(
106 | expect.objectContaining({
107 | calendarId: '[email protected]'
108 | })
109 | );
110 | });
111 |
112 | it('should match summaryOverride case-insensitively', async () => {
113 | const args = {
114 | calendarId: 'WORK CALENDAR',
115 | timeMin: '2025-06-02T00:00:00Z',
116 | timeMax: '2025-06-09T23:59:59Z'
117 | };
118 |
119 | await handler.runTool(args, mockOAuth2Client);
120 |
121 | expect(mockCalendar.events.list).toHaveBeenCalledWith(
122 | expect.objectContaining({
123 | calendarId: '[email protected]'
124 | })
125 | );
126 | });
127 |
128 | it('should match summary case-insensitively', async () => {
129 | const args = {
130 | calendarId: 'personal calendar',
131 | timeMin: '2025-06-02T00:00:00Z',
132 | timeMax: '2025-06-09T23:59:59Z'
133 | };
134 |
135 | await handler.runTool(args, mockOAuth2Client);
136 |
137 | expect(mockCalendar.events.list).toHaveBeenCalledWith(
138 | expect.objectContaining({
139 | calendarId: '[email protected]'
140 | })
141 | );
142 | });
143 |
144 | it('should prefer summaryOverride over similar summary name', async () => {
145 | // Even if there's a calendar with summary "My Team",
146 | // it should match the summaryOverride first
147 | const args = {
148 | calendarId: 'My Team',
149 | timeMin: '2025-06-02T00:00:00Z',
150 | timeMax: '2025-06-09T23:59:59Z'
151 | };
152 |
153 | await handler.runTool(args, mockOAuth2Client);
154 |
155 | expect(mockCalendar.events.list).toHaveBeenCalledWith(
156 | expect.objectContaining({
157 | calendarId: '[email protected]'
158 | })
159 | );
160 | });
161 | });
162 |
163 | describe('multiple calendar name resolution', () => {
164 | it('should resolve multiple calendar names including summaryOverride', async () => {
165 | const args = {
166 | calendarId: ['Work Calendar', 'Personal Calendar'], // Pass as array, not JSON string
167 | timeMin: '2025-06-02T00:00:00Z',
168 | timeMax: '2025-06-09T23:59:59Z'
169 | };
170 |
171 | // Mock fetch for batch requests
172 | global.fetch = vi.fn().mockResolvedValue({
173 | ok: true,
174 | status: 200,
175 | headers: {
176 | get: vi.fn()
177 | },
178 | text: () => Promise.resolve(`--batch_boundary
179 | Content-Type: application/http
180 | Content-ID: <item1>
181 |
182 | HTTP/1.1 200 OK
183 | Content-Type: application/json
184 |
185 | {"items": []}
186 |
187 | --batch_boundary
188 | Content-Type: application/http
189 | Content-ID: <item2>
190 |
191 | HTTP/1.1 200 OK
192 | Content-Type: application/json
193 |
194 | {"items": []}
195 |
196 | --batch_boundary--`)
197 | });
198 |
199 | await handler.runTool(args, mockOAuth2Client);
200 |
201 | // Should have called fetch with both resolved calendar IDs
202 | expect(global.fetch).toHaveBeenCalled();
203 | const fetchCall = vi.mocked(global.fetch).mock.calls[0];
204 | const requestBody = fetchCall[1]?.body as string;
205 |
206 | // Calendar IDs may be URL-encoded in batch request
207 | expect(requestBody).toMatch(/work@example\.com|work%40example\.com/);
208 | expect(requestBody).toMatch(/personal@example\.com|personal%40example\.com/);
209 | });
210 |
211 | it('should resolve mix of IDs, summary names, and summaryOverride names', async () => {
212 | const args = {
213 | calendarId: ['primary', 'Work Calendar', 'Personal Calendar'], // Pass as array
214 | timeMin: '2025-06-02T00:00:00Z',
215 | timeMax: '2025-06-09T23:59:59Z'
216 | };
217 |
218 | global.fetch = vi.fn().mockResolvedValue({
219 | ok: true,
220 | status: 200,
221 | headers: {
222 | get: vi.fn()
223 | },
224 | text: () => Promise.resolve(`--batch_boundary
225 | Content-Type: application/http
226 |
227 | HTTP/1.1 200 OK
228 |
229 | {"items": []}
230 | --batch_boundary--`)
231 | });
232 |
233 | await handler.runTool(args, mockOAuth2Client);
234 |
235 | const fetchCall = vi.mocked(global.fetch).mock.calls[0];
236 | const requestBody = fetchCall[1]?.body as string;
237 |
238 | // Should include all three calendar IDs (may be URL-encoded)
239 | expect(requestBody).toContain('primary');
240 | expect(requestBody).toMatch(/work@example\.com|work%40example\.com/);
241 | expect(requestBody).toMatch(/personal@example\.com|personal%40example\.com/);
242 | });
243 | });
244 |
245 | describe('error handling with summaryOverride', () => {
246 | it('should provide helpful error listing both summaryOverride and summary', async () => {
247 | const args = {
248 | calendarId: 'NonExistentCalendar',
249 | timeMin: '2025-06-02T00:00:00Z',
250 | timeMax: '2025-06-09T23:59:59Z'
251 | };
252 |
253 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
254 | /Calendar\(s\) not found: "NonExistentCalendar"/
255 | );
256 |
257 | try {
258 | await handler.runTool(args, mockOAuth2Client);
259 | } catch (error: any) {
260 | // Error message should show both override and original name
261 | expect(error.message).toContain('Work Calendar');
262 | expect(error.message).toContain('Engineering Team - Project Alpha - Q4 2024');
263 | expect(error.message).toContain('My Team');
264 | expect(error.message).toContain('Team Events');
265 | }
266 | });
267 |
268 | it('should handle calendar with summaryOverride same as summary', async () => {
269 | // Update mock to have a calendar where override equals summary
270 | mockCalendar.calendarList.list.mockResolvedValueOnce({
271 | data: {
272 | items: [
273 | {
274 | id: '[email protected]',
275 | summary: 'Test Calendar',
276 | summaryOverride: 'Test Calendar'
277 | }
278 | ]
279 | }
280 | });
281 |
282 | const args = {
283 | calendarId: 'NonExistent',
284 | timeMin: '2025-06-02T00:00:00Z',
285 | timeMax: '2025-06-09T23:59:59Z'
286 | };
287 |
288 | try {
289 | await handler.runTool(args, mockOAuth2Client);
290 | } catch (error: any) {
291 | // Should not show duplicate when override equals summary
292 | const message = error.message;
293 | const matches = (message.match(/Test Calendar/g) || []).length;
294 | expect(matches).toBe(1);
295 | }
296 | });
297 | });
298 |
299 | describe('performance optimization', () => {
300 | it('should skip API call when all inputs are IDs', async () => {
301 | const args = {
302 | calendarId: ['primary', '[email protected]'], // Pass as array
303 | timeMin: '2025-06-02T00:00:00Z',
304 | timeMax: '2025-06-09T23:59:59Z'
305 | };
306 |
307 | // Reset the mock to track calls
308 | mockCalendar.calendarList.list.mockClear();
309 |
310 | global.fetch = vi.fn().mockResolvedValue({
311 | ok: true,
312 | status: 200,
313 | headers: {
314 | get: vi.fn()
315 | },
316 | text: () => Promise.resolve(`--batch_boundary
317 | Content-Type: application/http
318 |
319 | HTTP/1.1 200 OK
320 |
321 | {"items": []}
322 | --batch_boundary--`)
323 | });
324 |
325 | await handler.runTool(args, mockOAuth2Client);
326 |
327 | // Should NOT have called calendarList.list since all inputs are IDs
328 | expect(mockCalendar.calendarList.list).not.toHaveBeenCalled();
329 | });
330 |
331 | it('should call API only once for multiple name resolutions', async () => {
332 | const args = {
333 | calendarId: ['Work Calendar', 'Personal Calendar', 'My Team'], // Pass as array
334 | timeMin: '2025-06-02T00:00:00Z',
335 | timeMax: '2025-06-09T23:59:59Z'
336 | };
337 |
338 | mockCalendar.calendarList.list.mockClear();
339 |
340 | global.fetch = vi.fn().mockResolvedValue({
341 | ok: true,
342 | status: 200,
343 | headers: {
344 | get: vi.fn()
345 | },
346 | text: () => Promise.resolve(`--batch_boundary
347 | Content-Type: application/http
348 |
349 | HTTP/1.1 200 OK
350 |
351 | {"items": []}
352 | --batch_boundary--`)
353 | });
354 |
355 | await handler.runTool(args, mockOAuth2Client);
356 |
357 | // Should have called calendarList.list exactly once
358 | expect(mockCalendar.calendarList.list).toHaveBeenCalledTimes(1);
359 | });
360 | });
361 |
362 | describe('input validation', () => {
363 | it('should filter out empty strings', async () => {
364 | const args = {
365 | calendarId: ['primary', '', 'Work Calendar'], // Pass as array
366 | timeMin: '2025-06-02T00:00:00Z',
367 | timeMax: '2025-06-09T23:59:59Z'
368 | };
369 |
370 | global.fetch = vi.fn().mockResolvedValue({
371 | ok: true,
372 | status: 200,
373 | headers: {
374 | get: vi.fn()
375 | },
376 | text: () => Promise.resolve(`--batch_boundary
377 | Content-Type: application/http
378 |
379 | HTTP/1.1 200 OK
380 |
381 | {"items": []}
382 | --batch_boundary--`)
383 | });
384 |
385 | // Should not throw - empty string should be filtered out
386 | await expect(handler.runTool(args, mockOAuth2Client)).resolves.toBeDefined();
387 | });
388 |
389 | it('should reject when all inputs are empty/whitespace', async () => {
390 | const args = {
391 | calendarId: ['', ' ', '\t'], // Pass as array
392 | timeMin: '2025-06-02T00:00:00Z',
393 | timeMax: '2025-06-09T23:59:59Z'
394 | };
395 |
396 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
397 | /At least one valid calendar identifier is required/
398 | );
399 | });
400 | });
401 | });
402 |
```
--------------------------------------------------------------------------------
/src/tests/unit/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for the Google Calendar MCP Server implementation
3 | */
4 | import { describe, it, expect, vi, beforeEach } from 'vitest';
5 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6 | import { OAuth2Client } from "google-auth-library";
7 |
8 | // Import tool handlers to test them directly
9 | import { ListCalendarsHandler } from "../../handlers/core/ListCalendarsHandler.js";
10 | import { CreateEventHandler } from "../../handlers/core/CreateEventHandler.js";
11 | import { ListEventsHandler } from "../../handlers/core/ListEventsHandler.js";
12 |
13 | // Mock OAuth2Client
14 | vi.mock('google-auth-library', () => ({
15 | OAuth2Client: vi.fn().mockImplementation(() => ({
16 | setCredentials: vi.fn(),
17 | refreshAccessToken: vi.fn().mockResolvedValue({ credentials: { access_token: 'mock_access_token' } }),
18 | on: vi.fn(),
19 | }))
20 | }));
21 |
22 | // Mock googleapis
23 | vi.mock('googleapis', () => ({
24 | google: {
25 | calendar: vi.fn().mockReturnValue({
26 | calendarList: {
27 | list: vi.fn(),
28 | get: vi.fn()
29 | },
30 | events: {
31 | list: vi.fn(),
32 | insert: vi.fn(),
33 | patch: vi.fn(),
34 | delete: vi.fn()
35 | },
36 | colors: {
37 | get: vi.fn()
38 | },
39 | freebusy: {
40 | query: vi.fn()
41 | }
42 | })
43 | }
44 | }));
45 |
46 | // Mock TokenManager
47 | vi.mock('./auth/tokenManager.js', () => ({
48 | TokenManager: vi.fn().mockImplementation(() => ({
49 | validateTokens: vi.fn().mockResolvedValue(true),
50 | loadSavedTokens: vi.fn().mockResolvedValue(true),
51 | clearTokens: vi.fn(),
52 | })),
53 | }));
54 |
55 | describe('Google Calendar MCP Server', () => {
56 | let mockOAuth2Client: OAuth2Client;
57 |
58 | beforeEach(() => {
59 | vi.clearAllMocks();
60 | mockOAuth2Client = new OAuth2Client();
61 | });
62 |
63 | describe('McpServer Configuration', () => {
64 | it('should create McpServer with correct configuration', () => {
65 | const server = new McpServer({
66 | name: "google-calendar",
67 | version: "1.2.0"
68 | });
69 |
70 | expect(server).toBeDefined();
71 | // McpServer doesn't expose internal configuration for testing,
72 | // but we can verify it doesn't throw during creation
73 | });
74 | });
75 |
76 | describe('Tool Handlers', () => {
77 | it('should handle list-calendars tool correctly', async () => {
78 | const handler = new ListCalendarsHandler();
79 | const { google } = await import('googleapis');
80 | const mockCalendarApi = google.calendar('v3');
81 |
82 | // Mock the API response
83 | (mockCalendarApi.calendarList.list as any).mockResolvedValue({
84 | data: {
85 | items: [
86 | {
87 | id: 'cal1',
88 | summary: 'Work Calendar',
89 | timeZone: 'America/New_York',
90 | kind: 'calendar#calendarListEntry',
91 | accessRole: 'owner',
92 | primary: true,
93 | selected: true,
94 | hidden: false,
95 | backgroundColor: '#0D7377',
96 | defaultReminders: [
97 | { method: 'popup', minutes: 15 },
98 | { method: 'email', minutes: 60 }
99 | ],
100 | description: 'Work-related events and meetings'
101 | },
102 | {
103 | id: 'cal2',
104 | summary: 'Personal',
105 | timeZone: 'America/Los_Angeles',
106 | kind: 'calendar#calendarListEntry',
107 | accessRole: 'reader',
108 | primary: false,
109 | selected: true,
110 | hidden: false,
111 | backgroundColor: '#D50000'
112 | },
113 | ]
114 | }
115 | });
116 |
117 | const result = await handler.runTool({}, mockOAuth2Client);
118 |
119 | expect(mockCalendarApi.calendarList.list).toHaveBeenCalled();
120 |
121 | // Parse the JSON response
122 | const response = JSON.parse((result.content as any)[0].text);
123 |
124 | expect(response.totalCount).toBe(2);
125 | expect(response.calendars).toHaveLength(2);
126 | expect(response.calendars[0]).toMatchObject({
127 | id: 'cal1',
128 | summary: 'Work Calendar',
129 | description: 'Work-related events and meetings',
130 | timeZone: 'America/New_York',
131 | backgroundColor: '#0D7377',
132 | accessRole: 'owner',
133 | primary: true,
134 | selected: true,
135 | hidden: false
136 | });
137 | expect(response.calendars[0].defaultReminders).toHaveLength(2);
138 | expect(response.calendars[1]).toMatchObject({
139 | id: 'cal2',
140 | summary: 'Personal',
141 | timeZone: 'America/Los_Angeles',
142 | backgroundColor: '#D50000',
143 | accessRole: 'reader',
144 | primary: false
145 | });
146 | });
147 |
148 | it('should handle create-event tool with valid arguments', async () => {
149 | const handler = new CreateEventHandler();
150 | const { google } = await import('googleapis');
151 | const mockCalendarApi = google.calendar('v3');
152 |
153 | const mockEventArgs = {
154 | calendarId: 'primary',
155 | summary: 'Team Meeting',
156 | description: 'Discuss project progress',
157 | start: '2024-08-15T10:00:00',
158 | end: '2024-08-15T11:00:00',
159 | attendees: [{ email: '[email protected]' }],
160 | location: 'Conference Room 4',
161 | };
162 |
163 | const mockApiResponse = {
164 | id: 'eventId123',
165 | summary: mockEventArgs.summary,
166 | };
167 |
168 | // Mock calendar details for timezone retrieval
169 | (mockCalendarApi.calendarList.get as any).mockResolvedValue({
170 | data: {
171 | id: 'primary',
172 | timeZone: 'America/Los_Angeles'
173 | }
174 | });
175 |
176 | (mockCalendarApi.events.insert as any).mockResolvedValue({ data: mockApiResponse });
177 |
178 | const result = await handler.runTool(mockEventArgs, mockOAuth2Client);
179 |
180 | expect(mockCalendarApi.calendarList.get).toHaveBeenCalledWith({ calendarId: 'primary' });
181 | expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
182 | calendarId: mockEventArgs.calendarId,
183 | requestBody: expect.objectContaining({
184 | summary: mockEventArgs.summary,
185 | description: mockEventArgs.description,
186 | start: { dateTime: mockEventArgs.start, timeZone: 'America/Los_Angeles' },
187 | end: { dateTime: mockEventArgs.end, timeZone: 'America/Los_Angeles' },
188 | attendees: mockEventArgs.attendees,
189 | location: mockEventArgs.location,
190 | }),
191 | });
192 |
193 | expect(result.content).toHaveLength(1);
194 | expect(result.content[0].type).toBe('text');
195 | const response = JSON.parse((result.content[0] as any).text);
196 | expect(response.event).toBeDefined();
197 | expect(response.event.id).toBe('eventId123');
198 | expect(response.event.summary).toBe('Team Meeting');
199 | });
200 |
201 | it('should use calendar default timezone when timeZone is not provided', async () => {
202 | const handler = new CreateEventHandler();
203 | const { google } = await import('googleapis');
204 | const mockCalendarApi = google.calendar('v3');
205 |
206 | const mockEventArgs = {
207 | calendarId: 'primary',
208 | summary: 'Meeting without timezone',
209 | start: '2024-08-15T10:00:00', // Timezone-naive datetime
210 | end: '2024-08-15T11:00:00', // Timezone-naive datetime
211 | };
212 |
213 | // Mock calendar details with specific timezone
214 | (mockCalendarApi.calendarList.get as any).mockResolvedValue({
215 | data: {
216 | id: 'primary',
217 | timeZone: 'Europe/London'
218 | }
219 | });
220 |
221 | (mockCalendarApi.events.insert as any).mockResolvedValue({
222 | data: { id: 'testEvent', summary: mockEventArgs.summary }
223 | });
224 |
225 | await handler.runTool(mockEventArgs, mockOAuth2Client);
226 |
227 | // Verify that the calendar's timezone was used
228 | expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
229 | calendarId: mockEventArgs.calendarId,
230 | requestBody: expect.objectContaining({
231 | start: { dateTime: mockEventArgs.start, timeZone: 'Europe/London' },
232 | end: { dateTime: mockEventArgs.end, timeZone: 'Europe/London' },
233 | }),
234 | });
235 | });
236 |
237 | it('should handle timezone-aware datetime strings correctly', async () => {
238 | const handler = new CreateEventHandler();
239 | const { google } = await import('googleapis');
240 | const mockCalendarApi = google.calendar('v3');
241 |
242 | const mockEventArgs = {
243 | calendarId: 'primary',
244 | summary: 'Meeting with timezone in datetime',
245 | start: '2024-08-15T10:00:00-07:00', // Timezone-aware datetime
246 | end: '2024-08-15T11:00:00-07:00', // Timezone-aware datetime
247 | };
248 |
249 | // Mock calendar details (should not be used since timezone is in datetime)
250 | (mockCalendarApi.calendarList.get as any).mockResolvedValue({
251 | data: {
252 | id: 'primary',
253 | timeZone: 'Europe/London'
254 | }
255 | });
256 |
257 | (mockCalendarApi.events.insert as any).mockResolvedValue({
258 | data: { id: 'testEvent', summary: mockEventArgs.summary }
259 | });
260 |
261 | await handler.runTool(mockEventArgs, mockOAuth2Client);
262 |
263 | // Verify that timezone from datetime was used (no timeZone property)
264 | expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
265 | calendarId: mockEventArgs.calendarId,
266 | requestBody: expect.objectContaining({
267 | start: { dateTime: mockEventArgs.start }, // No timeZone property
268 | end: { dateTime: mockEventArgs.end }, // No timeZone property
269 | }),
270 | });
271 | });
272 |
273 | it('should handle list-events tool correctly', async () => {
274 | const handler = new ListEventsHandler();
275 | const { google } = await import('googleapis');
276 | const mockCalendarApi = google.calendar('v3');
277 |
278 | const listEventsArgs = {
279 | calendarId: 'primary',
280 | timeMin: '2024-08-01T00:00:00Z',
281 | timeMax: '2024-08-31T23:59:59Z',
282 | };
283 |
284 | const mockEvents = [
285 | {
286 | id: 'event1',
287 | summary: 'Meeting',
288 | start: { dateTime: '2024-08-15T10:00:00Z' },
289 | end: { dateTime: '2024-08-15T11:00:00Z' }
290 | },
291 | ];
292 |
293 | (mockCalendarApi.events.list as any).mockResolvedValue({
294 | data: { items: mockEvents }
295 | });
296 |
297 | const result = await handler.runTool(listEventsArgs, mockOAuth2Client);
298 |
299 | expect(mockCalendarApi.events.list).toHaveBeenCalledWith({
300 | calendarId: listEventsArgs.calendarId,
301 | timeMin: listEventsArgs.timeMin,
302 | timeMax: listEventsArgs.timeMax,
303 | singleEvents: true,
304 | orderBy: 'startTime'
305 | });
306 |
307 | // Should return structured JSON with events
308 | expect(result.content).toHaveLength(1);
309 | expect(result.content[0].type).toBe('text');
310 | const response = JSON.parse((result.content[0] as any).text);
311 | expect(response.events).toHaveLength(1);
312 | expect(response.totalCount).toBe(1);
313 | expect(response.events[0].id).toBe('event1');
314 | });
315 | });
316 |
317 | describe('Configuration and Environment Variables', () => {
318 | it('should parse environment variables correctly', async () => {
319 | const originalEnv = process.env;
320 |
321 | try {
322 | // Set test environment variables
323 | process.env.TRANSPORT = 'http';
324 | process.env.PORT = '4000';
325 | process.env.HOST = '0.0.0.0';
326 | process.env.DEBUG = 'true';
327 |
328 | // Import config parser after setting env vars
329 | const { parseArgs } = await import('../../config/TransportConfig.js');
330 |
331 | const config = parseArgs([]);
332 |
333 | expect(config.transport.type).toBe('http');
334 | expect(config.transport.port).toBe(4000);
335 | expect(config.transport.host).toBe('0.0.0.0');
336 | expect(config.debug).toBe(true);
337 | } finally {
338 | // Restore original environment
339 | process.env = originalEnv;
340 | }
341 | });
342 |
343 | it('should allow CLI arguments to override environment variables', async () => {
344 | const originalEnv = process.env;
345 |
346 | try {
347 | // Set environment variables
348 | process.env.TRANSPORT = 'http';
349 | process.env.PORT = '4000';
350 |
351 | const { parseArgs } = await import('../../config/TransportConfig.js');
352 |
353 | // CLI arguments should override env vars
354 | const config = parseArgs(['--transport', 'stdio', '--port', '5000']);
355 |
356 | expect(config.transport.type).toBe('stdio');
357 | expect(config.transport.port).toBe(5000);
358 | } finally {
359 | process.env = originalEnv;
360 | }
361 | });
362 | });
363 | });
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/list-events-registry.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Comprehensive tests for list-events tool registration flow
3 | * Tests the complete path: schema validation → handlerFunction → handler execution
4 | *
5 | * These tests verify the fix for issue #95 by testing:
6 | * 1. Schema validation (accepts all formats)
7 | * 2. HandlerFunction preprocessing (converts single-quoted JSON, validates arrays)
8 | * 3. Real-world scenarios from Home Assistant and other integrations
9 | */
10 |
11 | import { describe, it, expect } from 'vitest';
12 | import { ToolSchemas, ToolRegistry } from '../../../tools/registry.js';
13 |
14 | // Get the handlerFunction for testing the full flow
15 | const toolDefinition = (ToolRegistry as any).tools?.find((t: any) => t.name === 'list-events');
16 | const handlerFunction = toolDefinition?.handlerFunction;
17 |
18 | describe('list-events Registration Flow (Schema + HandlerFunction)', () => {
19 | describe('Schema validation (first step)', () => {
20 | it('should validate native array format', () => {
21 | const input = {
22 | calendarId: ['primary', '[email protected]'],
23 | timeMin: '2024-01-01T00:00:00',
24 | timeMax: '2024-01-02T00:00:00'
25 | };
26 |
27 | const result = ToolSchemas['list-events'].safeParse(input);
28 | expect(result.success).toBe(true);
29 | expect(result.data?.calendarId).toEqual(['primary', '[email protected]']);
30 | });
31 |
32 | it('should validate single string format', () => {
33 | const input = {
34 | calendarId: 'primary',
35 | timeMin: '2024-01-01T00:00:00',
36 | timeMax: '2024-01-02T00:00:00'
37 | };
38 |
39 | const result = ToolSchemas['list-events'].safeParse(input);
40 | expect(result.success).toBe(true);
41 | expect(result.data?.calendarId).toBe('primary');
42 | });
43 |
44 | it('should validate JSON string format', () => {
45 | const input = {
46 | calendarId: '["primary", "[email protected]"]',
47 | timeMin: '2024-01-01T00:00:00',
48 | timeMax: '2024-01-02T00:00:00'
49 | };
50 |
51 | const result = ToolSchemas['list-events'].safeParse(input);
52 | expect(result.success).toBe(true);
53 | expect(result.data?.calendarId).toBe('["primary", "[email protected]"]');
54 | });
55 | });
56 |
57 | describe('Array validation constraints', () => {
58 | it('should enforce minimum array length', () => {
59 | const input = {
60 | calendarId: [],
61 | timeMin: '2024-01-01T00:00:00',
62 | timeMax: '2024-01-02T00:00:00'
63 | };
64 |
65 | const result = ToolSchemas['list-events'].safeParse(input);
66 | expect(result.success).toBe(false);
67 | if (!result.success) {
68 | expect(result.error.issues[0].message).toContain('At least one calendar ID is required');
69 | }
70 | });
71 |
72 | it('should enforce maximum array length', () => {
73 | const input = {
74 | calendarId: Array(51).fill('calendar'),
75 | timeMin: '2024-01-01T00:00:00',
76 | timeMax: '2024-01-02T00:00:00'
77 | };
78 |
79 | const result = ToolSchemas['list-events'].safeParse(input);
80 | expect(result.success).toBe(false);
81 | if (!result.success) {
82 | expect(result.error.issues[0].message).toContain('Maximum 50 calendars');
83 | }
84 | });
85 |
86 | it('should reject duplicate calendar IDs in array', () => {
87 | const input = {
88 | calendarId: ['primary', 'primary'],
89 | timeMin: '2024-01-01T00:00:00',
90 | timeMax: '2024-01-02T00:00:00'
91 | };
92 |
93 | const result = ToolSchemas['list-events'].safeParse(input);
94 | expect(result.success).toBe(false);
95 | if (!result.success) {
96 | expect(result.error.issues[0].message).toContain('Duplicate calendar IDs');
97 | }
98 | });
99 |
100 | it('should reject empty strings in array', () => {
101 | const input = {
102 | calendarId: ['primary', ''],
103 | timeMin: '2024-01-01T00:00:00',
104 | timeMax: '2024-01-02T00:00:00'
105 | };
106 |
107 | const result = ToolSchemas['list-events'].safeParse(input);
108 | expect(result.success).toBe(false);
109 | });
110 | });
111 |
112 | describe('Type preservation after validation', () => {
113 | it('should preserve array type for native arrays (issue #95 fix)', () => {
114 | const input = {
115 | calendarId: ['primary', '[email protected]', '[email protected]'],
116 | timeMin: '2024-01-01T00:00:00',
117 | timeMax: '2024-01-02T00:00:00'
118 | };
119 |
120 | const result = ToolSchemas['list-events'].parse(input);
121 |
122 | // The key fix: arrays should NOT be transformed to JSON strings by the schema
123 | // The handlerFunction will handle the conversion logic
124 | expect(Array.isArray(result.calendarId)).toBe(true);
125 | expect(result.calendarId).toEqual(['primary', '[email protected]', '[email protected]']);
126 | });
127 |
128 | it('should preserve string type for single strings', () => {
129 | const input = {
130 | calendarId: 'primary',
131 | timeMin: '2024-01-01T00:00:00',
132 | timeMax: '2024-01-02T00:00:00'
133 | };
134 |
135 | const result = ToolSchemas['list-events'].parse(input);
136 | expect(typeof result.calendarId).toBe('string');
137 | expect(result.calendarId).toBe('primary');
138 | });
139 |
140 | it('should preserve string type for JSON strings', () => {
141 | const input = {
142 | calendarId: '["primary", "[email protected]"]',
143 | timeMin: '2024-01-01T00:00:00',
144 | timeMax: '2024-01-02T00:00:00'
145 | };
146 |
147 | const result = ToolSchemas['list-events'].parse(input);
148 | expect(typeof result.calendarId).toBe('string');
149 | expect(result.calendarId).toBe('["primary", "[email protected]"]');
150 | });
151 | });
152 |
153 | describe('Real-world scenarios from issue #95', () => {
154 | it('should handle exact input from Home Assistant multi-mcp', () => {
155 | // This is the exact format that was failing in issue #95
156 | const input = {
157 | calendarId: ['primary', '[email protected]', '[email protected]', '[email protected]', '[email protected]'],
158 | timeMin: '2025-10-09T00:00:00',
159 | timeMax: '2025-10-09T23:59:59'
160 | };
161 |
162 | const result = ToolSchemas['list-events'].safeParse(input);
163 | expect(result.success).toBe(true);
164 | expect(Array.isArray(result.data?.calendarId)).toBe(true);
165 | expect(result.data?.calendarId).toHaveLength(5);
166 | });
167 |
168 | it('should handle mixed special characters in calendar IDs', () => {
169 | const input = {
170 | calendarId: ['primary', '[email protected]', '[email protected]'],
171 | timeMin: '2024-01-01T00:00:00',
172 | timeMax: '2024-01-02T00:00:00'
173 | };
174 |
175 | const result = ToolSchemas['list-events'].safeParse(input);
176 | expect(result.success).toBe(true);
177 | expect(result.data?.calendarId).toEqual(['primary', '[email protected]', '[email protected]']);
178 | });
179 |
180 | it('should accept single-quoted JSON string format (Python/shell style)', () => {
181 | // Some clients may send JSON-like strings with single quotes instead of double quotes
182 | // e.g., from Python str() representation or shell scripts
183 | // The schema should accept it as a string (handlerFunction will process it)
184 | const input = {
185 | calendarId: "['primary', '[email protected]']",
186 | timeMin: '2025-10-09T00:00:00',
187 | timeMax: '2025-10-09T23:59:59'
188 | };
189 |
190 | // Schema should accept it as a string (not reject it)
191 | const result = ToolSchemas['list-events'].safeParse(input);
192 | expect(result.success).toBe(true);
193 | expect(typeof result.data?.calendarId).toBe('string');
194 | expect(result.data?.calendarId).toBe("['primary', '[email protected]']");
195 | });
196 | });
197 |
198 | // HandlerFunction tests - second step after schema validation
199 | if (!handlerFunction) {
200 | console.warn('⚠️ handlerFunction not found - skipping handler tests');
201 | } else {
202 | describe('HandlerFunction preprocessing (second step)', () => {
203 | describe('Format handling', () => {
204 | it('should pass through native arrays unchanged', async () => {
205 | const input = {
206 | calendarId: ['primary', '[email protected]'],
207 | timeMin: '2024-01-01T00:00:00',
208 | timeMax: '2024-01-02T00:00:00'
209 | };
210 |
211 | const result = await handlerFunction(input);
212 | expect(Array.isArray(result.calendarId)).toBe(true);
213 | expect(result.calendarId).toEqual(['primary', '[email protected]']);
214 | });
215 |
216 | it('should pass through single strings unchanged', async () => {
217 | const input = {
218 | calendarId: 'primary',
219 | timeMin: '2024-01-01T00:00:00',
220 | timeMax: '2024-01-02T00:00:00'
221 | };
222 |
223 | const result = await handlerFunction(input);
224 | expect(typeof result.calendarId).toBe('string');
225 | expect(result.calendarId).toBe('primary');
226 | });
227 |
228 | it('should parse valid JSON strings with double quotes', async () => {
229 | const input = {
230 | calendarId: '["primary", "[email protected]"]',
231 | timeMin: '2024-01-01T00:00:00',
232 | timeMax: '2024-01-02T00:00:00'
233 | };
234 |
235 | const result = await handlerFunction(input);
236 | expect(Array.isArray(result.calendarId)).toBe(true);
237 | expect(result.calendarId).toEqual(['primary', '[email protected]']);
238 | });
239 |
240 | it('should parse single-quoted JSON-like strings (Python/shell style) - THE KEY FIX', async () => {
241 | // This is the failing case that needed fixing
242 | const input = {
243 | calendarId: "['primary', '[email protected]']",
244 | timeMin: '2025-10-09T00:00:00',
245 | timeMax: '2025-10-09T23:59:59'
246 | };
247 |
248 | const result = await handlerFunction(input);
249 | expect(Array.isArray(result.calendarId)).toBe(true);
250 | expect(result.calendarId).toEqual(['primary', '[email protected]']);
251 | });
252 |
253 | it('should handle calendar IDs with apostrophes in single-quoted JSON', async () => {
254 | // Calendar IDs can contain apostrophes (e.g., "John's Calendar")
255 | // Our replacement logic should not break these
256 | const input = {
257 | calendarId: "['primary', '[email protected]']",
258 | timeMin: '2024-01-01T00:00:00',
259 | timeMax: '2024-01-02T00:00:00'
260 | };
261 |
262 | const result = await handlerFunction(input);
263 | expect(Array.isArray(result.calendarId)).toBe(true);
264 | expect(result.calendarId).toEqual(['primary', '[email protected]']);
265 | });
266 |
267 | it('should handle JSON strings with whitespace', async () => {
268 | const input = {
269 | calendarId: ' ["primary", "[email protected]"] ',
270 | timeMin: '2024-01-01T00:00:00',
271 | timeMax: '2024-01-02T00:00:00'
272 | };
273 |
274 | const result = await handlerFunction(input);
275 | expect(Array.isArray(result.calendarId)).toBe(true);
276 | expect(result.calendarId).toEqual(['primary', '[email protected]']);
277 | });
278 | });
279 |
280 | describe('JSON string validation', () => {
281 | it('should reject empty arrays in JSON strings', async () => {
282 | const input = {
283 | calendarId: '[]',
284 | timeMin: '2024-01-01T00:00:00',
285 | timeMax: '2024-01-02T00:00:00'
286 | };
287 |
288 | await expect(handlerFunction(input)).rejects.toThrow('At least one calendar ID is required');
289 | });
290 |
291 | it('should reject arrays exceeding 50 calendars', async () => {
292 | const input = {
293 | calendarId: JSON.stringify(Array(51).fill('calendar')),
294 | timeMin: '2024-01-01T00:00:00',
295 | timeMax: '2024-01-02T00:00:00'
296 | };
297 |
298 | await expect(handlerFunction(input)).rejects.toThrow('Maximum 50 calendars');
299 | });
300 |
301 | it('should reject duplicate calendar IDs in JSON strings', async () => {
302 | const input = {
303 | calendarId: '["primary", "primary"]',
304 | timeMin: '2024-01-01T00:00:00',
305 | timeMax: '2024-01-02T00:00:00'
306 | };
307 |
308 | await expect(handlerFunction(input)).rejects.toThrow('Duplicate calendar IDs');
309 | });
310 | });
311 |
312 | describe('Error handling', () => {
313 | it('should provide clear error for malformed JSON array', async () => {
314 | const input = {
315 | calendarId: '["primary", "missing-quote}]',
316 | timeMin: '2024-01-01T00:00:00',
317 | timeMax: '2024-01-02T00:00:00'
318 | };
319 |
320 | await expect(handlerFunction(input)).rejects.toThrow('Invalid JSON format for calendarId');
321 | });
322 |
323 | it('should reject JSON arrays with non-string elements', async () => {
324 | const input = {
325 | calendarId: '["primary", 123, null]',
326 | timeMin: '2024-01-01T00:00:00',
327 | timeMax: '2024-01-02T00:00:00'
328 | };
329 |
330 | await expect(handlerFunction(input)).rejects.toThrow('Array must contain only non-empty strings');
331 | });
332 | });
333 | });
334 | }
335 | });
336 |
```