This is page 3 of 5. Use http://codebase.md/nspady/google-calendar-mcp?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/handlers/BatchRequestHandler.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @jest-environment node
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OAuth2Client } from 'google-auth-library';
import { BatchRequestHandler, BatchRequest, BatchResponse } from '../../../handlers/core/BatchRequestHandler.js';
describe('BatchRequestHandler', () => {
let mockOAuth2Client: OAuth2Client;
let batchHandler: BatchRequestHandler;
beforeEach(() => {
vi.clearAllMocks();
mockOAuth2Client = {
getAccessToken: vi.fn().mockResolvedValue({ token: 'mock_access_token' })
} as any;
batchHandler = new BatchRequestHandler(mockOAuth2Client);
});
describe('Batch Request Creation', () => {
it('should create proper multipart request body with single request', () => {
const requests: BatchRequest[] = [
{
method: 'GET',
path: '/calendar/v3/calendars/primary/events?singleEvents=true&orderBy=startTime'
}
];
const result = (batchHandler as any).createBatchBody(requests);
const boundary = (batchHandler as any).boundary;
expect(result).toContain(`--${boundary}`);
expect(result).toContain('Content-Type: application/http');
expect(result).toContain('Content-ID: <item1>');
expect(result).toContain('GET /calendar/v3/calendars/primary/events');
expect(result).toContain('singleEvents=true');
expect(result).toContain('orderBy=startTime');
expect(result).toContain(`--${boundary}--`);
});
it('should create proper multipart request body with multiple requests', () => {
const requests: BatchRequest[] = [
{
method: 'GET',
path: '/calendar/v3/calendars/primary/events'
},
{
method: 'GET',
path: '/calendar/v3/calendars/work%40example.com/events'
},
{
method: 'GET',
path: '/calendar/v3/calendars/personal%40example.com/events'
}
];
const result = (batchHandler as any).createBatchBody(requests);
const boundary = (batchHandler as any).boundary;
expect(result).toContain('Content-ID: <item1>');
expect(result).toContain('Content-ID: <item2>');
expect(result).toContain('Content-ID: <item3>');
expect(result).toContain('calendars/primary/events');
expect(result).toContain('calendars/work%40example.com/events');
expect(result).toContain('calendars/personal%40example.com/events');
// Should have proper boundary structure
const boundaryCount = (result.match(new RegExp(`--${boundary}`, 'g')) || []).length;
expect(boundaryCount).toBe(4); // 3 request boundaries + 1 end boundary
});
it('should handle requests with custom headers', () => {
const requests: BatchRequest[] = [
{
method: 'POST',
path: '/calendar/v3/calendars/primary/events',
headers: {
'If-Match': '"etag123"',
'X-Custom-Header': 'custom-value'
}
}
];
const result = (batchHandler as any).createBatchBody(requests);
expect(result).toContain('If-Match: "etag123"');
expect(result).toContain('X-Custom-Header: custom-value');
});
it('should handle requests with JSON body', () => {
const requestBody = {
summary: 'Test Event',
start: { dateTime: '2024-01-15T10:00:00Z' },
end: { dateTime: '2024-01-15T11:00:00Z' }
};
const requests: BatchRequest[] = [
{
method: 'POST',
path: '/calendar/v3/calendars/primary/events',
body: requestBody
}
];
const result = (batchHandler as any).createBatchBody(requests);
expect(result).toContain('Content-Type: application/json');
expect(result).toContain(JSON.stringify(requestBody));
expect(result).toContain('"summary":"Test Event"');
});
it('should encode URLs properly in batch requests', () => {
const requests: BatchRequest[] = [
{
method: 'GET',
path: '/calendar/v3/calendars/test%40example.com/events?timeMin=2024-01-01T00%3A00%3A00Z'
}
];
const result = (batchHandler as any).createBatchBody(requests);
expect(result).toContain('calendars/test%40example.com/events');
expect(result).toContain('timeMin=2024-01-01T00%3A00%3A00Z');
});
});
describe('Batch Response Parsing', () => {
it('should parse successful response correctly', () => {
const mockResponseText = `HTTP/1.1 200 OK
Content-Length: response_total_content_length
Content-Type: multipart/mixed; boundary=batch_abc123
--batch_abc123
Content-Type: application/http
Content-ID: <response-item1>
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 123
{
"items": [
{
"id": "event1",
"summary": "Test Event",
"start": {"dateTime": "2024-01-15T10:00:00Z"},
"end": {"dateTime": "2024-01-15T11:00:00Z"}
}
]
}
--batch_abc123--`;
const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
expect(responses).toHaveLength(1);
expect(responses[0].statusCode).toBe(200);
expect(responses[0].body.items).toHaveLength(1);
expect(responses[0].body.items[0].summary).toBe('Test Event');
});
it('should parse multiple responses correctly', () => {
const mockResponseText = `HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=batch_abc123
--batch_abc123
Content-Type: application/http
Content-ID: <response-item1>
HTTP/1.1 200 OK
Content-Type: application/json
{"items": [{"id": "event1", "summary": "Event 1"}]}
--batch_abc123
Content-Type: application/http
Content-ID: <response-item2>
HTTP/1.1 200 OK
Content-Type: application/json
{"items": [{"id": "event2", "summary": "Event 2"}]}
--batch_abc123--`;
const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
expect(responses).toHaveLength(2);
expect(responses[0].body.items[0].summary).toBe('Event 1');
expect(responses[1].body.items[0].summary).toBe('Event 2');
});
it('should handle error responses in batch', () => {
const mockResponseText = `HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=batch_abc123
--batch_abc123
Content-Type: application/http
Content-ID: <response-item1>
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": {
"code": 404,
"message": "Calendar not found"
}
}
--batch_abc123--`;
const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
expect(responses).toHaveLength(1);
expect(responses[0].statusCode).toBe(404);
expect(responses[0].body.error.code).toBe(404);
expect(responses[0].body.error.message).toBe('Calendar not found');
});
it('should handle mixed success and error responses', () => {
const mockResponseText = `HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=batch_abc123
--batch_abc123
Content-Type: application/http
Content-ID: <response-item1>
HTTP/1.1 200 OK
Content-Type: application/json
{"items": [{"id": "event1", "summary": "Success"}]}
--batch_abc123
Content-Type: application/http
Content-ID: <response-item2>
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": {
"code": 403,
"message": "Access denied"
}
}
--batch_abc123--`;
const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
expect(responses).toHaveLength(2);
expect(responses[0].statusCode).toBe(200);
expect(responses[0].body.items[0].summary).toBe('Success');
expect(responses[1].statusCode).toBe(403);
expect(responses[1].body.error.message).toBe('Access denied');
});
it('should handle empty response parts gracefully', () => {
const mockResponseText = `HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=batch_abc123
--batch_abc123
--batch_abc123
Content-Type: application/http
Content-ID: <response-item1>
HTTP/1.1 200 OK
Content-Type: application/json
{"items": []}
--batch_abc123--`;
const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
expect(responses).toHaveLength(1);
expect(responses[0].statusCode).toBe(200);
expect(responses[0].body.items).toEqual([]);
});
it('should handle malformed JSON gracefully', () => {
const mockResponseText = `HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=batch_abc123
--batch_abc123
Content-Type: application/http
Content-ID: <response-item1>
HTTP/1.1 200 OK
Content-Type: application/json
{invalid json here}
--batch_abc123--`;
const responses = (batchHandler as any).parseBatchResponse(mockResponseText);
expect(responses).toHaveLength(1);
expect(responses[0].statusCode).toBe(200);
expect(responses[0].body).toBe('{invalid json here}');
});
});
describe('Integration Tests', () => {
it('should execute batch request with mocked fetch', async () => {
const mockResponseText = `HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=batch_abc123
--batch_abc123
Content-Type: application/http
HTTP/1.1 200 OK
Content-Type: application/json
{"items": [{"id": "event1", "summary": "Test"}]}
--batch_abc123--`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
text: () => Promise.resolve(mockResponseText)
});
const requests: BatchRequest[] = [
{
method: 'GET',
path: '/calendar/v3/calendars/primary/events'
}
];
const responses = await batchHandler.executeBatch(requests);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/batch/calendar/v3',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer mock_access_token',
'Content-Type': expect.stringContaining('multipart/mixed; boundary=')
})
})
);
expect(responses).toHaveLength(1);
expect(responses[0].statusCode).toBe(200);
});
it('should handle network errors during batch execution', async () => {
// Create a handler with no retries for this test
const noRetryHandler = new BatchRequestHandler(mockOAuth2Client);
(noRetryHandler as any).maxRetries = 0; // Override max retries
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const requests: BatchRequest[] = [
{
method: 'GET',
path: '/calendar/v3/calendars/primary/events'
}
];
await expect(noRetryHandler.executeBatch(requests))
.rejects.toThrow('Failed to execute batch request: Network error');
});
it('should handle authentication errors', async () => {
mockOAuth2Client.getAccessToken = vi.fn().mockRejectedValue(
new Error('Authentication failed')
);
const requests: BatchRequest[] = [
{
method: 'GET',
path: '/calendar/v3/calendars/primary/events'
}
];
await expect(batchHandler.executeBatch(requests))
.rejects.toThrow('Authentication failed');
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/CalendarNameResolution.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Unit tests for calendar name resolution feature
* Tests the resolveCalendarId and resolveCalendarIds methods in BaseToolHandler
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ListEventsHandler } from '../../../handlers/core/ListEventsHandler.js';
import { OAuth2Client } from 'google-auth-library';
import { google } from 'googleapis';
// Mock googleapis globally
vi.mock('googleapis', () => ({
google: {
calendar: vi.fn(() => ({
events: {
list: vi.fn()
},
calendarList: {
list: vi.fn(),
get: vi.fn()
}
}))
}
}));
describe('Calendar Name Resolution', () => {
const mockOAuth2Client = {
getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
} as unknown as OAuth2Client;
let handler: ListEventsHandler;
let mockCalendar: any;
beforeEach(() => {
handler = new ListEventsHandler();
mockCalendar = {
events: {
list: vi.fn().mockResolvedValue({
data: {
items: []
}
})
},
calendarList: {
list: vi.fn().mockResolvedValue({
data: {
items: [
{
id: 'primary',
summary: 'Primary Calendar',
summaryOverride: undefined
},
{
id: '[email protected]',
summary: 'Engineering Team - Project Alpha - Q4 2024',
summaryOverride: 'Work Calendar'
},
{
id: '[email protected]',
summary: 'Personal Calendar',
summaryOverride: undefined
},
{
id: '[email protected]',
summary: 'Team Events',
summaryOverride: 'My Team'
}
]
}
}),
get: vi.fn().mockResolvedValue({
data: { timeZone: 'UTC' }
})
}
};
vi.mocked(google.calendar).mockReturnValue(mockCalendar);
});
describe('summaryOverride matching priority', () => {
it('should match summaryOverride before summary (exact match)', async () => {
const args = {
calendarId: 'Work Calendar',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
await handler.runTool(args, mockOAuth2Client);
// Should have called events.list with the resolved ID
expect(mockCalendar.events.list).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: '[email protected]'
})
);
});
it('should fall back to summary if summaryOverride does not match', async () => {
const args = {
calendarId: 'Personal Calendar',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.list).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: '[email protected]'
})
);
});
it('should match summaryOverride case-insensitively', async () => {
const args = {
calendarId: 'WORK CALENDAR',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.list).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: '[email protected]'
})
);
});
it('should match summary case-insensitively', async () => {
const args = {
calendarId: 'personal calendar',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.list).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: '[email protected]'
})
);
});
it('should prefer summaryOverride over similar summary name', async () => {
// Even if there's a calendar with summary "My Team",
// it should match the summaryOverride first
const args = {
calendarId: 'My Team',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.list).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: '[email protected]'
})
);
});
});
describe('multiple calendar name resolution', () => {
it('should resolve multiple calendar names including summaryOverride', async () => {
const args = {
calendarId: ['Work Calendar', 'Personal Calendar'], // Pass as array, not JSON string
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
// Mock fetch for batch requests
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: vi.fn()
},
text: () => Promise.resolve(`--batch_boundary
Content-Type: application/http
Content-ID: <item1>
HTTP/1.1 200 OK
Content-Type: application/json
{"items": []}
--batch_boundary
Content-Type: application/http
Content-ID: <item2>
HTTP/1.1 200 OK
Content-Type: application/json
{"items": []}
--batch_boundary--`)
});
await handler.runTool(args, mockOAuth2Client);
// Should have called fetch with both resolved calendar IDs
expect(global.fetch).toHaveBeenCalled();
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
const requestBody = fetchCall[1]?.body as string;
// Calendar IDs may be URL-encoded in batch request
expect(requestBody).toMatch(/work@example\.com|work%40example\.com/);
expect(requestBody).toMatch(/personal@example\.com|personal%40example\.com/);
});
it('should resolve mix of IDs, summary names, and summaryOverride names', async () => {
const args = {
calendarId: ['primary', 'Work Calendar', 'Personal Calendar'], // Pass as array
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: vi.fn()
},
text: () => Promise.resolve(`--batch_boundary
Content-Type: application/http
HTTP/1.1 200 OK
{"items": []}
--batch_boundary--`)
});
await handler.runTool(args, mockOAuth2Client);
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
const requestBody = fetchCall[1]?.body as string;
// Should include all three calendar IDs (may be URL-encoded)
expect(requestBody).toContain('primary');
expect(requestBody).toMatch(/work@example\.com|work%40example\.com/);
expect(requestBody).toMatch(/personal@example\.com|personal%40example\.com/);
});
});
describe('error handling with summaryOverride', () => {
it('should provide helpful error listing both summaryOverride and summary', async () => {
const args = {
calendarId: 'NonExistentCalendar',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
/Calendar\(s\) not found: "NonExistentCalendar"/
);
try {
await handler.runTool(args, mockOAuth2Client);
} catch (error: any) {
// Error message should show both override and original name
expect(error.message).toContain('Work Calendar');
expect(error.message).toContain('Engineering Team - Project Alpha - Q4 2024');
expect(error.message).toContain('My Team');
expect(error.message).toContain('Team Events');
}
});
it('should handle calendar with summaryOverride same as summary', async () => {
// Update mock to have a calendar where override equals summary
mockCalendar.calendarList.list.mockResolvedValueOnce({
data: {
items: [
{
id: '[email protected]',
summary: 'Test Calendar',
summaryOverride: 'Test Calendar'
}
]
}
});
const args = {
calendarId: 'NonExistent',
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
try {
await handler.runTool(args, mockOAuth2Client);
} catch (error: any) {
// Should not show duplicate when override equals summary
const message = error.message;
const matches = (message.match(/Test Calendar/g) || []).length;
expect(matches).toBe(1);
}
});
});
describe('performance optimization', () => {
it('should skip API call when all inputs are IDs', async () => {
const args = {
calendarId: ['primary', '[email protected]'], // Pass as array
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
// Reset the mock to track calls
mockCalendar.calendarList.list.mockClear();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: vi.fn()
},
text: () => Promise.resolve(`--batch_boundary
Content-Type: application/http
HTTP/1.1 200 OK
{"items": []}
--batch_boundary--`)
});
await handler.runTool(args, mockOAuth2Client);
// Should NOT have called calendarList.list since all inputs are IDs
expect(mockCalendar.calendarList.list).not.toHaveBeenCalled();
});
it('should call API only once for multiple name resolutions', async () => {
const args = {
calendarId: ['Work Calendar', 'Personal Calendar', 'My Team'], // Pass as array
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
mockCalendar.calendarList.list.mockClear();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: vi.fn()
},
text: () => Promise.resolve(`--batch_boundary
Content-Type: application/http
HTTP/1.1 200 OK
{"items": []}
--batch_boundary--`)
});
await handler.runTool(args, mockOAuth2Client);
// Should have called calendarList.list exactly once
expect(mockCalendar.calendarList.list).toHaveBeenCalledTimes(1);
});
});
describe('input validation', () => {
it('should filter out empty strings', async () => {
const args = {
calendarId: ['primary', '', 'Work Calendar'], // Pass as array
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: vi.fn()
},
text: () => Promise.resolve(`--batch_boundary
Content-Type: application/http
HTTP/1.1 200 OK
{"items": []}
--batch_boundary--`)
});
// Should not throw - empty string should be filtered out
await expect(handler.runTool(args, mockOAuth2Client)).resolves.toBeDefined();
});
it('should reject when all inputs are empty/whitespace', async () => {
const args = {
calendarId: ['', ' ', '\t'], // Pass as array
timeMin: '2025-06-02T00:00:00Z',
timeMax: '2025-06-09T23:59:59Z'
};
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
/At least one valid calendar identifier is required/
);
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/unit/index.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for the Google Calendar MCP Server implementation
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { OAuth2Client } from "google-auth-library";
// Import tool handlers to test them directly
import { ListCalendarsHandler } from "../../handlers/core/ListCalendarsHandler.js";
import { CreateEventHandler } from "../../handlers/core/CreateEventHandler.js";
import { ListEventsHandler } from "../../handlers/core/ListEventsHandler.js";
// Mock OAuth2Client
vi.mock('google-auth-library', () => ({
OAuth2Client: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
refreshAccessToken: vi.fn().mockResolvedValue({ credentials: { access_token: 'mock_access_token' } }),
on: vi.fn(),
}))
}));
// Mock googleapis
vi.mock('googleapis', () => ({
google: {
calendar: vi.fn().mockReturnValue({
calendarList: {
list: vi.fn(),
get: vi.fn()
},
events: {
list: vi.fn(),
insert: vi.fn(),
patch: vi.fn(),
delete: vi.fn()
},
colors: {
get: vi.fn()
},
freebusy: {
query: vi.fn()
}
})
}
}));
// Mock TokenManager
vi.mock('./auth/tokenManager.js', () => ({
TokenManager: vi.fn().mockImplementation(() => ({
validateTokens: vi.fn().mockResolvedValue(true),
loadSavedTokens: vi.fn().mockResolvedValue(true),
clearTokens: vi.fn(),
})),
}));
describe('Google Calendar MCP Server', () => {
let mockOAuth2Client: OAuth2Client;
beforeEach(() => {
vi.clearAllMocks();
mockOAuth2Client = new OAuth2Client();
});
describe('McpServer Configuration', () => {
it('should create McpServer with correct configuration', () => {
const server = new McpServer({
name: "google-calendar",
version: "1.2.0"
});
expect(server).toBeDefined();
// McpServer doesn't expose internal configuration for testing,
// but we can verify it doesn't throw during creation
});
});
describe('Tool Handlers', () => {
it('should handle list-calendars tool correctly', async () => {
const handler = new ListCalendarsHandler();
const { google } = await import('googleapis');
const mockCalendarApi = google.calendar('v3');
// Mock the API response
(mockCalendarApi.calendarList.list as any).mockResolvedValue({
data: {
items: [
{
id: 'cal1',
summary: 'Work Calendar',
timeZone: 'America/New_York',
kind: 'calendar#calendarListEntry',
accessRole: 'owner',
primary: true,
selected: true,
hidden: false,
backgroundColor: '#0D7377',
defaultReminders: [
{ method: 'popup', minutes: 15 },
{ method: 'email', minutes: 60 }
],
description: 'Work-related events and meetings'
},
{
id: 'cal2',
summary: 'Personal',
timeZone: 'America/Los_Angeles',
kind: 'calendar#calendarListEntry',
accessRole: 'reader',
primary: false,
selected: true,
hidden: false,
backgroundColor: '#D50000'
},
]
}
});
const result = await handler.runTool({}, mockOAuth2Client);
expect(mockCalendarApi.calendarList.list).toHaveBeenCalled();
// Parse the JSON response
const response = JSON.parse((result.content as any)[0].text);
expect(response.totalCount).toBe(2);
expect(response.calendars).toHaveLength(2);
expect(response.calendars[0]).toMatchObject({
id: 'cal1',
summary: 'Work Calendar',
description: 'Work-related events and meetings',
timeZone: 'America/New_York',
backgroundColor: '#0D7377',
accessRole: 'owner',
primary: true,
selected: true,
hidden: false
});
expect(response.calendars[0].defaultReminders).toHaveLength(2);
expect(response.calendars[1]).toMatchObject({
id: 'cal2',
summary: 'Personal',
timeZone: 'America/Los_Angeles',
backgroundColor: '#D50000',
accessRole: 'reader',
primary: false
});
});
it('should handle create-event tool with valid arguments', async () => {
const handler = new CreateEventHandler();
const { google } = await import('googleapis');
const mockCalendarApi = google.calendar('v3');
const mockEventArgs = {
calendarId: 'primary',
summary: 'Team Meeting',
description: 'Discuss project progress',
start: '2024-08-15T10:00:00',
end: '2024-08-15T11:00:00',
attendees: [{ email: '[email protected]' }],
location: 'Conference Room 4',
};
const mockApiResponse = {
id: 'eventId123',
summary: mockEventArgs.summary,
};
// Mock calendar details for timezone retrieval
(mockCalendarApi.calendarList.get as any).mockResolvedValue({
data: {
id: 'primary',
timeZone: 'America/Los_Angeles'
}
});
(mockCalendarApi.events.insert as any).mockResolvedValue({ data: mockApiResponse });
const result = await handler.runTool(mockEventArgs, mockOAuth2Client);
expect(mockCalendarApi.calendarList.get).toHaveBeenCalledWith({ calendarId: 'primary' });
expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
calendarId: mockEventArgs.calendarId,
requestBody: expect.objectContaining({
summary: mockEventArgs.summary,
description: mockEventArgs.description,
start: { dateTime: mockEventArgs.start, timeZone: 'America/Los_Angeles' },
end: { dateTime: mockEventArgs.end, timeZone: 'America/Los_Angeles' },
attendees: mockEventArgs.attendees,
location: mockEventArgs.location,
}),
});
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.event).toBeDefined();
expect(response.event.id).toBe('eventId123');
expect(response.event.summary).toBe('Team Meeting');
});
it('should use calendar default timezone when timeZone is not provided', async () => {
const handler = new CreateEventHandler();
const { google } = await import('googleapis');
const mockCalendarApi = google.calendar('v3');
const mockEventArgs = {
calendarId: 'primary',
summary: 'Meeting without timezone',
start: '2024-08-15T10:00:00', // Timezone-naive datetime
end: '2024-08-15T11:00:00', // Timezone-naive datetime
};
// Mock calendar details with specific timezone
(mockCalendarApi.calendarList.get as any).mockResolvedValue({
data: {
id: 'primary',
timeZone: 'Europe/London'
}
});
(mockCalendarApi.events.insert as any).mockResolvedValue({
data: { id: 'testEvent', summary: mockEventArgs.summary }
});
await handler.runTool(mockEventArgs, mockOAuth2Client);
// Verify that the calendar's timezone was used
expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
calendarId: mockEventArgs.calendarId,
requestBody: expect.objectContaining({
start: { dateTime: mockEventArgs.start, timeZone: 'Europe/London' },
end: { dateTime: mockEventArgs.end, timeZone: 'Europe/London' },
}),
});
});
it('should handle timezone-aware datetime strings correctly', async () => {
const handler = new CreateEventHandler();
const { google } = await import('googleapis');
const mockCalendarApi = google.calendar('v3');
const mockEventArgs = {
calendarId: 'primary',
summary: 'Meeting with timezone in datetime',
start: '2024-08-15T10:00:00-07:00', // Timezone-aware datetime
end: '2024-08-15T11:00:00-07:00', // Timezone-aware datetime
};
// Mock calendar details (should not be used since timezone is in datetime)
(mockCalendarApi.calendarList.get as any).mockResolvedValue({
data: {
id: 'primary',
timeZone: 'Europe/London'
}
});
(mockCalendarApi.events.insert as any).mockResolvedValue({
data: { id: 'testEvent', summary: mockEventArgs.summary }
});
await handler.runTool(mockEventArgs, mockOAuth2Client);
// Verify that timezone from datetime was used (no timeZone property)
expect(mockCalendarApi.events.insert).toHaveBeenCalledWith({
calendarId: mockEventArgs.calendarId,
requestBody: expect.objectContaining({
start: { dateTime: mockEventArgs.start }, // No timeZone property
end: { dateTime: mockEventArgs.end }, // No timeZone property
}),
});
});
it('should handle list-events tool correctly', async () => {
const handler = new ListEventsHandler();
const { google } = await import('googleapis');
const mockCalendarApi = google.calendar('v3');
const listEventsArgs = {
calendarId: 'primary',
timeMin: '2024-08-01T00:00:00Z',
timeMax: '2024-08-31T23:59:59Z',
};
const mockEvents = [
{
id: 'event1',
summary: 'Meeting',
start: { dateTime: '2024-08-15T10:00:00Z' },
end: { dateTime: '2024-08-15T11:00:00Z' }
},
];
(mockCalendarApi.events.list as any).mockResolvedValue({
data: { items: mockEvents }
});
const result = await handler.runTool(listEventsArgs, mockOAuth2Client);
expect(mockCalendarApi.events.list).toHaveBeenCalledWith({
calendarId: listEventsArgs.calendarId,
timeMin: listEventsArgs.timeMin,
timeMax: listEventsArgs.timeMax,
singleEvents: true,
orderBy: 'startTime'
});
// Should return structured JSON with events
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.events).toHaveLength(1);
expect(response.totalCount).toBe(1);
expect(response.events[0].id).toBe('event1');
});
});
describe('Configuration and Environment Variables', () => {
it('should parse environment variables correctly', async () => {
const originalEnv = process.env;
try {
// Set test environment variables
process.env.TRANSPORT = 'http';
process.env.PORT = '4000';
process.env.HOST = '0.0.0.0';
process.env.DEBUG = 'true';
// Import config parser after setting env vars
const { parseArgs } = await import('../../config/TransportConfig.js');
const config = parseArgs([]);
expect(config.transport.type).toBe('http');
expect(config.transport.port).toBe(4000);
expect(config.transport.host).toBe('0.0.0.0');
expect(config.debug).toBe(true);
} finally {
// Restore original environment
process.env = originalEnv;
}
});
it('should allow CLI arguments to override environment variables', async () => {
const originalEnv = process.env;
try {
// Set environment variables
process.env.TRANSPORT = 'http';
process.env.PORT = '4000';
const { parseArgs } = await import('../../config/TransportConfig.js');
// CLI arguments should override env vars
const config = parseArgs(['--transport', 'stdio', '--port', '5000']);
expect(config.transport.type).toBe('stdio');
expect(config.transport.port).toBe(5000);
} finally {
process.env = originalEnv;
}
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/list-events-registry.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Comprehensive tests for list-events tool registration flow
* Tests the complete path: schema validation → handlerFunction → handler execution
*
* These tests verify the fix for issue #95 by testing:
* 1. Schema validation (accepts all formats)
* 2. HandlerFunction preprocessing (converts single-quoted JSON, validates arrays)
* 3. Real-world scenarios from Home Assistant and other integrations
*/
import { describe, it, expect } from 'vitest';
import { ToolSchemas, ToolRegistry } from '../../../tools/registry.js';
// Get the handlerFunction for testing the full flow
const toolDefinition = (ToolRegistry as any).tools?.find((t: any) => t.name === 'list-events');
const handlerFunction = toolDefinition?.handlerFunction;
describe('list-events Registration Flow (Schema + HandlerFunction)', () => {
describe('Schema validation (first step)', () => {
it('should validate native array format', () => {
const input = {
calendarId: ['primary', '[email protected]'],
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(true);
expect(result.data?.calendarId).toEqual(['primary', '[email protected]']);
});
it('should validate single string format', () => {
const input = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(true);
expect(result.data?.calendarId).toBe('primary');
});
it('should validate JSON string format', () => {
const input = {
calendarId: '["primary", "[email protected]"]',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(true);
expect(result.data?.calendarId).toBe('["primary", "[email protected]"]');
});
});
describe('Array validation constraints', () => {
it('should enforce minimum array length', () => {
const input = {
calendarId: [],
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('At least one calendar ID is required');
}
});
it('should enforce maximum array length', () => {
const input = {
calendarId: Array(51).fill('calendar'),
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Maximum 50 calendars');
}
});
it('should reject duplicate calendar IDs in array', () => {
const input = {
calendarId: ['primary', 'primary'],
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Duplicate calendar IDs');
}
});
it('should reject empty strings in array', () => {
const input = {
calendarId: ['primary', ''],
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(false);
});
});
describe('Type preservation after validation', () => {
it('should preserve array type for native arrays (issue #95 fix)', () => {
const input = {
calendarId: ['primary', '[email protected]', '[email protected]'],
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].parse(input);
// The key fix: arrays should NOT be transformed to JSON strings by the schema
// The handlerFunction will handle the conversion logic
expect(Array.isArray(result.calendarId)).toBe(true);
expect(result.calendarId).toEqual(['primary', '[email protected]', '[email protected]']);
});
it('should preserve string type for single strings', () => {
const input = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].parse(input);
expect(typeof result.calendarId).toBe('string');
expect(result.calendarId).toBe('primary');
});
it('should preserve string type for JSON strings', () => {
const input = {
calendarId: '["primary", "[email protected]"]',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].parse(input);
expect(typeof result.calendarId).toBe('string');
expect(result.calendarId).toBe('["primary", "[email protected]"]');
});
});
describe('Real-world scenarios from issue #95', () => {
it('should handle exact input from Home Assistant multi-mcp', () => {
// This is the exact format that was failing in issue #95
const input = {
calendarId: ['primary', '[email protected]', '[email protected]', '[email protected]', '[email protected]'],
timeMin: '2025-10-09T00:00:00',
timeMax: '2025-10-09T23:59:59'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(true);
expect(Array.isArray(result.data?.calendarId)).toBe(true);
expect(result.data?.calendarId).toHaveLength(5);
});
it('should handle mixed special characters in calendar IDs', () => {
const input = {
calendarId: ['primary', '[email protected]', '[email protected]'],
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(true);
expect(result.data?.calendarId).toEqual(['primary', '[email protected]', '[email protected]']);
});
it('should accept single-quoted JSON string format (Python/shell style)', () => {
// Some clients may send JSON-like strings with single quotes instead of double quotes
// e.g., from Python str() representation or shell scripts
// The schema should accept it as a string (handlerFunction will process it)
const input = {
calendarId: "['primary', '[email protected]']",
timeMin: '2025-10-09T00:00:00',
timeMax: '2025-10-09T23:59:59'
};
// Schema should accept it as a string (not reject it)
const result = ToolSchemas['list-events'].safeParse(input);
expect(result.success).toBe(true);
expect(typeof result.data?.calendarId).toBe('string');
expect(result.data?.calendarId).toBe("['primary', '[email protected]']");
});
});
// HandlerFunction tests - second step after schema validation
if (!handlerFunction) {
console.warn('⚠️ handlerFunction not found - skipping handler tests');
} else {
describe('HandlerFunction preprocessing (second step)', () => {
describe('Format handling', () => {
it('should pass through native arrays unchanged', async () => {
const input = {
calendarId: ['primary', '[email protected]'],
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = await handlerFunction(input);
expect(Array.isArray(result.calendarId)).toBe(true);
expect(result.calendarId).toEqual(['primary', '[email protected]']);
});
it('should pass through single strings unchanged', async () => {
const input = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = await handlerFunction(input);
expect(typeof result.calendarId).toBe('string');
expect(result.calendarId).toBe('primary');
});
it('should parse valid JSON strings with double quotes', async () => {
const input = {
calendarId: '["primary", "[email protected]"]',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = await handlerFunction(input);
expect(Array.isArray(result.calendarId)).toBe(true);
expect(result.calendarId).toEqual(['primary', '[email protected]']);
});
it('should parse single-quoted JSON-like strings (Python/shell style) - THE KEY FIX', async () => {
// This is the failing case that needed fixing
const input = {
calendarId: "['primary', '[email protected]']",
timeMin: '2025-10-09T00:00:00',
timeMax: '2025-10-09T23:59:59'
};
const result = await handlerFunction(input);
expect(Array.isArray(result.calendarId)).toBe(true);
expect(result.calendarId).toEqual(['primary', '[email protected]']);
});
it('should handle calendar IDs with apostrophes in single-quoted JSON', async () => {
// Calendar IDs can contain apostrophes (e.g., "John's Calendar")
// Our replacement logic should not break these
const input = {
calendarId: "['primary', '[email protected]']",
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = await handlerFunction(input);
expect(Array.isArray(result.calendarId)).toBe(true);
expect(result.calendarId).toEqual(['primary', '[email protected]']);
});
it('should handle JSON strings with whitespace', async () => {
const input = {
calendarId: ' ["primary", "[email protected]"] ',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
const result = await handlerFunction(input);
expect(Array.isArray(result.calendarId)).toBe(true);
expect(result.calendarId).toEqual(['primary', '[email protected]']);
});
});
describe('JSON string validation', () => {
it('should reject empty arrays in JSON strings', async () => {
const input = {
calendarId: '[]',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
await expect(handlerFunction(input)).rejects.toThrow('At least one calendar ID is required');
});
it('should reject arrays exceeding 50 calendars', async () => {
const input = {
calendarId: JSON.stringify(Array(51).fill('calendar')),
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
await expect(handlerFunction(input)).rejects.toThrow('Maximum 50 calendars');
});
it('should reject duplicate calendar IDs in JSON strings', async () => {
const input = {
calendarId: '["primary", "primary"]',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
await expect(handlerFunction(input)).rejects.toThrow('Duplicate calendar IDs');
});
});
describe('Error handling', () => {
it('should provide clear error for malformed JSON array', async () => {
const input = {
calendarId: '["primary", "missing-quote}]',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
await expect(handlerFunction(input)).rejects.toThrow('Invalid JSON format for calendarId');
});
it('should reject JSON arrays with non-string elements', async () => {
const input = {
calendarId: '["primary", 123, null]',
timeMin: '2024-01-01T00:00:00',
timeMax: '2024-01-02T00:00:00'
};
await expect(handlerFunction(input)).rejects.toThrow('Array must contain only non-empty strings');
});
});
});
}
});
```
--------------------------------------------------------------------------------
/src/auth/tokenManager.ts:
--------------------------------------------------------------------------------
```typescript
import { OAuth2Client, Credentials } from 'google-auth-library';
import fs from 'fs/promises';
import { getSecureTokenPath, getAccountMode, getLegacyTokenPath } from './utils.js';
import { GaxiosError } from 'gaxios';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
// Interface for multi-account token storage
interface MultiAccountTokens {
normal?: Credentials;
test?: Credentials;
}
export class TokenManager {
private oauth2Client: OAuth2Client;
private tokenPath: string;
private accountMode: 'normal' | 'test';
constructor(oauth2Client: OAuth2Client) {
this.oauth2Client = oauth2Client;
this.tokenPath = getSecureTokenPath();
this.accountMode = getAccountMode();
this.setupTokenRefresh();
}
// Method to expose the token path
public getTokenPath(): string {
return this.tokenPath;
}
// Method to get current account mode
public getAccountMode(): 'normal' | 'test' {
return this.accountMode;
}
// Method to switch account mode (useful for testing)
public setAccountMode(mode: 'normal' | 'test'): void {
this.accountMode = mode;
}
private async ensureTokenDirectoryExists(): Promise<void> {
try {
await mkdir(dirname(this.tokenPath), { recursive: true });
} catch (error) {
process.stderr.write(`Failed to create token directory: ${error}\n`);
}
}
private async loadMultiAccountTokens(): Promise<MultiAccountTokens> {
try {
const fileContent = await fs.readFile(this.tokenPath, "utf-8");
const parsed = JSON.parse(fileContent);
// Check if this is the old single-account format
if (parsed.access_token || parsed.refresh_token) {
// Convert old format to new multi-account format
const multiAccountTokens: MultiAccountTokens = {
normal: parsed
};
await this.saveMultiAccountTokens(multiAccountTokens);
return multiAccountTokens;
}
// Already in multi-account format
return parsed as MultiAccountTokens;
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// File doesn't exist, return empty structure
return {};
}
throw error;
}
}
private async saveMultiAccountTokens(multiAccountTokens: MultiAccountTokens): Promise<void> {
await this.ensureTokenDirectoryExists();
await fs.writeFile(this.tokenPath, JSON.stringify(multiAccountTokens, null, 2), {
mode: 0o600,
});
}
private setupTokenRefresh(): void {
this.oauth2Client.on("tokens", async (newTokens) => {
try {
const multiAccountTokens = await this.loadMultiAccountTokens();
const currentTokens = multiAccountTokens[this.accountMode] || {};
const updatedTokens = {
...currentTokens,
...newTokens,
refresh_token: newTokens.refresh_token || currentTokens.refresh_token,
};
multiAccountTokens[this.accountMode] = updatedTokens;
await this.saveMultiAccountTokens(multiAccountTokens);
if (process.env.NODE_ENV !== 'test') {
process.stderr.write(`Tokens updated and saved for ${this.accountMode} account\n`);
}
} catch (error: unknown) {
// Handle case where file might not exist yet
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
try {
const multiAccountTokens: MultiAccountTokens = {
[this.accountMode]: newTokens
};
await this.saveMultiAccountTokens(multiAccountTokens);
if (process.env.NODE_ENV !== 'test') {
process.stderr.write(`New tokens saved for ${this.accountMode} account\n`);
}
} catch (writeError) {
process.stderr.write("Error saving initial tokens: ");
if (writeError) {
process.stderr.write(writeError.toString());
}
process.stderr.write("\n");
}
} else {
process.stderr.write("Error saving updated tokens: ");
if (error instanceof Error) {
process.stderr.write(error.message);
} else if (typeof error === 'string') {
process.stderr.write(error);
}
process.stderr.write("\n");
}
}
});
}
private async migrateLegacyTokens(): Promise<boolean> {
const legacyPath = getLegacyTokenPath();
try {
// Check if legacy tokens exist
if (!(await fs.access(legacyPath).then(() => true).catch(() => false))) {
return false; // No legacy tokens to migrate
}
// Read legacy tokens
const legacyTokens = JSON.parse(await fs.readFile(legacyPath, "utf-8"));
if (!legacyTokens || typeof legacyTokens !== "object") {
process.stderr.write("Invalid legacy token format, skipping migration\n");
return false;
}
// Ensure new token directory exists
await this.ensureTokenDirectoryExists();
// Copy to new location
await fs.writeFile(this.tokenPath, JSON.stringify(legacyTokens, null, 2), {
mode: 0o600,
});
process.stderr.write(`Migrated tokens from legacy location: ${legacyPath} to: ${this.tokenPath}\n`);
// Optionally remove legacy file after successful migration
try {
await fs.unlink(legacyPath);
process.stderr.write("Removed legacy token file\n");
} catch (unlinkErr) {
process.stderr.write(`Warning: Could not remove legacy token file: ${unlinkErr}\n`);
}
return true;
} catch (error) {
process.stderr.write(`Error migrating legacy tokens: ${error}\n`);
return false;
}
}
async loadSavedTokens(): Promise<boolean> {
try {
await this.ensureTokenDirectoryExists();
// Check if current token file exists
const tokenExists = await fs.access(this.tokenPath).then(() => true).catch(() => false);
// If no current tokens, try to migrate from legacy location
if (!tokenExists) {
const migrated = await this.migrateLegacyTokens();
if (!migrated) {
process.stderr.write(`No token file found at: ${this.tokenPath}\n`);
return false;
}
}
const multiAccountTokens = await this.loadMultiAccountTokens();
const tokens = multiAccountTokens[this.accountMode];
if (!tokens || typeof tokens !== "object") {
process.stderr.write(`No tokens found for ${this.accountMode} account in file: ${this.tokenPath}\n`);
return false;
}
this.oauth2Client.setCredentials(tokens);
process.stderr.write(`Loaded tokens for ${this.accountMode} account\n`);
return true;
} catch (error: unknown) {
process.stderr.write(`Error loading tokens for ${this.accountMode} account: `);
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
try {
await fs.unlink(this.tokenPath);
process.stderr.write("Removed potentially corrupted token file\n");
} catch (unlinkErr) { /* ignore */ }
}
return false;
}
}
async refreshTokensIfNeeded(): Promise<boolean> {
const expiryDate = this.oauth2Client.credentials.expiry_date;
const isExpired = expiryDate
? Date.now() >= expiryDate - 5 * 60 * 1000 // 5 minute buffer
: !this.oauth2Client.credentials.access_token; // No token means we need one
if (isExpired && this.oauth2Client.credentials.refresh_token) {
if (process.env.NODE_ENV !== 'test') {
process.stderr.write(`Auth token expired or nearing expiry for ${this.accountMode} account, refreshing...\n`);
}
try {
const response = await this.oauth2Client.refreshAccessToken();
const newTokens = response.credentials;
if (!newTokens.access_token) {
throw new Error("Received invalid tokens during refresh");
}
// The 'tokens' event listener should handle saving
this.oauth2Client.setCredentials(newTokens);
if (process.env.NODE_ENV !== 'test') {
process.stderr.write(`Token refreshed successfully for ${this.accountMode} account\n`);
}
return true;
} catch (refreshError) {
if (refreshError instanceof GaxiosError && refreshError.response?.data?.error === 'invalid_grant') {
process.stderr.write(`Error refreshing auth token for ${this.accountMode} account: Invalid grant. Token likely expired or revoked. Please re-authenticate.\n`);
return false; // Indicate failure due to invalid grant
} else {
// Handle other refresh errors
process.stderr.write(`Error refreshing auth token for ${this.accountMode} account: `);
if (refreshError instanceof Error) {
process.stderr.write(refreshError.message);
} else if (typeof refreshError === 'string') {
process.stderr.write(refreshError);
}
process.stderr.write("\n");
return false;
}
}
} else if (!this.oauth2Client.credentials.access_token && !this.oauth2Client.credentials.refresh_token) {
process.stderr.write(`No access or refresh token available for ${this.accountMode} account. Please re-authenticate.\n`);
return false;
} else {
// Token is valid or no refresh token available
return true;
}
}
async validateTokens(accountMode?: 'normal' | 'test'): Promise<boolean> {
// For unit tests that don't need real authentication, they should mock at the handler level
// Integration tests always need real tokens
const modeToValidate = accountMode || this.accountMode;
const currentMode = this.accountMode;
try {
// Temporarily switch to the mode we want to validate if different
if (modeToValidate !== currentMode) {
this.accountMode = modeToValidate;
}
if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) {
// Try loading first if no credentials set
if (!(await this.loadSavedTokens())) {
return false; // No saved tokens to load
}
// Check again after loading
if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) {
return false; // Still no token after loading
}
}
const result = await this.refreshTokensIfNeeded();
return result;
} finally {
// Always restore the original account mode
if (modeToValidate !== currentMode) {
this.accountMode = currentMode;
}
}
}
async saveTokens(tokens: Credentials): Promise<void> {
try {
const multiAccountTokens = await this.loadMultiAccountTokens();
multiAccountTokens[this.accountMode] = tokens;
await this.saveMultiAccountTokens(multiAccountTokens);
this.oauth2Client.setCredentials(tokens);
process.stderr.write(`Tokens saved successfully for ${this.accountMode} account to: ${this.tokenPath}\n`);
} catch (error: unknown) {
process.stderr.write(`Error saving tokens for ${this.accountMode} account: ${error}\n`);
throw error;
}
}
async clearTokens(): Promise<void> {
try {
this.oauth2Client.setCredentials({}); // Clear in memory
const multiAccountTokens = await this.loadMultiAccountTokens();
delete multiAccountTokens[this.accountMode];
// If no accounts left, delete the entire file
if (Object.keys(multiAccountTokens).length === 0) {
await fs.unlink(this.tokenPath);
process.stderr.write(`All tokens cleared, file deleted\n`);
} else {
await this.saveMultiAccountTokens(multiAccountTokens);
process.stderr.write(`Tokens cleared for ${this.accountMode} account\n`);
}
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// File already gone, which is fine
process.stderr.write("Token file already deleted\n");
} else {
process.stderr.write(`Error clearing tokens for ${this.accountMode} account: ${error}\n`);
// Don't re-throw, clearing is best-effort
}
}
}
// Method to list available accounts
async listAvailableAccounts(): Promise<string[]> {
try {
const multiAccountTokens = await this.loadMultiAccountTokens();
return Object.keys(multiAccountTokens);
} catch (error) {
return [];
}
}
// Method to switch to a different account (useful for runtime switching)
async switchAccount(newMode: 'normal' | 'test'): Promise<boolean> {
this.accountMode = newMode;
return this.loadSavedTokens();
}
}
```
--------------------------------------------------------------------------------
/scripts/test-docker.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# Docker Testing Script for Google Calendar MCP Server
# Tests Docker container functionality including stdio/HTTP modes and calendar integration
set -e # Exit on any error
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_TIMEOUT=120
HTTP_PORT=3001 # Use different port to avoid conflicts
CONTAINER_NAME="test-calendar-mcp"
CONTAINER_NAME_STDIO="test-calendar-mcp-stdio"
CONTAINER_NAME_HTTP="test-calendar-mcp-http"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}ℹ️ $1${NC}"
}
log_success() {
echo -e "${GREEN}✅ $1${NC}"
}
log_warn() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}❌ $1${NC}"
}
# Cleanup function
cleanup() {
log_info "Cleaning up test containers..."
# Stop and remove containers
docker stop "$CONTAINER_NAME_STDIO" 2>/dev/null || true
docker stop "$CONTAINER_NAME_HTTP" 2>/dev/null || true
docker rm "$CONTAINER_NAME_STDIO" 2>/dev/null || true
docker rm "$CONTAINER_NAME_HTTP" 2>/dev/null || true
# Remove test network if it exists
docker network rm mcp-test-network 2>/dev/null || true
log_info "Cleanup completed"
}
# Trap to ensure cleanup on exit
trap cleanup EXIT
# Check prerequisites
check_prerequisites() {
log_info "Checking prerequisites..."
# Check Docker
if ! command -v docker &> /dev/null; then
log_error "Docker is not installed"
exit 1
fi
# Check docker compose (modern command)
if ! docker compose version &> /dev/null; then
log_error "Docker Compose plugin is not installed"
log_info "Please install Docker Compose plugin: https://docs.docker.com/compose/install/"
exit 1
fi
# Check OAuth credentials
if [[ ! -f "$PROJECT_ROOT/gcp-oauth.keys.json" ]]; then
log_error "OAuth credentials file not found: gcp-oauth.keys.json"
log_info "Please download OAuth credentials from Google Cloud Console"
exit 1
fi
# Check environment variables for integration tests
if [[ -z "$TEST_CALENDAR_ID" ]]; then
log_warn "TEST_CALENDAR_ID not set - integration tests will be limited"
fi
log_success "Prerequisites check passed"
}
# Build Docker image
build_image() {
log_info "Building Docker image..."
cd "$PROJECT_ROOT"
docker build -t google-calendar-mcp:test .
log_success "Docker image built successfully"
}
# Test container startup and basic functionality
test_container_health() {
local mode=$1
local container_name=$2
log_info "Testing $mode mode container health..."
case $mode in
"stdio")
# Start stdio container with shell to keep it running
docker run -d \
--name "$container_name" \
-v "$PROJECT_ROOT/gcp-oauth.keys.json:/app/gcp-oauth.keys.json:ro" \
-v mcp-test-tokens:/home/nodejs/.config/google-calendar-mcp \
-e NODE_ENV=test \
-e TRANSPORT=stdio \
--entrypoint=/bin/sh \
google-calendar-mcp:test -c "while true; do sleep 30; done"
;;
"http")
# Start HTTP container
docker run -d \
--name "$container_name" \
-p "$HTTP_PORT:3000" \
-v "$PROJECT_ROOT/gcp-oauth.keys.json:/app/gcp-oauth.keys.json:ro" \
-v mcp-test-tokens:/home/nodejs/.config/google-calendar-mcp \
-e NODE_ENV=test \
-e TRANSPORT=http \
-e HOST=0.0.0.0 \
-e PORT=3000 \
google-calendar-mcp:test
;;
esac
# Wait for container to be ready
log_info "Waiting for container to be ready..."
sleep 5
# Check if container is running
if ! docker ps | grep -q "$container_name"; then
log_error "Container $container_name failed to start"
docker logs "$container_name"
return 1
fi
log_success "$mode mode container is healthy"
}
# Test HTTP endpoint accessibility
test_http_endpoints() {
log_info "Testing HTTP endpoints..."
# Wait for HTTP server to be ready
for i in {1..30}; do
if curl -s "http://localhost:$HTTP_PORT/health" > /dev/null 2>&1; then
break
fi
sleep 1
if [[ $i -eq 30 ]]; then
log_error "HTTP server failed to start within 30 seconds"
docker logs "$CONTAINER_NAME_HTTP"
return 1
fi
done
# Test health endpoint
if ! curl -s "http://localhost:$HTTP_PORT/health" | grep -q "healthy"; then
log_error "Health endpoint not responding correctly"
return 1
fi
# Test info endpoint
if ! curl -s "http://localhost:$HTTP_PORT/info" > /dev/null; then
log_error "Info endpoint not accessible"
return 1
fi
log_success "HTTP endpoints are accessible"
}
# Test MCP tool listing via Docker
test_mcp_tools() {
local container_name=$1
log_info "Testing MCP tool availability in container..."
# Create a simple Node.js script to test MCP connection
cat > "$PROJECT_ROOT/test-mcp-connection.js" << 'EOF'
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
const { spawn } = require('child_process');
async function testMCPConnection() {
const client = new Client({
name: "docker-test-client",
version: "1.0.0"
}, {
capabilities: { tools: {} }
});
try {
// For stdio mode, exec into the container
const transport = new StdioClientTransport({
command: 'docker',
args: ['exec', '-i', process.argv[2], 'npm', 'start'],
env: { ...process.env, NODE_ENV: 'test' }
});
await client.connect(transport);
const tools = await client.listTools();
console.log(`✅ Successfully connected to MCP server in container`);
console.log(`📋 Available tools: ${tools.tools.length}`);
// Test a simple tool call (list-calendars doesn't require auth setup)
try {
const result = await client.callTool({
name: 'list-calendars',
arguments: {}
});
console.log(`🔧 Tool execution test: SUCCESS`);
} catch (toolError) {
// Expected for auth issues in test environment
console.log(`🔧 Tool execution test: ${toolError.message.includes('auth') ? 'AUTH_REQUIRED (expected)' : 'FAILED'}`);
}
await client.close();
process.exit(0);
} catch (error) {
console.error(`❌ MCP connection failed:`, error.message);
process.exit(1);
}
}
if (process.argv.length < 3) {
console.error('Usage: node test-mcp-connection.js <container-name>');
process.exit(1);
}
testMCPConnection();
EOF
# Run the MCP connection test
if node "$PROJECT_ROOT/test-mcp-connection.js" "$container_name"; then
log_success "MCP tools accessible via Docker"
else
log_error "MCP tools test failed"
return 1
fi
# Cleanup test file
rm -f "$PROJECT_ROOT/test-mcp-connection.js"
}
# Test Docker Compose integration (simplified setup)
test_docker_compose() {
log_info "Testing Docker Compose integration..."
cd "$PROJECT_ROOT"
# Test stdio mode (default)
docker compose up -d
sleep 5
if ! docker compose ps | grep -q "calendar-mcp.*Up"; then
log_error "Docker Compose stdio mode failed"
docker compose logs calendar-mcp
return 1
fi
docker compose down
# Test HTTP mode by temporarily modifying compose file
log_info "Testing HTTP mode (requires manual setup)..."
log_warn "HTTP mode test skipped - requires manual docker-compose.yml edit"
log_info "To test HTTP mode manually:"
log_info "1. Uncomment ports and environment sections in docker-compose.yml"
log_info "2. Run: docker compose up -d"
log_info "3. Test: curl http://localhost:3000/health"
log_success "Docker Compose integration working"
}
# Test authentication setup (if credentials available)
test_auth_setup() {
log_info "Testing authentication setup in container..."
# This will test if the auth command works (may fail due to interactive nature)
if docker exec "$CONTAINER_NAME_STDIO" npm run auth --help > /dev/null 2>&1; then
log_success "Auth command accessible in container"
else
log_warn "Auth command test inconclusive (expected for non-interactive environment)"
fi
# Test token file paths are accessible
if docker exec "$CONTAINER_NAME_STDIO" ls -la /home/nodejs/.config/google-calendar-mcp/ > /dev/null 2>&1; then
log_success "Token storage directory accessible"
else
log_error "Token storage directory not accessible"
return 1
fi
}
# Run integration tests against Docker container (if environment supports it)
test_calendar_integration() {
if [[ -z "$TEST_CALENDAR_ID" || -z "$CLAUDE_API_KEY" ]]; then
log_warn "Skipping calendar integration tests (missing TEST_CALENDAR_ID or CLAUDE_API_KEY)"
return 0
fi
log_info "Running calendar integration tests against Docker container..."
# Use existing integration test but point it to Docker container
cd "$PROJECT_ROOT"
# Set environment to use Docker container
export DOCKER_CONTAINER_NAME="$CONTAINER_NAME_STDIO"
export USE_DOCKER_CONTAINER=true
# Run subset of integration tests
if timeout $TEST_TIMEOUT npm run test:integration -- --reporter=verbose --run docker 2>/dev/null; then
log_success "Calendar integration tests passed"
else
log_warn "Calendar integration tests incomplete (may require manual auth)"
fi
}
# Performance testing
test_performance() {
log_info "Running basic performance tests..."
# Test HTTP response times
local avg_response_time
avg_response_time=$(curl -o /dev/null -s -w '%{time_total}\n' \
"http://localhost:$HTTP_PORT/health" \
"http://localhost:$HTTP_PORT/health" \
"http://localhost:$HTTP_PORT/health" | \
awk '{sum+=$1} END {print sum/NR}')
echo "Average HTTP response time: ${avg_response_time}s"
# Test container resource usage
local memory_usage
memory_usage=$(docker stats --no-stream --format "{{.MemUsage}}" "$CONTAINER_NAME_HTTP" | cut -d'/' -f1)
echo "Container memory usage: $memory_usage"
log_success "Performance tests completed"
}
# Main test execution
main() {
log_info "🐳 Starting Docker integration tests for Google Calendar MCP Server"
# Cleanup any existing test containers
cleanup
# Run test suite
check_prerequisites
build_image
# Test stdio mode
test_container_health "stdio" "$CONTAINER_NAME_STDIO"
test_mcp_tools "$CONTAINER_NAME_STDIO"
test_auth_setup
# Test HTTP mode
test_container_health "http" "$CONTAINER_NAME_HTTP"
test_http_endpoints
test_performance
# Test Docker Compose integration
test_docker_compose
# Test calendar integration (if environment supports it)
test_calendar_integration
log_success "🎉 All Docker tests completed successfully!"
# Print summary
echo ""
echo "📋 Test Summary:"
echo " ✅ Container Health (stdio & HTTP)"
echo " ✅ MCP Tool Accessibility"
echo " ✅ HTTP Endpoint Testing"
echo " ✅ Docker Compose Integration"
echo " ✅ Authentication Setup"
echo " ✅ Performance Metrics"
if [[ -n "$TEST_CALENDAR_ID" && -n "$CLAUDE_API_KEY" ]]; then
echo " ✅ Calendar Integration"
else
echo " ⚠️ Calendar Integration (skipped - missing env vars)"
fi
}
# Handle command line arguments
case "${1:-}" in
--help|-h)
echo "Docker Testing Script for Google Calendar MCP Server"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " --help, -h Show this help message"
echo " --quick Run only essential tests (faster)"
echo " --integration Run full integration tests (requires auth)"
echo ""
echo "Environment Variables:"
echo " TEST_CALENDAR_ID Calendar ID for integration tests"
echo " CLAUDE_API_KEY Anthropic API key for Claude integration"
echo " INVITEE_1, INVITEE_2 Email addresses for event testing"
echo ""
echo "Prerequisites:"
echo " - Docker and Docker Compose plugin installed"
echo " - gcp-oauth.keys.json file in project root"
echo " - For integration tests: authenticated test account"
exit 0
;;
--quick)
log_info "Running quick Docker tests only..."
check_prerequisites
build_image
test_container_health "stdio" "$CONTAINER_NAME_STDIO"
test_container_health "http" "$CONTAINER_NAME_HTTP"
test_http_endpoints
test_docker_compose
log_success "Quick tests completed!"
;;
--integration)
if [[ -z "$TEST_CALENDAR_ID" ]]; then
log_error "--integration requires TEST_CALENDAR_ID environment variable"
exit 1
fi
main
;;
"")
main
;;
*)
log_error "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
```
--------------------------------------------------------------------------------
/src/tests/unit/schemas/validators.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from 'vitest';
import { ToolSchemas } from '../../../tools/registry.js';
// Use the unified schemas from registry
const UpdateEventArgumentsSchema = ToolSchemas['update-event'];
const ListEventsArgumentsSchema = ToolSchemas['list-events'];
// Helper to generate a future date string in timezone-naive format
function getFutureDateString(daysFromNow: number = 365): string {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + daysFromNow);
// Format as timezone-naive ISO string (no timezone suffix)
return futureDate.toISOString().split('.')[0];
}
describe('UpdateEventArgumentsSchema with Recurring Event Support', () => {
describe('Basic Validation', () => {
it('should validate basic required fields', () => {
const validArgs = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles'
};
const result = UpdateEventArgumentsSchema.parse(validArgs);
expect(result.modificationScope).toBeUndefined(); // optional with no default
expect(result.calendarId).toBe('primary');
expect(result.eventId).toBe('event123');
expect(result.timeZone).toBe('America/Los_Angeles');
});
it('should reject missing required fields', () => {
const invalidArgs = {
calendarId: 'primary',
// missing eventId and timeZone
};
expect(() => UpdateEventArgumentsSchema.parse(invalidArgs)).toThrow();
});
it('should validate optional fields when provided', () => {
const validArgs = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
summary: 'Updated Meeting',
description: 'Updated description',
location: 'New Location',
colorId: '9',
start: '2024-06-15T10:00:00',
end: '2024-06-15T11:00:00'
};
const result = UpdateEventArgumentsSchema.parse(validArgs);
expect(result.summary).toBe('Updated Meeting');
expect(result.description).toBe('Updated description');
expect(result.location).toBe('New Location');
expect(result.colorId).toBe('9');
});
});
describe('Modification Scope Validation', () => {
it('should leave modificationScope undefined when not provided', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles'
};
const result = UpdateEventArgumentsSchema.parse(args);
expect(result.modificationScope).toBeUndefined();
});
it('should accept valid modificationScope values', () => {
const validScopes = ['thisEventOnly', 'all', 'thisAndFollowing'] as const;
validScopes.forEach(scope => {
const args: any = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: scope
};
// Add required fields for each scope
if (scope === 'thisEventOnly') {
args.originalStartTime = '2024-06-15T10:00:00';
} else if (scope === 'thisAndFollowing') {
args.futureStartDate = getFutureDateString(90); // 90 days from now
}
const result = UpdateEventArgumentsSchema.parse(args);
expect(result.modificationScope).toBe(scope);
});
});
it('should reject invalid modificationScope values', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'invalid'
};
expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow();
});
});
describe('Single Instance Scope Validation', () => {
it('should require originalStartTime when modificationScope is "thisEventOnly"', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisEventOnly'
// missing originalStartTime
};
expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow(
/originalStartTime is required when modificationScope is 'thisEventOnly'/
);
});
it('should accept valid originalStartTime for thisEventOnly scope', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisEventOnly',
originalStartTime: '2024-06-15T10:00:00'
};
const result = UpdateEventArgumentsSchema.parse(args);
expect(result.modificationScope).toBe('thisEventOnly');
expect(result.originalStartTime).toBe('2024-06-15T10:00:00');
});
it('should reject invalid originalStartTime format', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisEventOnly',
originalStartTime: '2024-06-15 10:00:00' // invalid format
};
expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow();
});
it('should accept originalStartTime without timezone designator', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisEventOnly',
originalStartTime: '2024-06-15T10:00:00' // timezone-naive format (expected)
};
expect(() => UpdateEventArgumentsSchema.parse(args)).not.toThrow();
});
});
describe('Future Instances Scope Validation', () => {
it('should require futureStartDate when modificationScope is "thisAndFollowing"', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisAndFollowing'
// missing futureStartDate
};
expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow(
/futureStartDate is required when modificationScope is 'thisAndFollowing'/
);
});
it('should accept valid futureStartDate for thisAndFollowing scope', () => {
const futureDateString = getFutureDateString(30); // 30 days from now
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisAndFollowing',
futureStartDate: futureDateString
};
const result = UpdateEventArgumentsSchema.parse(args);
expect(result.modificationScope).toBe('thisAndFollowing');
expect(result.futureStartDate).toBe(futureDateString);
});
it('should reject futureStartDate in the past', () => {
const pastDate = new Date();
pastDate.setFullYear(pastDate.getFullYear() - 1);
// Format as ISO string without milliseconds
const pastDateString = pastDate.toISOString().split('.')[0] + 'Z';
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisAndFollowing',
futureStartDate: pastDateString
};
expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow(
/futureStartDate must be in the future/
);
});
it('should reject invalid futureStartDate format', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisAndFollowing',
futureStartDate: '2024-12-31 10:00:00' // invalid format
};
expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow();
});
});
describe('Datetime Format Validation', () => {
const validDatetimes = [
'2024-06-15T10:00:00', // timezone-naive (preferred)
'2024-12-31T23:59:59', // timezone-naive (preferred)
'2024-01-01T00:00:00', // timezone-naive (preferred)
'2024-06-15T10:00:00Z', // timezone-aware (accepted)
'2024-06-15T10:00:00-07:00', // timezone-aware (accepted)
'2024-06-15T10:00:00+05:30' // timezone-aware (accepted)
];
const invalidDatetimes = [
'2024-06-15 10:00:00', // space instead of T
'24-06-15T10:00:00', // short year
'2024-6-15T10:00:00', // single digit month
'2024-06-15T10:00' // missing seconds
];
validDatetimes.forEach(datetime => {
it(`should accept valid datetime format: ${datetime}`, () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
start: datetime,
end: datetime
};
expect(() => UpdateEventArgumentsSchema.parse(args)).not.toThrow();
});
});
invalidDatetimes.forEach(datetime => {
it(`should reject invalid datetime format: ${datetime}`, () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
start: datetime
};
expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow();
});
});
});
describe('Complex Scenarios', () => {
it('should validate complete update with all fields', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'thisAndFollowing',
futureStartDate: getFutureDateString(60), // 60 days from now
summary: 'Updated Meeting',
description: 'Updated description',
location: 'New Conference Room',
start: '2024-06-15T10:00:00',
end: '2024-06-15T11:00:00',
colorId: '9',
attendees: [
{ email: '[email protected]' },
{ email: '[email protected]' }
],
reminders: {
useDefault: false,
overrides: [
{ method: 'email', minutes: 1440 },
{ method: 'popup', minutes: 10 }
]
},
recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO']
};
const result = UpdateEventArgumentsSchema.parse(args);
expect(result).toMatchObject(args);
});
it('should not require conditional fields for "all" scope', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'all',
summary: 'Updated Meeting'
// no originalStartTime or futureStartDate required
};
expect(() => UpdateEventArgumentsSchema.parse(args)).not.toThrow();
});
it('should allow optional conditional fields when not required', () => {
const args = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
modificationScope: 'all',
originalStartTime: '2024-06-15T10:00:00', // optional for 'all' scope
summary: 'Updated Meeting'
};
const result = UpdateEventArgumentsSchema.parse(args);
expect(result.originalStartTime).toBe('2024-06-15T10:00:00');
});
});
describe('Backward Compatibility', () => {
it('should maintain compatibility with existing update calls', () => {
// Existing call format without new parameters
const legacyArgs = {
calendarId: 'primary',
eventId: 'event123',
timeZone: 'America/Los_Angeles',
summary: 'Updated Meeting',
location: 'Conference Room A'
};
const result = UpdateEventArgumentsSchema.parse(legacyArgs);
expect(result.modificationScope).toBeUndefined(); // optional with no default
expect(result.summary).toBe('Updated Meeting');
expect(result.location).toBe('Conference Room A');
});
});
});
describe('ListEventsArgumentsSchema JSON String Handling', () => {
it('should parse JSON string calendarId into array', () => {
const input = {
calendarId: '["primary", "[email protected]"]',
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-01-02T00:00:00Z'
};
const result = ListEventsArgumentsSchema.parse(input);
// The new schema keeps JSON strings as strings (they are parsed in the handler)
expect(result.calendarId).toBe('["primary", "[email protected]"]');
});
it('should handle regular string calendarId', () => {
const input = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-01-02T00:00:00Z'
};
const result = ListEventsArgumentsSchema.parse(input);
expect(result.calendarId).toBe('primary');
});
it('should handle regular array calendarId', () => {
// Arrays are now supported via preprocessing
const input = {
calendarId: ['primary', '[email protected]'],
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-01-02T00:00:00Z'
};
// Arrays are now kept as arrays (not transformed to JSON strings)
const result = ListEventsArgumentsSchema.parse(input);
expect(result.calendarId).toEqual(['primary', '[email protected]']);
});
it('should reject invalid JSON string', () => {
// Invalid JSON strings are accepted by the schema but will fail in the handler
const input = {
calendarId: '["primary", invalid]',
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-01-02T00:00:00Z'
};
// The schema accepts any string - validation happens in the handler
const result = ListEventsArgumentsSchema.parse(input);
expect(result.calendarId).toBe('["primary", invalid]');
});
it('should reject JSON string with non-string elements', () => {
// Schema accepts any string - validation happens in the handler
const input = {
calendarId: '["primary", 123]',
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-01-02T00:00:00Z'
};
// The schema accepts any string - validation happens in the handler
const result = ListEventsArgumentsSchema.parse(input);
expect(result.calendarId).toBe('["primary", 123]');
});
});
```
--------------------------------------------------------------------------------
/src/handlers/core/BaseToolHandler.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { OAuth2Client } from "google-auth-library";
import { GaxiosError } from 'gaxios';
import { calendar_v3, google } from "googleapis";
import { getCredentialsProjectId } from "../../auth/utils.js";
export abstract class BaseToolHandler {
abstract runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult>;
protected handleGoogleApiError(error: unknown): never {
if (error instanceof GaxiosError) {
const status = error.response?.status;
const errorData = error.response?.data;
// Handle specific Google API errors with appropriate MCP error codes
if (errorData?.error === 'invalid_grant') {
throw new McpError(
ErrorCode.InvalidRequest,
'Authentication token is invalid or expired. Please re-run the authentication process (e.g., `npm run auth`).'
);
}
if (status === 400) {
// Extract detailed error information for Bad Request
const errorMessage = errorData?.error?.message;
const errorDetails = errorData?.error?.errors?.map((e: any) =>
`${e.message || e.reason}${e.location ? ` (${e.location})` : ''}`
).join('; ');
// Also include raw error data for debugging if details are missing
let fullMessage: string;
if (errorDetails) {
fullMessage = `Bad Request: ${errorMessage || 'Invalid request parameters'}. Details: ${errorDetails}`;
} else if (errorMessage) {
fullMessage = `Bad Request: ${errorMessage}`;
} else {
// Include stringified error data for debugging
const errorStr = JSON.stringify(errorData, null, 2);
fullMessage = `Bad Request: Invalid request parameters. Raw error: ${errorStr}`;
}
throw new McpError(
ErrorCode.InvalidRequest,
fullMessage
);
}
if (status === 403) {
throw new McpError(
ErrorCode.InvalidRequest,
`Access denied: ${errorData?.error?.message || 'Insufficient permissions'}`
);
}
if (status === 404) {
throw new McpError(
ErrorCode.InvalidRequest,
`Resource not found: ${errorData?.error?.message || 'The requested calendar or event does not exist'}`
);
}
if (status === 429) {
const errorMessage = errorData?.error?.message || '';
// Provide specific guidance for quota-related rate limits
if (errorMessage.includes('User Rate Limit Exceeded')) {
throw new McpError(
ErrorCode.InvalidRequest,
`Rate limit exceeded. This may be due to missing quota project configuration.
Ensure your OAuth credentials include project_id information:
1. Check that your gcp-oauth.keys.json file contains project_id
2. Re-download credentials from Google Cloud Console if needed
3. The file should have format: {"installed": {"project_id": "your-project-id", ...}}
Original error: ${errorMessage}`
);
}
throw new McpError(
ErrorCode.InternalError,
`Rate limit exceeded. Please try again later. ${errorMessage}`
);
}
if (status && status >= 500) {
throw new McpError(
ErrorCode.InternalError,
`Google API server error: ${errorData?.error?.message || error.message}`
);
}
// Generic Google API error with detailed information
const errorMessage = errorData?.error?.message || error.message;
const errorDetails = errorData?.error?.errors?.map((e: any) =>
`${e.message || e.reason}${e.location ? ` (${e.location})` : ''}`
).join('; ');
const fullMessage = errorDetails
? `Google API error: ${errorMessage}. Details: ${errorDetails}`
: `Google API error: ${errorMessage}`;
throw new McpError(
ErrorCode.InvalidRequest,
fullMessage
);
}
// Handle non-Google API errors
if (error instanceof Error) {
throw new McpError(
ErrorCode.InternalError,
`Internal error: ${error.message}`
);
}
throw new McpError(
ErrorCode.InternalError,
'An unknown error occurred'
);
}
protected getCalendar(auth: OAuth2Client): calendar_v3.Calendar {
// Try to get project ID from credentials file for quota project header
const quotaProjectId = getCredentialsProjectId();
const config: any = {
version: 'v3',
auth,
timeout: 3000 // 3 second timeout for API calls
};
// Add quota project ID if available
if (quotaProjectId) {
config.quotaProjectId = quotaProjectId;
}
return google.calendar(config);
}
protected async withTimeout<T>(promise: Promise<T>, timeoutMs: number = 30000): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)), timeoutMs);
});
return Promise.race([promise, timeoutPromise]);
}
/**
* Gets calendar details including default timezone
* @param client OAuth2Client
* @param calendarId Calendar ID to fetch details for
* @returns Calendar details with timezone
*/
protected async getCalendarDetails(client: OAuth2Client, calendarId: string): Promise<calendar_v3.Schema$CalendarListEntry> {
try {
const calendar = this.getCalendar(client);
const response = await calendar.calendarList.get({ calendarId });
if (!response.data) {
throw new Error(`Calendar ${calendarId} not found`);
}
return response.data;
} catch (error) {
throw this.handleGoogleApiError(error);
}
}
/**
* Gets the default timezone for a calendar, falling back to UTC if not available
* @param client OAuth2Client
* @param calendarId Calendar ID
* @returns Timezone string (IANA format)
*/
protected async getCalendarTimezone(client: OAuth2Client, calendarId: string): Promise<string> {
try {
const calendarDetails = await this.getCalendarDetails(client, calendarId);
return calendarDetails.timeZone || 'UTC';
} catch (error) {
// If we can't get calendar details, fall back to UTC
return 'UTC';
}
}
/**
* Resolves calendar name to calendar ID. If the input is already an ID, returns it unchanged.
* Supports both exact and case-insensitive name matching.
*
* Per Google Calendar API documentation:
* - Calendar IDs are typically email addresses (e.g., "[email protected]") or "primary" keyword
* - Calendar names are stored in "summary" field (calendar title) and "summaryOverride" field (user's personal override)
*
* Matching priority (user's personal override name takes precedence):
* 1. Exact match on summaryOverride
* 2. Case-insensitive match on summaryOverride
* 3. Exact match on summary
* 4. Case-insensitive match on summary
*
* This ensures if a user has set a personal override, it's always checked first (both exact and fuzzy),
* before falling back to the calendar's actual title.
*
* @param client OAuth2Client
* @param nameOrId Calendar name (summary/summaryOverride) or ID
* @returns Calendar ID
* @throws McpError if calendar name cannot be resolved
*/
protected async resolveCalendarId(client: OAuth2Client, nameOrId: string): Promise<string> {
// If it looks like an ID (contains @ or is 'primary'), return as-is
if (nameOrId === 'primary' || nameOrId.includes('@')) {
return nameOrId;
}
// Try to resolve as a calendar name by fetching calendar list
try {
const calendar = this.getCalendar(client);
const response = await calendar.calendarList.list();
const calendars = response.data.items || [];
const lowerName = nameOrId.toLowerCase();
// Priority 1: Exact match on summaryOverride (user's personal name)
let match = calendars.find(cal => cal.summaryOverride === nameOrId);
// Priority 2: Case-insensitive match on summaryOverride
if (!match) {
match = calendars.find(cal =>
cal.summaryOverride?.toLowerCase() === lowerName
);
}
// Priority 3: Exact match on summary (calendar's actual title)
if (!match) {
match = calendars.find(cal => cal.summary === nameOrId);
}
// Priority 4: Case-insensitive match on summary
if (!match) {
match = calendars.find(cal =>
cal.summary?.toLowerCase() === lowerName
);
}
if (match && match.id) {
return match.id;
}
// Calendar name not found - provide helpful error message showing both summary and override
const availableCalendars = calendars
.map(cal => {
if (cal.summaryOverride && cal.summaryOverride !== cal.summary) {
return `"${cal.summaryOverride}" / "${cal.summary}" (${cal.id})`;
}
return `"${cal.summary}" (${cal.id})`;
})
.join(', ');
throw new McpError(
ErrorCode.InvalidRequest,
`Calendar "${nameOrId}" not found. Available calendars: ${availableCalendars || 'none'}. Use 'list-calendars' tool to see all available calendars.`
);
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw this.handleGoogleApiError(error);
}
}
/**
* Resolves multiple calendar names/IDs to calendar IDs in batch.
* Fetches calendar list once for efficiency when resolving multiple calendars.
* Optimized to skip API call if all inputs are already IDs.
*
* Matching priority (user's personal override name takes precedence):
* 1. Exact match on summaryOverride
* 2. Case-insensitive match on summaryOverride
* 3. Exact match on summary
* 4. Case-insensitive match on summary
*
* @param client OAuth2Client
* @param namesOrIds Array of calendar names (summary/summaryOverride) or IDs
* @returns Array of resolved calendar IDs
* @throws McpError if any calendar name cannot be resolved
*/
protected async resolveCalendarIds(client: OAuth2Client, namesOrIds: string[]): Promise<string[]> {
// Filter out empty/whitespace-only strings
const validInputs = namesOrIds.filter(item => item && item.trim().length > 0);
if (validInputs.length === 0) {
throw new McpError(
ErrorCode.InvalidRequest,
'At least one valid calendar identifier is required'
);
}
// Quick check: if all inputs look like IDs, skip the API call
const needsResolution = validInputs.some(item =>
item !== 'primary' && !item.includes('@')
);
if (!needsResolution) {
// All inputs are already IDs, return as-is
return validInputs;
}
// Batch resolve all calendars at once by fetching calendar list once
const calendar = this.getCalendar(client);
const response = await calendar.calendarList.list();
const calendars = response.data.items || [];
// Build name-to-ID mappings for efficient lookup
// Priority: summaryOverride takes precedence over summary
const overrideToIdMap = new Map<string, string>();
const summaryToIdMap = new Map<string, string>();
const lowerOverrideToIdMap = new Map<string, string>();
const lowerSummaryToIdMap = new Map<string, string>();
for (const cal of calendars) {
if (cal.id) {
if (cal.summaryOverride) {
overrideToIdMap.set(cal.summaryOverride, cal.id);
lowerOverrideToIdMap.set(cal.summaryOverride.toLowerCase(), cal.id);
}
if (cal.summary) {
summaryToIdMap.set(cal.summary, cal.id);
lowerSummaryToIdMap.set(cal.summary.toLowerCase(), cal.id);
}
}
}
const resolvedIds: string[] = [];
const errors: string[] = [];
for (const nameOrId of validInputs) {
// If it looks like an ID (contains @ or is 'primary'), use as-is
if (nameOrId === 'primary' || nameOrId.includes('@')) {
resolvedIds.push(nameOrId);
continue;
}
const lowerName = nameOrId.toLowerCase();
// Priority 1: Exact match on summaryOverride
let id = overrideToIdMap.get(nameOrId);
// Priority 2: Case-insensitive match on summaryOverride
if (!id) {
id = lowerOverrideToIdMap.get(lowerName);
}
// Priority 3: Exact match on summary
if (!id) {
id = summaryToIdMap.get(nameOrId);
}
// Priority 4: Case-insensitive match on summary
if (!id) {
id = lowerSummaryToIdMap.get(lowerName);
}
if (id) {
resolvedIds.push(id);
} else {
errors.push(nameOrId);
}
}
// If any calendars couldn't be resolved, throw error with helpful message
if (errors.length > 0) {
const availableCalendars = calendars
.map(cal => {
if (cal.summaryOverride && cal.summaryOverride !== cal.summary) {
return `"${cal.summaryOverride}" / "${cal.summary}" (${cal.id})`;
}
return `"${cal.summary}" (${cal.id})`;
})
.join(', ');
const errorMessage = `Calendar(s) not found: ${errors.map(e => `"${e}"`).join(', ')}. Available calendars: ${availableCalendars || 'none'}. Use 'list-calendars' tool to see all available calendars.`;
throw new McpError(
ErrorCode.InvalidRequest,
errorMessage
);
}
return resolvedIds;
}
}
```
--------------------------------------------------------------------------------
/src/tests/integration/docker-integration.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { spawn, ChildProcess, exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import { TestDataFactory } from './test-data-factory.js';
const execAsync = promisify(exec);
/**
* Docker Integration Tests for Google Calendar MCP Server
*
* REQUIREMENTS TO RUN THESE TESTS:
* 1. Docker and docker-compose installed
* 2. Valid Google OAuth credentials file (gcp-oauth.keys.json)
* 3. For full integration: Authenticated test account (npm run dev auth:test)
* 4. Environment variables: TEST_CALENDAR_ID
*
* These tests verify:
* 1. Docker containers start and stop correctly
* 2. MCP server is accessible within Docker
* 3. Calendar operations work through Docker
* 4. Both stdio and HTTP transports function
* 5. Performance and resource usage
*/
describe('Docker Integration Tests', () => {
let mcpClient: Client;
let dockerProcess: ChildProcess;
let testFactory: TestDataFactory;
let createdEventIds: string[] = [];
const TEST_CALENDAR_ID = process.env.TEST_CALENDAR_ID;
const CONTAINER_NAME = 'test-calendar-mcp-integration';
const HTTP_PORT = 3002; // Different port for test isolation
beforeAll(async () => {
console.log('🐳 Starting Docker integration tests...');
if (!TEST_CALENDAR_ID) {
throw new Error('TEST_CALENDAR_ID environment variable is required');
}
testFactory = new TestDataFactory();
// Ensure any existing test containers are cleaned up
await cleanupDockerResources();
// Build fresh test image
console.log('🔨 Building Docker test image...');
await execAsync('docker build -t google-calendar-mcp:test .', {
cwd: process.cwd(),
timeout: 60000
});
console.log('✅ Docker image built successfully');
}, 120000);
afterAll(async () => {
// Cleanup all created events
await cleanupAllCreatedEvents();
// Cleanup Docker resources
await cleanupDockerResources();
console.log('🧹 Docker integration test cleanup completed');
}, 30000);
beforeEach(() => {
createdEventIds = [];
});
afterEach(async () => {
// Cleanup events created in this test
await cleanupEvents(createdEventIds);
createdEventIds = [];
// Ensure client is closed
if (mcpClient) {
try {
await mcpClient.close();
} catch (error) {
// Ignore close errors
}
}
});
describe('Docker Container Functionality', () => {
it('should start stdio container and connect via MCP', async () => {
console.log('🔌 Testing stdio container startup...');
// Start container in stdio mode
const startTime = testFactory.startTimer('docker-stdio-startup');
await execAsync(`docker run -d --name ${CONTAINER_NAME} \
-v ${process.cwd()}/gcp-oauth.keys.json:/usr/src/app/gcp-oauth.keys.json:ro \
-v mcp-test-tokens:/home/nodejs/.config/google-calendar-mcp \
-e NODE_ENV=test \
-e TRANSPORT=stdio \
--entrypoint=/bin/sh \
google-calendar-mcp:test -c "while true; do sleep 30; done"`);
testFactory.endTimer('docker-stdio-startup', startTime, true);
// Verify container is running
const { stdout } = await execAsync(`docker ps --filter name=${CONTAINER_NAME} --format "{{.Status}}"`);
expect(stdout.trim()).toContain('Up');
// Connect to MCP server in container
mcpClient = new Client({
name: "docker-integration-client",
version: "1.0.0"
}, {
capabilities: { tools: {} }
});
const transport = new StdioClientTransport({
command: 'docker',
args: ['exec', '-i', CONTAINER_NAME, 'npm', 'start'],
env: { ...process.env, NODE_ENV: 'test' }
});
const connectStartTime = testFactory.startTimer('mcp-connection');
await mcpClient.connect(transport);
testFactory.endTimer('mcp-connection', connectStartTime, true);
// Test basic functionality
const tools = await mcpClient.listTools();
expect(tools.tools.length).toBeGreaterThan(0);
// Find expected tools
const expectedTools = ['list-calendars', 'create-event', 'list-events'];
expectedTools.forEach(toolName => {
const tool = tools.tools.find(t => t.name === toolName);
expect(tool).toBeDefined();
});
console.log(`✅ Connected to MCP server in Docker container (${tools.tools.length} tools available)`);
// Cleanup
await mcpClient.close();
await execAsync(`docker stop ${CONTAINER_NAME} && docker rm ${CONTAINER_NAME}`);
}, 60000);
it('should start HTTP container and serve endpoints', async () => {
console.log('🌐 Testing HTTP container startup...');
const startTime = testFactory.startTimer('docker-http-startup');
// Start container in HTTP mode
await execAsync(`docker run -d --name ${CONTAINER_NAME}-http \
-p ${HTTP_PORT}:3000 \
-v ${process.cwd()}/gcp-oauth.keys.json:/usr/src/app/gcp-oauth.keys.json:ro \
-v mcp-test-tokens:/home/nodejs/.config/google-calendar-mcp \
-e NODE_ENV=test \
-e TRANSPORT=http \
-e HOST=0.0.0.0 \
-e PORT=3000 \
google-calendar-mcp:test`);
// Wait for HTTP server to be ready
let serverReady = false;
for (let i = 0; i < 30; i++) {
try {
const response = await fetch(`http://localhost:${HTTP_PORT}/health`);
if (response.ok) {
serverReady = true;
break;
}
} catch (error) {
// Server not ready yet
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
testFactory.endTimer('docker-http-startup', startTime, serverReady);
expect(serverReady).toBe(true);
// Test health endpoint
const healthResponse = await fetch(`http://localhost:${HTTP_PORT}/health`);
expect(healthResponse.ok).toBe(true);
const healthData = await healthResponse.text();
expect(healthData).toBe('ok');
// Test info endpoint
const infoResponse = await fetch(`http://localhost:${HTTP_PORT}/info`);
expect(infoResponse.ok).toBe(true);
const infoData = await infoResponse.json();
expect(infoData).toHaveProperty('name');
expect(infoData).toHaveProperty('version');
console.log('✅ HTTP container serving endpoints correctly');
// Cleanup
await execAsync(`docker stop ${CONTAINER_NAME}-http && docker rm ${CONTAINER_NAME}-http`);
}, 60000);
it('should work with docker-compose', async () => {
console.log('🐳 Testing docker-compose integration...');
const startTime = testFactory.startTimer('docker-compose-test');
const composeOverridePath = `${process.cwd()}/docker-compose.override.yml`;
try {
// Test stdio mode (default)
console.log(' Testing stdio mode with docker-compose...');
await execAsync('docker compose up -d', { cwd: process.cwd() });
await new Promise(resolve => setTimeout(resolve, 5000));
const { stdout: psStdio } = await execAsync('docker compose ps', { cwd: process.cwd() });
expect(psStdio).toContain('Up');
await execAsync('docker compose down', { cwd: process.cwd() });
console.log(' ✅ docker-compose stdio mode works');
// Test HTTP mode using an override file
console.log(' Testing http mode with docker-compose...');
const composeOverride = `
services:
calendar-mcp:
ports:
- "${HTTP_PORT}:3000"
environment:
TRANSPORT: http
HOST: 0.0.0.0
PORT: 3000
`;
await fs.writeFile(composeOverridePath, composeOverride);
await execAsync('docker compose up -d', { cwd: process.cwd() });
let httpReady = false;
for (let i = 0; i < 20; i++) {
try {
const response = await fetch(`http://localhost:${HTTP_PORT}/health`);
if (response.ok) {
httpReady = true;
break;
}
} catch (error) { /* wait */ }
await new Promise(resolve => setTimeout(resolve, 1000));
}
expect(httpReady).toBe(true);
console.log(' ✅ docker-compose http mode works');
testFactory.endTimer('docker-compose-test', startTime, true);
console.log('✅ docker-compose integration working');
} finally {
// Always cleanup
await execAsync('docker compose down', { cwd: process.cwd() }).catch(() => {});
await fs.unlink(composeOverridePath).catch(() => {});
}
}, 90000);
});
describe('Calendar Operations via Docker', () => {
beforeEach(async () => {
// Start container for calendar operations
await execAsync(`docker run -d --name ${CONTAINER_NAME} \
-v ${process.cwd()}/gcp-oauth.keys.json:/usr/src/app/gcp-oauth.keys.json:ro \
-v mcp-test-tokens:/home/nodejs/.config/google-calendar-mcp \
-e NODE_ENV=test \
-e TRANSPORT=stdio \
--entrypoint=/bin/sh \
google-calendar-mcp:test -c "while true; do sleep 30; done"`);
// Connect MCP client
mcpClient = new Client({
name: "docker-calendar-client",
version: "1.0.0"
}, {
capabilities: { tools: {} }
});
const transport = new StdioClientTransport({
command: 'docker',
args: ['exec', '-i', CONTAINER_NAME, 'npm', 'start'],
env: { ...process.env, NODE_ENV: 'test' }
});
await mcpClient.connect(transport);
});
afterEach(async () => {
if (mcpClient) {
await mcpClient.close();
}
await execAsync(`docker stop ${CONTAINER_NAME} && docker rm ${CONTAINER_NAME}`).catch(() => {});
});
it('should list calendars through Docker', async () => {
console.log('📅 Testing calendar listing via Docker...');
const startTime = testFactory.startTimer('docker-list-calendars');
try {
const result = await mcpClient.callTool({
name: 'list-calendars',
arguments: {}
});
testFactory.endTimer('docker-list-calendars', startTime, true);
expect(result).toBeDefined();
expect(result.content).toBeDefined();
const calendars = result.content as any[];
expect(Array.isArray(calendars)).toBe(true);
// Should have at least primary calendar
expect(calendars.length).toBeGreaterThan(0);
console.log(`✅ Listed ${calendars.length} calendars via Docker`);
} catch (error) {
testFactory.endTimer('docker-list-calendars', startTime, false, String(error));
throw error;
}
}, 30000);
it('should create and manage events through Docker', async () => {
console.log('📝 Testing event creation via Docker...');
const eventDetails = TestDataFactory.createSingleEvent({
summary: 'Docker Integration Test Event'
});
const eventData = {
...eventDetails,
calendarId: TEST_CALENDAR_ID
};
const createStartTime = testFactory.startTimer('docker-create-event');
try {
// Create event
const createResult = await mcpClient.callTool({
name: 'create-event',
arguments: eventData
});
testFactory.endTimer('docker-create-event', createStartTime, true);
expect(createResult).toBeDefined();
expect(createResult.content).toBeDefined();
// Extract event ID for cleanup
const eventId = TestDataFactory.extractEventIdFromResponse(createResult);
expect(eventId).toBeTruthy();
if (eventId) {
createdEventIds.push(eventId);
}
console.log(`✅ Created event ${eventId} via Docker`);
// Verify event exists by listing events
const listStartTime = testFactory.startTimer('docker-list-events');
const listResult = await mcpClient.callTool({
name: 'list-events',
arguments: {
calendarId: TEST_CALENDAR_ID,
timeMin: eventData.start,
timeMax: eventData.end
}
});
testFactory.endTimer('docker-list-events', listStartTime, true);
expect(listResult.content).toBeDefined();
const events = Array.isArray(listResult.content) ? listResult.content : [listResult.content];
const createdEvent = events.find((event: any) =>
event.text && event.text.includes(eventData.summary)
);
expect(createdEvent).toBeDefined();
console.log('✅ Verified event creation through listing');
} catch (error) {
testFactory.endTimer('docker-create-event', createStartTime, false, String(error));
throw error;
}
}, 45000);
it('should handle current time requests through Docker', async () => {
console.log('🕐 Testing current time via Docker...');
const startTime = testFactory.startTimer('docker-current-time');
try {
const result = await mcpClient.callTool({
name: 'get-current-time',
arguments: {
timeZone: 'America/Los_Angeles'
}
});
testFactory.endTimer('docker-current-time', startTime, true);
expect(result).toBeDefined();
expect(result.content).toBeDefined();
console.log('✅ Current time retrieved via Docker');
} catch (error) {
testFactory.endTimer('docker-current-time', startTime, false, String(error));
throw error;
}
}, 15000);
});
describe('Performance and Resource Testing', () => {
it('should perform within acceptable resource limits', async () => {
console.log('📊 Testing Docker container performance...');
// Start container
await execAsync(`docker run -d --name ${CONTAINER_NAME} \
-v ${process.cwd()}/gcp-oauth.keys.json:/usr/src/app/gcp-oauth.keys.json:ro \
-v mcp-test-tokens:/home/nodejs/.config/google-calendar-mcp \
-e NODE_ENV=test \
-e TRANSPORT=stdio \
--entrypoint=/bin/sh \
google-calendar-mcp:test -c "while true; do sleep 30; done"`);
// Wait for container to stabilize
await new Promise(resolve => setTimeout(resolve, 5000));
// Get container stats
const { stdout } = await execAsync(`docker stats --no-stream --format "{{.MemUsage}},{{.CPUPerc}}" ${CONTAINER_NAME}`);
const [memUsage, cpuUsage] = stdout.trim().split(',');
console.log(`Memory usage: ${memUsage}`);
console.log(`CPU usage: ${cpuUsage}`);
// Parse memory usage (e.g., "45.2MiB / 512MiB")
const memoryMB = parseFloat(memUsage.split('/')[0].replace('MiB', '').trim());
expect(memoryMB).toBeLessThan(200); // Should use less than 200MB
// Parse CPU usage (e.g., "1.23%")
const cpuPercent = parseFloat(cpuUsage.replace('%', ''));
expect(cpuPercent).toBeLessThan(50); // Should use less than 50% CPU when idle
console.log('✅ Container performance within acceptable limits');
// Cleanup
await execAsync(`docker stop ${CONTAINER_NAME} && docker rm ${CONTAINER_NAME}`);
}, 30000);
it('should handle concurrent requests efficiently', async () => {
console.log('🚀 Testing concurrent request handling...');
// Start HTTP container for concurrent testing
await execAsync(`docker run -d --name ${CONTAINER_NAME}-http \
-p ${HTTP_PORT}:3000 \
-v ${process.cwd()}/gcp-oauth.keys.json:/usr/src/app/gcp-oauth.keys.json:ro \
-v mcp-test-tokens:/home/nodejs/.config/google-calendar-mcp \
-e NODE_ENV=test \
-e TRANSPORT=http \
-e HOST=0.0.0.0 \
-e PORT=3000 \
google-calendar-mcp:test`);
// Wait for server to be ready
for (let i = 0; i < 20; i++) {
try {
const response = await fetch(`http://localhost:${HTTP_PORT}/health`);
if (response.ok) break;
} catch (error) {
// Not ready yet
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Make concurrent health check requests
const concurrentRequests = 10;
const startTime = Date.now();
const requests = Array(concurrentRequests).fill(null).map(async () => {
const response = await fetch(`http://localhost:${HTTP_PORT}/health`);
return { ok: response.ok, time: Date.now() };
});
const results = await Promise.all(requests);
const totalTime = Date.now() - startTime;
// All requests should succeed
expect(results.every(r => r.ok)).toBe(true);
// Average response time should be reasonable
expect(totalTime / concurrentRequests).toBeLessThan(1000); // Less than 1 second per request on average
console.log(`✅ Handled ${concurrentRequests} concurrent requests in ${totalTime}ms`);
// Cleanup
await execAsync(`docker stop ${CONTAINER_NAME}-http && docker rm ${CONTAINER_NAME}-http`);
}, 60000);
});
// Helper Functions
async function cleanupDockerResources(): Promise<void> {
const containerNames = [
CONTAINER_NAME,
`${CONTAINER_NAME}-http`
];
for (const name of containerNames) {
try {
await execAsync(`docker stop ${name} 2>/dev/null || true`);
await execAsync(`docker rm ${name} 2>/dev/null || true`);
} catch (error) {
// Ignore cleanup errors
}
}
// Remove test volume
try {
await execAsync('docker volume rm mcp-test-tokens 2>/dev/null || true');
} catch (error) {
// Ignore cleanup errors
}
}
async function cleanupEvents(eventIds: string[]): Promise<void> {
if (!mcpClient || eventIds.length === 0) return;
for (const eventId of eventIds) {
try {
await mcpClient.callTool({
name: 'delete-event',
arguments: {
calendarId: TEST_CALENDAR_ID,
eventId,
sendUpdates: 'none'
}
});
console.log(`🗑️ Cleaned up event: ${eventId}`);
} catch (error) {
console.warn(`Failed to cleanup event ${eventId}:`, String(error));
}
}
}
async function cleanupAllCreatedEvents(): Promise<void> {
await cleanupEvents(createdEventIds);
}
});
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/CreateEventHandler.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CreateEventHandler } from '../../../handlers/core/CreateEventHandler.js';
import { OAuth2Client } from 'google-auth-library';
// Mock the googleapis module
vi.mock('googleapis', () => ({
google: {
calendar: vi.fn(() => ({
events: {
insert: vi.fn()
}
}))
},
calendar_v3: {}
}));
// Mock the event ID validator
vi.mock('../../../utils/event-id-validator.js', () => ({
validateEventId: vi.fn((eventId: string) => {
if (eventId && eventId.length < 5 || eventId.length > 1024) {
throw new Error(`Invalid event ID: length must be between 5 and 1024 characters`);
}
if (eventId && !/^[a-zA-Z0-9-]+$/.test(eventId)) {
throw new Error(`Invalid event ID: can only contain letters, numbers, and hyphens`);
}
})
}));
// Mock datetime utilities
vi.mock('../../../utils/datetime.js', () => ({
createTimeObject: vi.fn((datetime: string, timezone: string) => ({
dateTime: datetime,
timeZone: timezone
}))
}));
describe('CreateEventHandler', () => {
let handler: CreateEventHandler;
let mockOAuth2Client: OAuth2Client;
let mockCalendar: any;
beforeEach(() => {
handler = new CreateEventHandler();
mockOAuth2Client = new OAuth2Client();
// Setup mock calendar
mockCalendar = {
events: {
insert: vi.fn()
}
};
// Mock the getCalendar method
vi.spyOn(handler as any, 'getCalendar').mockReturnValue(mockCalendar);
// Mock getCalendarTimezone
vi.spyOn(handler as any, 'getCalendarTimezone').mockResolvedValue('America/Los_Angeles');
});
describe('Basic Event Creation', () => {
it('should create an event without custom ID', async () => {
const mockCreatedEvent = {
id: 'generated-id-123',
summary: 'Test Event',
start: { dateTime: '2025-01-15T10:00:00Z' },
end: { dateTime: '2025-01-15T11:00:00Z' },
htmlLink: 'https://calendar.google.com/event?eid=abc123'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
const result = await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith({
calendarId: 'primary',
requestBody: expect.objectContaining({
summary: 'Test Event',
start: { dateTime: '2025-01-15T10:00:00', timeZone: 'America/Los_Angeles' },
end: { dateTime: '2025-01-15T11:00:00', timeZone: 'America/Los_Angeles' }
})
});
// Should not include id field when no custom ID provided
expect(mockCalendar.events.insert.mock.calls[0][0].requestBody.id).toBeUndefined();
expect(result.content[0].type).toBe('text');
const response = JSON.parse(result.content[0].text);
expect(response.event).toBeDefined();
expect(response.event.id).toBe('generated-id-123');
expect(response.event.summary).toBe('Test Event');
});
it('should create event with all basic optional fields', async () => {
const mockCreatedEvent = {
id: 'full-event',
summary: 'Full Event',
description: 'Event description',
location: 'Conference Room A',
start: { dateTime: '2025-01-15T10:00:00Z' },
end: { dateTime: '2025-01-15T11:00:00Z' },
attendees: [{ email: '[email protected]' }],
colorId: '5',
reminders: { useDefault: false, overrides: [{ method: 'email', minutes: 30 }] }
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
eventId: 'full-event',
summary: 'Full Event',
description: 'Event description',
location: 'Conference Room A',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
attendees: [{ email: '[email protected]' }],
colorId: '5',
reminders: {
useDefault: false,
overrides: [{ method: 'email' as const, minutes: 30 }]
}
};
const result = await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith({
calendarId: 'primary',
requestBody: expect.objectContaining({
id: 'full-event',
summary: 'Full Event',
description: 'Event description',
location: 'Conference Room A',
attendees: [{ email: '[email protected]' }],
colorId: '5',
reminders: {
useDefault: false,
overrides: [{ method: 'email', minutes: 30 }]
}
})
});
const response = JSON.parse(result.content[0].text);
expect(response.event).toBeDefined();
expect(response.event.id).toBeDefined();
});
});
describe('Custom Event IDs', () => {
it('should create an event with custom ID', async () => {
const mockCreatedEvent = {
id: 'customevent2025',
summary: 'Test Event',
start: { dateTime: '2025-01-15T10:00:00Z' },
end: { dateTime: '2025-01-15T11:00:00Z' },
htmlLink: 'https://calendar.google.com/event?eid=abc123'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
eventId: 'customevent2025',
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
const result = await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith({
calendarId: 'primary',
requestBody: expect.objectContaining({
id: 'customevent2025',
summary: 'Test Event',
start: { dateTime: '2025-01-15T10:00:00', timeZone: 'America/Los_Angeles' },
end: { dateTime: '2025-01-15T11:00:00', timeZone: 'America/Los_Angeles' }
})
});
const response = JSON.parse(result.content[0].text);
expect(response.event).toBeDefined();
expect(response.event.id).toBeDefined();
});
it('should validate event ID before making API call', async () => {
const args = {
calendarId: 'primary',
eventId: 'abc', // Too short (< 5 chars)
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
'Invalid event ID: length must be between 5 and 1024 characters'
);
// Should not call the API if validation fails
expect(mockCalendar.events.insert).not.toHaveBeenCalled();
});
it('should handle invalid custom event ID', async () => {
const args = {
calendarId: 'primary',
eventId: 'bad id', // Contains space
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
'Invalid event ID: can only contain letters, numbers, and hyphens'
);
expect(mockCalendar.events.insert).not.toHaveBeenCalled();
});
it('should handle event ID conflict (409 error)', async () => {
const conflictError = new Error('Conflict');
(conflictError as any).code = 409;
mockCalendar.events.insert.mockRejectedValue(conflictError);
const args = {
calendarId: 'primary',
eventId: 'existing-event',
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
"Event ID 'existing-event' already exists. Please use a different ID."
);
});
it('should handle event ID conflict with response status', async () => {
const conflictError = new Error('Conflict');
(conflictError as any).response = { status: 409 };
mockCalendar.events.insert.mockRejectedValue(conflictError);
const args = {
calendarId: 'primary',
eventId: 'existing-event',
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
"Event ID 'existing-event' already exists. Please use a different ID."
);
});
});
describe('Guest Management Properties', () => {
it('should create event with transparency setting', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Focus Time',
transparency: 'transparent'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Focus Time',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
transparency: 'transparent' as const
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
transparency: 'transparent'
})
})
);
});
it('should create event with visibility settings', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Private Meeting',
visibility: 'private'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Private Meeting',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
visibility: 'private' as const
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
visibility: 'private'
})
})
);
});
it('should create event with guest permissions', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Team Meeting'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Team Meeting',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
guestsCanInviteOthers: false,
guestsCanModify: true,
guestsCanSeeOtherGuests: false,
anyoneCanAddSelf: true
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
guestsCanInviteOthers: false,
guestsCanModify: true,
guestsCanSeeOtherGuests: false,
anyoneCanAddSelf: true
})
})
);
});
it('should send update notifications when specified', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Meeting'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Meeting',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
sendUpdates: 'externalOnly' as const
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
sendUpdates: 'externalOnly'
})
);
});
});
describe('Conference Data', () => {
it('should create event with conference data', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Video Call',
conferenceData: {
entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }]
}
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Video Call',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
conferenceData: {
createRequest: {
requestId: 'unique-request-123',
conferenceSolutionKey: {
type: 'hangoutsMeet' as const
}
}
}
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
conferenceData: {
createRequest: {
requestId: 'unique-request-123',
conferenceSolutionKey: {
type: 'hangoutsMeet'
}
}
}
}),
conferenceDataVersion: 1
})
);
});
});
describe('Extended Properties', () => {
it('should create event with extended properties', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Custom Event'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Custom Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
extendedProperties: {
private: {
'appId': '12345',
'customField': 'value1'
},
shared: {
'projectId': 'proj-789',
'category': 'meeting'
}
}
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
extendedProperties: {
private: {
'appId': '12345',
'customField': 'value1'
},
shared: {
'projectId': 'proj-789',
'category': 'meeting'
}
}
})
})
);
});
});
describe('Attachments', () => {
it('should create event with attachments', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Meeting with Docs'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Meeting with Docs',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
attachments: [
{
fileUrl: 'https://docs.google.com/document/d/123',
title: 'Meeting Agenda',
mimeType: 'application/vnd.google-apps.document'
},
{
fileUrl: 'https://drive.google.com/file/d/456',
title: 'Presentation',
mimeType: 'application/vnd.google-apps.presentation',
fileId: '456'
}
]
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
attachments: [
{
fileUrl: 'https://docs.google.com/document/d/123',
title: 'Meeting Agenda',
mimeType: 'application/vnd.google-apps.document'
},
{
fileUrl: 'https://drive.google.com/file/d/456',
title: 'Presentation',
mimeType: 'application/vnd.google-apps.presentation',
fileId: '456'
}
]
}),
supportsAttachments: true
})
);
});
});
describe('Enhanced Attendees', () => {
it('should create event with detailed attendee information', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Team Sync'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Team Sync',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
attendees: [
{
email: '[email protected]',
displayName: 'Alice Smith',
optional: false,
responseStatus: 'accepted' as const
},
{
email: '[email protected]',
displayName: 'Bob Jones',
optional: true,
responseStatus: 'needsAction' as const,
comment: 'May join late',
additionalGuests: 2
}
]
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
attendees: [
{
email: '[email protected]',
displayName: 'Alice Smith',
optional: false,
responseStatus: 'accepted'
},
{
email: '[email protected]',
displayName: 'Bob Jones',
optional: true,
responseStatus: 'needsAction',
comment: 'May join late',
additionalGuests: 2
}
]
})
})
);
});
});
describe('Source Property', () => {
it('should create event with source information', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Follow-up Meeting'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
summary: 'Follow-up Meeting',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
source: {
url: 'https://example.com/meetings/123',
title: 'Original Meeting Request'
}
};
await handler.runTool(args, mockOAuth2Client);
expect(mockCalendar.events.insert).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
source: {
url: 'https://example.com/meetings/123',
title: 'Original Meeting Request'
}
})
})
);
});
});
describe('Error Handling', () => {
it('should handle API errors other than 409', async () => {
const apiError = new Error('API Error');
(apiError as any).code = 500;
mockCalendar.events.insert.mockRejectedValue(apiError);
const args = {
calendarId: 'primary',
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
// Mock handleGoogleApiError
vi.spyOn(handler as any, 'handleGoogleApiError').mockImplementation(() => {
throw new Error('Handled API Error');
});
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow('Handled API Error');
});
it('should handle missing response data', async () => {
mockCalendar.events.insert.mockResolvedValue({ data: null });
const args = {
calendarId: 'primary',
summary: 'Test Event',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00'
};
await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
'Failed to create event, no data returned'
);
});
});
describe('Combined Properties', () => {
it('should create event with multiple enhanced properties', async () => {
const mockCreatedEvent = {
id: 'event123',
summary: 'Complex Event'
};
mockCalendar.events.insert.mockResolvedValue({ data: mockCreatedEvent });
const args = {
calendarId: 'primary',
eventId: 'customcomplexevent',
summary: 'Complex Event',
description: 'An event with all features',
start: '2025-01-15T10:00:00',
end: '2025-01-15T11:00:00',
location: 'Conference Room A',
transparency: 'opaque' as const,
visibility: 'public' as const,
guestsCanInviteOthers: true,
guestsCanModify: false,
conferenceData: {
createRequest: {
requestId: 'conf-123',
conferenceSolutionKey: {
type: 'hangoutsMeet' as const
}
}
},
attendees: [
{
email: '[email protected]',
displayName: 'Team',
optional: false
}
],
extendedProperties: {
private: {
'trackingId': '789'
}
},
source: {
url: 'https://example.com/source',
title: 'Source System'
},
sendUpdates: 'all' as const
};
await handler.runTool(args, mockOAuth2Client);
const callArgs = mockCalendar.events.insert.mock.calls[0][0];
expect(callArgs.requestBody).toMatchObject({
id: 'customcomplexevent',
summary: 'Complex Event',
description: 'An event with all features',
location: 'Conference Room A',
transparency: 'opaque',
visibility: 'public',
guestsCanInviteOthers: true,
guestsCanModify: false
});
expect(callArgs.conferenceDataVersion).toBe(1);
expect(callArgs.sendUpdates).toBe('all');
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/BatchListEvents.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @jest-environment node
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OAuth2Client } from 'google-auth-library';
import { calendar_v3 } from 'googleapis';
// Import the types and schemas we're testing
import { ToolSchemas } from '../../../tools/registry.js';
// Get the schema for validation testing
const ListEventsArgumentsSchema = ToolSchemas['list-events'];
import { ListEventsHandler } from '../../../handlers/core/ListEventsHandler.js';
// Mock the BatchRequestHandler that we'll implement
class MockBatchRequestHandler {
constructor(_auth: OAuth2Client) {}
async executeBatch(_requests: any[]): Promise<any[]> {
// This will be mocked in tests
return [];
}
}
// Mock dependencies
vi.mock('google-auth-library');
vi.mock('googleapis');
interface ExtendedEvent extends calendar_v3.Schema$Event {
calendarId?: string;
}
describe('Batch List Events Functionality', () => {
let mockOAuth2Client: OAuth2Client;
let listEventsHandler: ListEventsHandler;
let mockCalendarApi: any;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create mock OAuth2Client
mockOAuth2Client = new OAuth2Client();
// Create mock calendar API
mockCalendarApi = {
events: {
list: vi.fn()
},
calendarList: {
list: vi.fn().mockResolvedValue({
data: {
items: [
{ id: 'primary', summary: 'Primary Calendar', summaryOverride: undefined },
{ id: '[email protected]', summary: 'Work Calendar', summaryOverride: undefined },
{ id: '[email protected]', summary: 'Personal Calendar', summaryOverride: undefined }
]
}
})
}
};
// Mock the getCalendar method in BaseToolHandler
listEventsHandler = new ListEventsHandler();
vi.spyOn(listEventsHandler as any, 'getCalendar').mockReturnValue(mockCalendarApi);
});
describe('Input Validation', () => {
it('should validate single calendar ID string', () => {
const input = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-12-31T23:59:59Z'
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(true);
expect(result.data?.calendarId).toBe('primary');
});
it('should validate array of calendar IDs', () => {
// Arrays must be passed as JSON strings in the new schema
const input = {
calendarId: '["primary", "[email protected]", "[email protected]"]',
timeMin: '2024-01-01T00:00:00Z'
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(true);
expect(typeof result.data?.calendarId).toBe('string');
expect(result.data?.calendarId).toBe('["primary", "[email protected]", "[email protected]"]');
});
it('should accept actual array of calendar IDs (not JSON string)', () => {
// Arrays are now directly supported and converted to JSON strings
const input = {
calendarId: ['primary', '[email protected]', '[email protected]'],
timeMin: '2024-01-01T00:00:00Z'
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(true);
// Arrays are now kept as arrays (not transformed to JSON strings)
expect(result.data?.calendarId).toEqual(['primary', '[email protected]', '[email protected]']);
});
it('should handle malformed JSON string gracefully', () => {
// Test that malformed JSON is treated as a regular string
const input = {
calendarId: '["primary", "[email protected]"', // Missing closing bracket
timeMin: '2024-01-01T00:00:00Z'
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(true);
expect(typeof result.data?.calendarId).toBe('string');
expect(result.data?.calendarId).toBe('["primary", "[email protected]"');
});
it('should reject empty calendar ID array', () => {
const input = {
calendarId: [],
timeMin: '2024-01-01T00:00:00Z'
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(false);
});
it('should reject array with too many calendar IDs (> 50)', () => {
const input = {
calendarId: Array(51).fill('cal').map((c, i) => `${c}${i}@example.com`),
timeMin: '2024-01-01T00:00:00Z'
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(false);
});
it('should reject invalid time format', () => {
const input = {
calendarId: 'primary',
timeMin: '2024-01-01' // Missing time and timezone
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(false);
});
});
describe('Single Calendar Events (Existing Functionality)', () => {
it('should handle single calendar ID as string', async () => {
// Arrange
const mockEvents: ExtendedEvent[] = [
{
id: 'event1',
summary: 'Meeting',
start: { dateTime: '2024-01-15T10:00:00Z' },
end: { dateTime: '2024-01-15T11:00:00Z' }
},
{
id: 'event2',
summary: 'Lunch',
start: { dateTime: '2024-01-15T12:00:00Z' },
end: { dateTime: '2024-01-15T13:00:00Z' },
location: 'Restaurant'
}
];
mockCalendarApi.events.list.mockResolvedValue({
data: { items: mockEvents }
});
const args = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-01-31T23:59:59Z'
};
// Act
const result = await listEventsHandler.runTool(args, mockOAuth2Client);
// Assert
expect(mockCalendarApi.events.list).toHaveBeenCalledWith({
calendarId: 'primary',
timeMin: args.timeMin,
timeMax: args.timeMax,
singleEvents: true,
orderBy: 'startTime'
});
// Should return structured JSON with events
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.events).toHaveLength(2);
expect(response.totalCount).toBe(2);
});
it('should handle empty results for single calendar', async () => {
// Arrange
mockCalendarApi.events.list.mockResolvedValue({
data: { items: [] }
});
const args = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00Z'
};
// Act
const result = await listEventsHandler.runTool(args, mockOAuth2Client);
// Assert - no events means empty array in JSON
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.events).toHaveLength(0);
expect(response.totalCount).toBe(0);
});
});
describe('Batch Request Creation', () => {
it('should create proper batch requests for multiple calendars', () => {
// This tests the batch request creation logic
const calendarIds = ['primary', '[email protected]', '[email protected]'];
const options = {
timeMin: '2024-01-01T00:00:00Z',
timeMax: '2024-01-31T23:59:59Z'
};
// Expected batch requests
const expectedRequests = calendarIds.map(calendarId => ({
method: 'GET',
path: `/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?` +
new URLSearchParams({
singleEvents: 'true',
orderBy: 'startTime',
timeMin: options.timeMin,
timeMax: options.timeMax
}).toString()
}));
// Verify the expected structure
expect(expectedRequests).toHaveLength(3);
expect(expectedRequests[0].path).toContain('calendars/primary/events');
expect(expectedRequests[1].path).toContain('calendars/work%40example.com/events');
expect(expectedRequests[2].path).toContain('calendars/personal%40example.com/events');
// All should have proper query parameters
expectedRequests.forEach(req => {
expect(req.path).toContain('singleEvents=true');
expect(req.path).toContain('orderBy=startTime');
expect(req.path).toContain('timeMin=2024-01-01T00%3A00%3A00Z');
expect(req.path).toContain('timeMax=2024-01-31T23%3A59%3A59Z');
});
});
it('should handle optional parameters in batch requests', () => {
const options = { timeMin: '2024-01-01T00:00:00Z' }; // Only timeMin, no timeMax
const expectedRequest = {
method: 'GET',
path: `/calendar/v3/calendars/primary/events?` +
new URLSearchParams({
singleEvents: 'true',
orderBy: 'startTime',
timeMin: options.timeMin
}).toString()
};
expect(expectedRequest.path).toContain('timeMin=2024-01-01T00%3A00%3A00Z');
expect(expectedRequest.path).not.toContain('timeMax');
});
});
describe('Batch Response Parsing', () => {
it('should parse successful batch responses correctly', () => {
// Mock successful batch responses
const mockBatchResponses = [
{
statusCode: 200,
headers: {},
body: {
items: [
{
id: 'work1',
summary: 'Work Meeting',
start: { dateTime: '2024-01-15T09:00:00Z' },
end: { dateTime: '2024-01-15T10:00:00Z' }
}
]
}
},
{
statusCode: 200,
headers: {},
body: {
items: [
{
id: 'personal1',
summary: 'Gym',
start: { dateTime: '2024-01-15T18:00:00Z' },
end: { dateTime: '2024-01-15T19:00:00Z' }
}
]
}
}
];
const calendarIds = ['[email protected]', '[email protected]'];
// Simulate processing batch responses
const allEvents: ExtendedEvent[] = [];
const errors: Array<{ calendarId: string; error: any }> = [];
mockBatchResponses.forEach((response, index) => {
const calendarId = calendarIds[index];
if (response.statusCode === 200 && response.body.items) {
const events = response.body.items.map((event: any) => ({
...event,
calendarId
}));
allEvents.push(...events);
} else {
errors.push({
calendarId,
error: response.body
});
}
});
// Assert results
expect(allEvents).toHaveLength(2);
expect(allEvents[0].calendarId).toBe('[email protected]');
expect(allEvents[0].summary).toBe('Work Meeting');
expect(allEvents[1].calendarId).toBe('[email protected]');
expect(allEvents[1].summary).toBe('Gym');
expect(errors).toHaveLength(0);
});
it('should handle partial failures in batch responses', () => {
// Mock mixed success/failure responses
const mockBatchResponses = [
{
statusCode: 200,
headers: {},
body: {
items: [
{
id: 'event1',
summary: 'Success Event',
start: { dateTime: '2024-01-15T09:00:00Z' },
end: { dateTime: '2024-01-15T10:00:00Z' }
}
]
}
},
{
statusCode: 404,
headers: {},
body: {
error: {
code: 404,
message: 'Calendar not found'
}
}
},
{
statusCode: 403,
headers: {},
body: {
error: {
code: 403,
message: 'Access denied'
}
}
}
];
const calendarIds = ['primary', '[email protected]', '[email protected]'];
// Simulate processing
const allEvents: ExtendedEvent[] = [];
const errors: Array<{ calendarId: string; error: any }> = [];
mockBatchResponses.forEach((response, index) => {
const calendarId = calendarIds[index];
if (response.statusCode === 200 && response.body.items) {
const events = response.body.items.map((event: any) => ({
...event,
calendarId
}));
allEvents.push(...events);
} else {
errors.push({
calendarId,
error: response.body
});
}
});
// Assert partial success
expect(allEvents).toHaveLength(1);
expect(allEvents[0].summary).toBe('Success Event');
expect(errors).toHaveLength(2);
expect(errors[0].calendarId).toBe('[email protected]');
expect(errors[1].calendarId).toBe('[email protected]');
});
it('should handle empty results from some calendars', () => {
const mockBatchResponses = [
{
statusCode: 200,
headers: {},
body: { items: [] } // Empty calendar
},
{
statusCode: 200,
headers: {},
body: {
items: [
{
id: 'event1',
summary: 'Only Event',
start: { dateTime: '2024-01-15T09:00:00Z' },
end: { dateTime: '2024-01-15T10:00:00Z' }
}
]
}
}
];
const calendarIds = ['[email protected]', '[email protected]'];
const allEvents: ExtendedEvent[] = [];
mockBatchResponses.forEach((response, index) => {
const calendarId = calendarIds[index];
if (response.statusCode === 200 && response.body.items) {
const events = response.body.items.map((event: any) => ({
...event,
calendarId
}));
allEvents.push(...events);
}
});
expect(allEvents).toHaveLength(1);
expect(allEvents[0].calendarId).toBe('[email protected]');
});
});
describe('Event Sorting and Formatting', () => {
it('should sort events by start time across multiple calendars', () => {
const events: ExtendedEvent[] = [
{
id: 'event2',
summary: 'Second Event',
start: { dateTime: '2024-01-15T14:00:00Z' },
end: { dateTime: '2024-01-15T15:00:00Z' },
calendarId: 'cal2'
},
{
id: 'event1',
summary: 'First Event',
start: { dateTime: '2024-01-15T09:00:00Z' },
end: { dateTime: '2024-01-15T10:00:00Z' },
calendarId: 'cal1'
},
{
id: 'event3',
summary: 'Third Event',
start: { dateTime: '2024-01-15T18:00:00Z' },
end: { dateTime: '2024-01-15T19:00:00Z' },
calendarId: 'cal1'
}
];
// Sort events by start time
const sortedEvents = events.sort((a, b) => {
const aStart = a.start?.dateTime || a.start?.date || '';
const bStart = b.start?.dateTime || b.start?.date || '';
return aStart.localeCompare(bStart);
});
expect(sortedEvents[0].summary).toBe('First Event');
expect(sortedEvents[1].summary).toBe('Second Event');
expect(sortedEvents[2].summary).toBe('Third Event');
});
it('should format multiple calendar events with calendar grouping', () => {
const events: ExtendedEvent[] = [
{
id: 'work1',
summary: 'Work Meeting',
start: { dateTime: '2024-01-15T09:00:00Z' },
end: { dateTime: '2024-01-15T10:00:00Z' },
calendarId: '[email protected]'
},
{
id: 'personal1',
summary: 'Gym',
start: { dateTime: '2024-01-15T18:00:00Z' },
end: { dateTime: '2024-01-15T19:00:00Z' },
calendarId: '[email protected]'
}
];
// Group events by calendar
const grouped = events.reduce((acc, event) => {
const calId = (event as any).calendarId || 'unknown';
if (!acc[calId]) acc[calId] = [];
acc[calId].push(event);
return acc;
}, {} as Record<string, ExtendedEvent[]>);
// Since we now return resources instead of formatted text,
// we just verify that events are grouped correctly
expect(grouped['[email protected]']).toHaveLength(1);
expect(grouped['[email protected]']).toHaveLength(1);
expect(grouped['[email protected]'][0].summary).toBe('Work Meeting');
expect(grouped['[email protected]'][0].summary).toBe('Gym');
});
it('should handle date-only events in sorting', () => {
const events: ExtendedEvent[] = [
{
id: 'all-day',
summary: 'All Day Event',
start: { date: '2024-01-15' },
end: { date: '2024-01-16' }
},
{
id: 'timed',
summary: 'Timed Event',
start: { dateTime: '2024-01-15T09:00:00Z' },
end: { dateTime: '2024-01-15T10:00:00Z' }
}
];
const sortedEvents = events.sort((a, b) => {
const aStart = a.start?.dateTime || a.start?.date || '';
const bStart = b.start?.dateTime || b.start?.date || '';
return aStart.localeCompare(bStart);
});
// Date-only event should come before timed event on same day
expect(sortedEvents[0].summary).toBe('All Day Event');
expect(sortedEvents[1].summary).toBe('Timed Event');
});
});
describe('Error Handling', () => {
it('should handle authentication errors', async () => {
// Mock authentication failure
const authError = new Error('Authentication required');
vi.spyOn(listEventsHandler as any, 'handleGoogleApiError').mockImplementation(() => {
throw authError;
});
mockCalendarApi.events.list.mockRejectedValue(new Error('invalid_grant'));
const args = {
calendarId: 'primary',
timeMin: '2024-01-01T00:00:00Z'
};
await expect(listEventsHandler.runTool(args, mockOAuth2Client))
.rejects.toThrow('Authentication required');
});
it('should handle rate limiting gracefully', () => {
const rateLimitResponse = {
statusCode: 429,
headers: { 'Retry-After': '60' },
body: {
error: {
code: 429,
message: 'Rate limit exceeded'
}
}
};
// This would be handled in the batch response processing
const calendarId = 'primary';
const errors: Array<{ calendarId: string; error: any }> = [];
errors.push({
calendarId,
error: rateLimitResponse.body
});
expect(errors).toHaveLength(1);
expect(errors[0].error.error.code).toBe(429);
expect(errors[0].error.error.message).toContain('Rate limit');
});
it('should handle network errors in batch requests', () => {
const networkError = {
statusCode: 0,
headers: {},
body: null,
error: new Error('Network connection failed')
};
const calendarId = 'primary';
const errors: Array<{ calendarId: string; error: any }> = [];
errors.push({
calendarId,
error: networkError.error
});
expect(errors).toHaveLength(1);
expect(errors[0].error.message).toContain('Network connection failed');
});
});
describe('Integration Scenarios', () => {
it('should handle maximum allowed calendars (50)', () => {
const maxCalendars = Array(50).fill('cal').map((c, i) => `${c}${i}@example.com`);
// Arrays must be passed as JSON strings in the new schema
const input = {
calendarId: JSON.stringify(maxCalendars),
timeMin: '2024-01-01T00:00:00Z'
};
const result = ListEventsArgumentsSchema.safeParse(input);
expect(result.success).toBe(true);
expect(typeof result.data?.calendarId).toBe('string');
// Verify the JSON string contains all 50 calendars
const parsed = JSON.parse(result.data?.calendarId as string);
expect(parsed).toHaveLength(50);
});
it('should prefer existing single calendar path for single array item', async () => {
// When array has only one item, should use existing implementation
const args = {
calendarId: ['primary'], // Array with single item
timeMin: '2024-01-01T00:00:00Z'
};
const mockEvents: ExtendedEvent[] = [
{
id: 'event1',
summary: 'Single Calendar Event',
start: { dateTime: '2024-01-15T10:00:00Z' },
end: { dateTime: '2024-01-15T11:00:00Z' }
}
];
mockCalendarApi.events.list.mockResolvedValue({
data: { items: mockEvents }
});
const result = await listEventsHandler.runTool(args, mockOAuth2Client);
// Should call regular API, not batch
expect(mockCalendarApi.events.list).toHaveBeenCalledWith({
calendarId: 'primary',
timeMin: args.timeMin,
timeMax: undefined,
singleEvents: true,
orderBy: 'startTime'
});
// Should return structured JSON with events
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const response = JSON.parse((result.content[0] as any).text);
expect(response.events).toHaveLength(1);
expect(response.totalCount).toBe(1);
expect(response.events[0].id).toBe('event1');
});
});
});
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/RecurringEventHelpers.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { calendar_v3 } from 'googleapis';
import { RecurringEventHelpers } from '../../../handlers/core/RecurringEventHelpers.js';
describe('RecurringEventHelpers', () => {
let helpers: RecurringEventHelpers;
let mockCalendar: any;
beforeEach(() => {
mockCalendar = {
events: {
get: vi.fn(),
patch: vi.fn(),
insert: vi.fn()
}
};
helpers = new RecurringEventHelpers(mockCalendar);
});
describe('detectEventType', () => {
it('should detect recurring events', async () => {
const mockEvent = {
data: {
id: 'event123',
summary: 'Weekly Meeting',
recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO']
}
};
mockCalendar.events.get.mockResolvedValue(mockEvent);
const result = await helpers.detectEventType('event123', 'primary');
expect(result).toBe('recurring');
expect(mockCalendar.events.get).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event123'
});
});
it('should detect single events', async () => {
const mockEvent = {
data: {
id: 'event123',
summary: 'One-time Meeting',
// no recurrence property
}
};
mockCalendar.events.get.mockResolvedValue(mockEvent);
const result = await helpers.detectEventType('event123', 'primary');
expect(result).toBe('single');
});
it('should detect single events with empty recurrence array', async () => {
const mockEvent = {
data: {
id: 'event123',
summary: 'One-time Meeting',
recurrence: []
}
};
mockCalendar.events.get.mockResolvedValue(mockEvent);
const result = await helpers.detectEventType('event123', 'primary');
expect(result).toBe('single');
});
it('should handle API errors', async () => {
mockCalendar.events.get.mockRejectedValue(new Error('Event not found'));
await expect(helpers.detectEventType('invalid123', 'primary'))
.rejects.toThrow('Event not found');
});
});
describe('formatInstanceId', () => {
const testCases = [
{
eventId: 'event123',
originalStartTime: '2024-06-15T10:00:00-07:00',
expected: 'event123_20240615T170000Z'
},
{
eventId: 'meeting456',
originalStartTime: '2024-12-31T23:59:59Z',
expected: 'meeting456_20241231T235959Z'
},
{
eventId: 'recurring_event',
originalStartTime: '2024-06-15T14:30:00+05:30',
expected: 'recurring_event_20240615T090000Z'
}
];
testCases.forEach(({ eventId, originalStartTime, expected }) => {
it(`should format instance ID correctly for ${originalStartTime}`, () => {
const result = helpers.formatInstanceId(eventId, originalStartTime);
expect(result).toBe(expected);
});
});
it('should handle datetime with milliseconds', () => {
const result = helpers.formatInstanceId('event123', '2024-06-15T10:00:00.000Z');
expect(result).toBe('event123_20240615T100000Z');
});
});
describe('calculateUntilDate', () => {
it('should calculate UNTIL date one day before future start date', () => {
const futureStartDate = '2024-06-20T10:00:00-07:00';
const result = helpers.calculateUntilDate(futureStartDate);
// Should be June 19th, 2024 at 10:00:00 in basic format
expect(result).toBe('20240619T170000Z');
});
it('should handle timezone conversions correctly', () => {
const futureStartDate = '2024-06-20T00:00:00Z';
const result = helpers.calculateUntilDate(futureStartDate);
// Should be June 19th, 2024 at 00:00:00 in basic format
expect(result).toBe('20240619T000000Z');
});
it('should handle different timezones', () => {
const futureStartDate = '2024-06-20T10:00:00+05:30';
const result = helpers.calculateUntilDate(futureStartDate);
// Should be June 19th, 2024 at 04:30:00 UTC in basic format
expect(result).toBe('20240619T043000Z');
});
});
describe('calculateEndTime', () => {
it('should calculate end time based on original duration', () => {
const originalEvent: calendar_v3.Schema$Event = {
start: { dateTime: '2024-06-15T10:00:00-07:00' },
end: { dateTime: '2024-06-15T11:00:00-07:00' }
};
const newStartTime = '2024-06-15T14:00:00-07:00';
const result = helpers.calculateEndTime(newStartTime, originalEvent);
// Should preserve the 1 hour duration from original event
expect(result).toBe('2024-06-15T22:00:00.000Z');
});
it('should handle different durations', () => {
const originalEvent: calendar_v3.Schema$Event = {
start: { dateTime: '2024-06-15T10:00:00Z' },
end: { dateTime: '2024-06-15T12:30:00Z' } // 2.5 hour duration
};
const newStartTime = '2024-06-16T09:00:00Z';
const result = helpers.calculateEndTime(newStartTime, originalEvent);
// Should be 2.5 hours later
expect(result).toBe('2024-06-16T11:30:00.000Z');
});
it('should handle cross-timezone calculations', () => {
const originalEvent: calendar_v3.Schema$Event = {
start: { dateTime: '2024-06-15T10:00:00-07:00' },
end: { dateTime: '2024-06-15T11:00:00-07:00' }
};
const newStartTime = '2024-06-15T10:00:00+05:30';
const result = helpers.calculateEndTime(newStartTime, originalEvent);
// Should maintain 1 hour duration
expect(result).toBe('2024-06-15T05:30:00.000Z');
});
});
describe('updateRecurrenceWithUntil', () => {
it('should add UNTIL clause to simple recurrence rule', () => {
const recurrence = ['RRULE:FREQ=WEEKLY;BYDAY=MO'];
const untilDate = '20240630T170000Z';
const result = helpers.updateRecurrenceWithUntil(recurrence, untilDate);
expect(result).toEqual(['RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240630T170000Z']);
});
it('should replace existing UNTIL clause', () => {
const recurrence = ['RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240531T170000Z'];
const untilDate = '20240630T170000Z';
const result = helpers.updateRecurrenceWithUntil(recurrence, untilDate);
expect(result).toEqual(['RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240630T170000Z']);
});
it('should replace COUNT with UNTIL', () => {
const recurrence = ['RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10'];
const untilDate = '20240630T170000Z';
const result = helpers.updateRecurrenceWithUntil(recurrence, untilDate);
expect(result).toEqual(['RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240630T170000Z']);
});
it('should handle complex recurrence rules', () => {
const recurrence = ['RRULE:FREQ=DAILY;INTERVAL=2;BYHOUR=10;BYMINUTE=0;COUNT=20'];
const untilDate = '20240630T170000Z';
const result = helpers.updateRecurrenceWithUntil(recurrence, untilDate);
expect(result).toEqual(['RRULE:FREQ=DAILY;INTERVAL=2;BYHOUR=10;BYMINUTE=0;UNTIL=20240630T170000Z']);
});
it('should throw error for empty recurrence', () => {
expect(() => helpers.updateRecurrenceWithUntil([], '20240630T170000Z'))
.toThrow('No recurrence rule found');
expect(() => helpers.updateRecurrenceWithUntil(undefined as any, '20240630T170000Z'))
.toThrow('No recurrence rule found');
});
it('should handle recurrence with EXDATE rules', () => {
const recurrence = [
'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR',
'EXDATE:20240610T100000Z',
'EXDATE:20240612T100000Z'
];
const untilDate = '20240630T170000Z';
const result = helpers.updateRecurrenceWithUntil(recurrence, untilDate);
expect(result).toEqual([
'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240630T170000Z',
'EXDATE:20240610T100000Z',
'EXDATE:20240612T100000Z'
]);
});
it('should handle EXDATE rules appearing before RRULE', () => {
const recurrence = [
'EXDATE:20240610T100000Z',
'RRULE:FREQ=WEEKLY;BYDAY=MO',
'EXDATE:20240612T100000Z'
];
const untilDate = '20240630T170000Z';
const result = helpers.updateRecurrenceWithUntil(recurrence, untilDate);
expect(result).toEqual([
'EXDATE:20240610T100000Z',
'RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240630T170000Z',
'EXDATE:20240612T100000Z'
]);
});
it('should throw error when no RRULE found', () => {
const recurrence = [
'EXDATE:20240610T100000Z',
'EXDATE:20240612T100000Z'
];
const untilDate = '20240630T170000Z';
expect(() => helpers.updateRecurrenceWithUntil(recurrence, untilDate))
.toThrow('No RRULE found in recurrence rules');
});
it('should handle complex recurrence with multiple EXDATE rules as reported in user issue', () => {
// This test case reproduces the exact scenario from the user's error
const recurrence = [
'EXDATE;TZID=America/Los_Angeles:20250702T130500',
'EXDATE;TZID=America/Los_Angeles:20250704T130500',
'EXDATE;TZID=America/Los_Angeles:20250707T130500',
'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR',
'EXDATE;TZID=America/Los_Angeles:20250709T130500',
'EXDATE;TZID=America/Los_Angeles:20250711T130500'
];
const untilDate = '20251102T210500Z';
const result = helpers.updateRecurrenceWithUntil(recurrence, untilDate);
// Should preserve all EXDATE rules and only modify the RRULE
expect(result).toEqual([
'EXDATE;TZID=America/Los_Angeles:20250702T130500',
'EXDATE;TZID=America/Los_Angeles:20250704T130500',
'EXDATE;TZID=America/Los_Angeles:20250707T130500',
'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20251102T210500Z',
'EXDATE;TZID=America/Los_Angeles:20250709T130500',
'EXDATE;TZID=America/Los_Angeles:20250711T130500'
]);
});
});
describe('cleanEventForDuplication', () => {
it('should remove system-generated fields', () => {
const originalEvent: calendar_v3.Schema$Event = {
id: 'event123',
etag: '"abc123"',
iCalUID: '[email protected]',
created: '2024-01-01T00:00:00Z',
updated: '2024-01-01T00:00:00Z',
htmlLink: 'https://calendar.google.com/event?eid=...',
hangoutLink: 'https://meet.google.com/...',
summary: 'Meeting',
description: 'Meeting description',
location: 'Conference Room',
start: { dateTime: '2024-06-15T10:00:00Z' },
end: { dateTime: '2024-06-15T11:00:00Z' }
};
const result = helpers.cleanEventForDuplication(originalEvent);
// Should remove system fields
expect(result.id).toBeUndefined();
expect(result.etag).toBeUndefined();
expect(result.iCalUID).toBeUndefined();
expect(result.created).toBeUndefined();
expect(result.updated).toBeUndefined();
expect(result.htmlLink).toBeUndefined();
expect(result.hangoutLink).toBeUndefined();
// Should preserve user fields
expect(result.summary).toBe('Meeting');
expect(result.description).toBe('Meeting description');
expect(result.location).toBe('Conference Room');
expect(result.start).toEqual({ dateTime: '2024-06-15T10:00:00Z' });
expect(result.end).toEqual({ dateTime: '2024-06-15T11:00:00Z' });
});
it('should not modify original event object', () => {
const originalEvent: calendar_v3.Schema$Event = {
id: 'event123',
summary: 'Meeting'
};
const result = helpers.cleanEventForDuplication(originalEvent);
// Original should be unchanged
expect(originalEvent.id).toBe('event123');
// Result should be cleaned
expect(result.id).toBeUndefined();
expect(result.summary).toBe('Meeting');
});
});
describe('buildUpdateRequestBody', () => {
it('should build request body with provided fields', () => {
const args = {
summary: 'Updated Meeting',
description: 'Updated description',
location: 'New Location',
colorId: '9'
// No timeZone or start/end - these should not be added
};
const result = helpers.buildUpdateRequestBody(args);
expect(result).toEqual({
summary: 'Updated Meeting',
description: 'Updated description',
location: 'New Location',
colorId: '9'
// No start/end should be present
});
});
it('should handle time changes correctly', () => {
const args = {
start: '2024-06-15T10:00:00-07:00',
end: '2024-06-15T11:00:00-07:00',
timeZone: 'America/Los_Angeles',
summary: 'Meeting'
};
const result = helpers.buildUpdateRequestBody(args);
expect(result).toEqual({
summary: 'Meeting',
start: {
dateTime: '2024-06-15T10:00:00-07:00',
date: null
// No timeZone when datetime already includes timezone
},
end: {
dateTime: '2024-06-15T11:00:00-07:00',
date: null
// No timeZone when datetime already includes timezone
}
});
});
it('should handle partial time changes', () => {
const args = {
start: '2024-06-15T10:00:00-07:00',
// no end provided
timeZone: 'America/Los_Angeles',
summary: 'Meeting'
};
const result = helpers.buildUpdateRequestBody(args);
expect(result.start).toEqual({
dateTime: '2024-06-15T10:00:00-07:00',
date: null
// No timeZone when datetime already includes timezone
});
expect(result.end).toBeUndefined();
});
it('should use default timezone when no timezone provided', () => {
const args = {
start: '2024-06-15T10:00:00',
end: '2024-06-15T11:00:00',
summary: 'Meeting'
};
const defaultTimeZone = 'Europe/London';
const result = helpers.buildUpdateRequestBody(args, defaultTimeZone);
expect(result).toEqual({
summary: 'Meeting',
start: {
dateTime: '2024-06-15T10:00:00',
timeZone: 'Europe/London',
date: null
},
end: {
dateTime: '2024-06-15T11:00:00',
timeZone: 'Europe/London',
date: null
}
});
});
it('should handle attendees and reminders', () => {
const args = {
attendees: [
{ email: '[email protected]' },
{ email: '[email protected]' }
],
reminders: {
useDefault: false,
overrides: [
{ method: 'email', minutes: 1440 },
{ method: 'popup', minutes: 10 }
]
},
timeZone: 'UTC'
};
const result = helpers.buildUpdateRequestBody(args);
expect(result.attendees).toEqual(args.attendees);
expect(result.reminders).toEqual(args.reminders);
});
it('should not include undefined fields', () => {
const args = {
summary: 'Meeting',
description: undefined,
location: null,
timeZone: 'UTC'
};
const result = helpers.buildUpdateRequestBody(args);
expect(result.summary).toBe('Meeting');
expect('description' in result).toBe(false);
expect('location' in result).toBe(false);
});
});
describe('Edge Cases and Boundary Conditions', () => {
it('should handle leap year dates correctly in formatInstanceId', () => {
const leapYearCases = [
{
eventId: 'leap123',
originalStartTime: '2024-02-29T10:00:00Z', // Leap year
expected: 'leap123_20240229T100000Z'
},
{
eventId: 'leap456',
originalStartTime: '2024-02-29T23:59:59-12:00', // Edge timezone
expected: 'leap456_20240301T115959Z'
}
];
leapYearCases.forEach(({ eventId, originalStartTime, expected }) => {
const result = helpers.formatInstanceId(eventId, originalStartTime);
expect(result).toBe(expected);
});
});
it('should handle extreme timezone offsets in formatInstanceId', () => {
const extremeTimezoneCases = [
{
eventId: 'extreme1',
originalStartTime: '2024-06-15T10:00:00+14:00', // UTC+14 (Kiribati)
expected: 'extreme1_20240614T200000Z'
},
{
eventId: 'extreme2',
originalStartTime: '2024-06-15T10:00:00-12:00', // UTC-12 (Baker Island)
expected: 'extreme2_20240615T220000Z'
}
];
extremeTimezoneCases.forEach(({ eventId, originalStartTime, expected }) => {
const result = helpers.formatInstanceId(eventId, originalStartTime);
expect(result).toBe(expected);
});
});
it('should handle calculateUntilDate with edge dates', () => {
const edgeCases = [
{
futureStartDate: '2024-01-01T00:00:00Z', // New Year
expected: '20231231T000000Z'
},
{
futureStartDate: '2024-12-31T23:59:59Z', // End of year
expected: '20241230T235959Z'
},
{
futureStartDate: '2024-03-01T00:00:00Z', // Day after leap day
expected: '20240229T000000Z'
}
];
edgeCases.forEach(({ futureStartDate, expected }) => {
const result = helpers.calculateUntilDate(futureStartDate);
expect(result).toBe(expected);
});
});
it('should handle calculateEndTime with very short and very long durations', () => {
// Very short duration (1 minute)
const shortDurationEvent: calendar_v3.Schema$Event = {
start: { dateTime: '2024-06-15T10:00:00Z' },
end: { dateTime: '2024-06-15T10:01:00Z' }
};
const shortResult = helpers.calculateEndTime('2024-06-16T15:30:00Z', shortDurationEvent);
expect(shortResult).toBe('2024-06-16T15:31:00.000Z');
// Very long duration (8 hours)
const longDurationEvent: calendar_v3.Schema$Event = {
start: { dateTime: '2024-06-15T09:00:00Z' },
end: { dateTime: '2024-06-15T17:00:00Z' }
};
const longResult = helpers.calculateEndTime('2024-06-16T10:00:00Z', longDurationEvent);
expect(longResult).toBe('2024-06-16T18:00:00.000Z');
// Multi-day duration
const multiDayEvent: calendar_v3.Schema$Event = {
start: { dateTime: '2024-06-15T10:00:00Z' },
end: { dateTime: '2024-06-17T10:00:00Z' } // 48 hours
};
const multiDayResult = helpers.calculateEndTime('2024-06-20T10:00:00Z', multiDayEvent);
expect(multiDayResult).toBe('2024-06-22T10:00:00.000Z');
});
it('should handle updateRecurrenceWithUntil with various RRULE formats', () => {
const complexRRuleCases = [
{
original: ['RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=10;BYMINUTE=30'],
untilDate: '20241215T103000Z',
expected: ['RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=10;BYMINUTE=30;UNTIL=20241215T103000Z']
},
{
original: ['RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=15;COUNT=5'],
untilDate: '20291215T103000Z',
expected: ['RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=15;UNTIL=20291215T103000Z']
},
{
original: ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;UNTIL=20241201T100000Z'],
untilDate: '20241115T100000Z',
expected: ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;UNTIL=20241115T100000Z']
}
];
complexRRuleCases.forEach(({ original, untilDate, expected }) => {
const result = helpers.updateRecurrenceWithUntil(original, untilDate);
expect(result).toEqual(expected);
});
});
it('should handle cleanEventForDuplication with all possible system fields', () => {
const eventWithAllSystemFields: calendar_v3.Schema$Event = {
id: 'event123',
etag: '"abc123"',
iCalUID: '[email protected]',
created: '2024-01-01T00:00:00Z',
updated: '2024-01-01T00:00:00Z',
htmlLink: 'https://calendar.google.com/event?eid=...',
hangoutLink: 'https://meet.google.com/...',
conferenceData: { entryPoints: [] },
creator: { email: '[email protected]' },
organizer: { email: '[email protected]' },
sequence: 1,
status: 'confirmed',
transparency: 'opaque',
visibility: 'default',
// User fields that should be preserved
summary: 'Meeting',
description: 'Meeting description',
location: 'Conference Room',
start: { dateTime: '2024-06-15T10:00:00Z' },
end: { dateTime: '2024-06-15T11:00:00Z' },
attendees: [{ email: '[email protected]' }],
recurrence: ['RRULE:FREQ=WEEKLY']
};
const result = helpers.cleanEventForDuplication(eventWithAllSystemFields);
// Should remove all system fields
expect(result.id).toBeUndefined();
expect(result.etag).toBeUndefined();
expect(result.iCalUID).toBeUndefined();
expect(result.created).toBeUndefined();
expect(result.updated).toBeUndefined();
expect(result.htmlLink).toBeUndefined();
expect(result.hangoutLink).toBeUndefined();
// Should preserve user fields
expect(result.summary).toBe('Meeting');
expect(result.description).toBe('Meeting description');
expect(result.location).toBe('Conference Room');
expect(result.attendees).toEqual([{ email: '[email protected]' }]);
expect(result.recurrence).toEqual(['RRULE:FREQ=WEEKLY']);
});
it('should handle buildUpdateRequestBody with complex nested objects', () => {
const complexArgs = {
summary: 'Complex Meeting',
attendees: [
{
email: '[email protected]',
displayName: 'User One',
responseStatus: 'accepted'
},
{
email: '[email protected]',
displayName: 'User Two',
responseStatus: 'tentative'
}
],
reminders: {
useDefault: false,
overrides: [
{ method: 'email', minutes: 1440 },
{ method: 'popup', minutes: 10 },
{ method: 'sms', minutes: 60 }
]
},
recurrence: [
'RRULE:FREQ=WEEKLY;BYDAY=MO',
'EXDATE:20240610T100000Z'
],
timeZone: 'America/Los_Angeles'
};
const result = helpers.buildUpdateRequestBody(complexArgs);
expect(result.attendees).toEqual(complexArgs.attendees);
expect(result.reminders).toEqual(complexArgs.reminders);
expect(result.recurrence).toEqual(complexArgs.recurrence);
// No start/end should be added when only timezone is provided without start/end values
expect(result.start).toBeUndefined();
expect(result.end).toBeUndefined();
});
it('should handle buildUpdateRequestBody with mixed null, undefined, and valid values', () => {
const mixedArgs = {
summary: 'Valid Summary',
description: null,
location: undefined,
colorId: '',
attendees: [],
reminders: null,
start: '2024-06-15T10:00:00Z',
end: null,
timeZone: 'UTC'
};
const result = helpers.buildUpdateRequestBody(mixedArgs);
expect(result.summary).toBe('Valid Summary');
expect('description' in result).toBe(false);
expect('location' in result).toBe(false);
expect(result.colorId).toBe(''); // Empty string should be included
expect(result.attendees).toEqual([]); // Empty array should be included
expect('reminders' in result).toBe(false);
expect(result.start).toEqual({
dateTime: '2024-06-15T10:00:00Z',
date: null
// No timeZone when datetime already includes timezone (Z suffix)
});
expect(result.end).toBeUndefined(); // end is null, so should not be set
});
});
});
```