Skip to main content
Glama
FreeBusyEventHandler.test.ts17.3 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { FreeBusyEventHandler } from '../../../handlers/core/FreeBusyEventHandler.js'; import { OAuth2Client } from 'google-auth-library'; import { CalendarRegistry } from '../../../services/CalendarRegistry.js'; // Mock the googleapis module vi.mock('googleapis', () => ({ google: { calendar: vi.fn(() => ({ freebusy: { query: vi.fn() } })) }, calendar_v3: {} })); // Mock datetime utils vi.mock('../../../handlers/utils/datetime.js', () => ({ convertToRFC3339: vi.fn((datetime, timezone) => { // Simplified for testing - just append Z return `${datetime}Z`; }) })); describe('FreeBusyEventHandler', () => { let handler: FreeBusyEventHandler; let mockOAuth2Client: OAuth2Client; let mockAccounts: Map<string, OAuth2Client>; let mockCalendar: any; beforeEach(() => { // Reset the singleton to get a fresh instance for each test CalendarRegistry.resetInstance(); handler = new FreeBusyEventHandler(); mockOAuth2Client = new OAuth2Client(); mockAccounts = new Map([['test', mockOAuth2Client]]); // Setup mock calendar mockCalendar = { freebusy: { query: 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 FreeBusy Query', () => { it('should query freebusy for a single calendar', async () => { const mockResponse = { calendars: { 'primary': { busy: [ { start: '2025-01-15T10:00:00Z', end: '2025-01-15T11:00:00Z' }, { start: '2025-01-15T14:00:00Z', end: '2025-01-15T15:00:00Z' } ] } } }; mockCalendar.freebusy.query.mockResolvedValue({ data: mockResponse }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; const result = await handler.runTool(args, mockAccounts); expect(mockCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: { timeMin: '2025-01-15T00:00:00Z', timeMax: '2025-01-15T23:59:59Z', items: [{ id: 'primary' }], timeZone: 'America/Los_Angeles' } }); expect(result.content[0].type).toBe('text'); const response = JSON.parse(result.content[0].text); expect(response.calendars.primary.busy).toHaveLength(2); expect(response.timeMin).toBe('2025-01-15T00:00:00'); expect(response.timeMax).toBe('2025-01-15T23:59:59'); }); it('should query freebusy for multiple calendars', async () => { const mockResponse = { calendars: { 'calendar1@group.calendar.google.com': { busy: [{ start: '2025-01-15T10:00:00Z', end: '2025-01-15T11:00:00Z' }] }, 'calendar2@group.calendar.google.com': { busy: [{ start: '2025-01-15T14:00:00Z', end: '2025-01-15T15:00:00Z' }] } } }; mockCalendar.freebusy.query.mockResolvedValue({ data: mockResponse }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [ { id: 'calendar1@group.calendar.google.com' }, { id: 'calendar2@group.calendar.google.com' } ] }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse(result.content[0].text); expect(Object.keys(response.calendars)).toHaveLength(2); expect(response.calendars['calendar1@group.calendar.google.com'].busy).toHaveLength(1); expect(response.calendars['calendar2@group.calendar.google.com'].busy).toHaveLength(1); }); it('should handle calendars with no busy periods', async () => { const mockResponse = { calendars: { 'primary': { busy: [] } } }; mockCalendar.freebusy.query.mockResolvedValue({ data: mockResponse }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse(result.content[0].text); expect(response.calendars.primary.busy).toHaveLength(0); }); }); describe('Time Range Validation', () => { it('should reject time ranges longer than 3 months', async () => { const args = { timeMin: '2025-01-01T00:00:00', timeMax: '2025-05-01T00:00:00', // 4 months calendars: [{ id: 'primary' }] }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'The time gap between timeMin and timeMax must be less than 3 months' ); }); it('should accept time ranges exactly at 3 months', async () => { mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-01T00:00:00', timeMax: '2025-03-31T00:00:00', // ~90 days calendars: [{ id: 'primary' }] }; await expect(handler.runTool(args, mockAccounts)).resolves.toBeDefined(); }); it('should accept time ranges less than 3 months', async () => { mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-01T00:00:00', timeMax: '2025-01-15T00:00:00', // 2 weeks calendars: [{ id: 'primary' }] }; await expect(handler.runTool(args, mockAccounts)).resolves.toBeDefined(); }); }); describe('Timezone Handling', () => { it('should use custom timezone when specified', async () => { mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }], timeZone: 'Europe/London' }; await handler.runTool(args, mockAccounts); expect(mockCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ timeZone: 'Europe/London' }) }); }); it('should use calendar default timezone when not specified', async () => { const spy = vi.spyOn(handler as any, 'getCalendarTimezone'); mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; await handler.runTool(args, mockAccounts); expect(spy).toHaveBeenCalledWith(mockOAuth2Client, 'primary'); expect(mockCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ timeZone: 'America/Los_Angeles' }) }); }); it('should fallback to UTC if calendar timezone fails', async () => { vi.spyOn(handler as any, 'getCalendarTimezone').mockRejectedValue(new Error('Failed')); mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; await handler.runTool(args, mockAccounts); expect(mockCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ timeZone: 'UTC' }) }); }); }); describe('Expansion Parameters', () => { it('should include groupExpansionMax when provided', async () => { mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }], groupExpansionMax: 10 }; await handler.runTool(args, mockAccounts); expect(mockCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ groupExpansionMax: 10 }) }); }); it('should include calendarExpansionMax when provided', async () => { mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }], calendarExpansionMax: 50 }; await handler.runTool(args, mockAccounts); expect(mockCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ calendarExpansionMax: 50 }) }); }); it('should include both expansion parameters', async () => { mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }], groupExpansionMax: 10, calendarExpansionMax: 50 }; await handler.runTool(args, mockAccounts); expect(mockCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ groupExpansionMax: 10, calendarExpansionMax: 50 }) }); }); }); describe('Error Handling', () => { it('should handle calendar errors in response', async () => { const mockResponse = { calendars: { 'invalid@calendar.com': { errors: [ { domain: 'global', reason: 'notFound' } ], busy: [] } } }; mockCalendar.freebusy.query.mockResolvedValue({ data: mockResponse }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'invalid@calendar.com' }] }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse(result.content[0].text); expect(response.calendars['invalid@calendar.com'].errors).toBeDefined(); expect(response.calendars['invalid@calendar.com'].errors[0].reason).toBe('notFound'); }); it('should handle API errors by returning error in response', async () => { // With multi-account support, API errors are caught and logged per-account // If all accounts fail, the calendar will show as notFound in the response const apiError = new Error('Bad Request'); (apiError as any).code = 400; mockCalendar.freebusy.query.mockRejectedValue(apiError); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; // The handler now catches errors per-account and returns notFound for calendars // that couldn't be queried from any account const result = await handler.runTool(args, mockAccounts); const response = JSON.parse(result.content[0].text); // Calendar should show as notFound since the query failed expect(response.calendars['primary'].errors).toBeDefined(); expect(response.calendars['primary'].errors[0].reason).toBe('notFound'); }); }); describe('Multi-Account Handling', () => { it('should use first account when no account specified', async () => { mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: {} } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; await handler.runTool(args, mockAccounts); // Should use first account from mockAccounts expect(mockCalendar.freebusy.query).toHaveBeenCalled(); }); it('should use specified account when provided', async () => { const spy = vi.spyOn(handler as any, 'getClientsForAccounts'); mockCalendar.freebusy.query.mockResolvedValue({ data: { calendars: { 'primary': { busy: [] } } } }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }], account: 'test' }; await handler.runTool(args, mockAccounts); // The handler now uses getClientsForAccounts which accepts account parameter expect(spy).toHaveBeenCalledWith('test', mockAccounts); }); it('should route calendars to correct accounts in multi-account queries', async () => { // Setup two accounts const workClient = new OAuth2Client(); const personalClient = new OAuth2Client(); const multiAccounts = new Map([ ['work', workClient], ['personal', personalClient] ]); // Setup different calendar responses for each client const workCalendar = { freebusy: { query: vi.fn().mockResolvedValue({ data: { calendars: { 'work@example.com': { busy: [{ start: '2025-01-15T09:00:00Z', end: '2025-01-15T10:00:00Z' }] } } } }) } }; const personalCalendar = { freebusy: { query: vi.fn().mockResolvedValue({ data: { calendars: { 'family@group.calendar.google.com': { busy: [{ start: '2025-01-15T14:00:00Z', end: '2025-01-15T15:00:00Z' }] } } } }) } }; vi.spyOn(handler as any, 'getCalendar').mockImplementation((client: OAuth2Client) => { if (client === workClient) return workCalendar; return personalCalendar; }); // Mock CalendarRegistry to route calendars to different accounts vi.spyOn((handler as any).calendarRegistry, 'resolveCalendarsToAccounts').mockResolvedValue({ resolved: new Map([ ['work', ['work@example.com']], ['personal', ['family@group.calendar.google.com']] ]), warnings: [] }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [ { id: 'work@example.com' }, { id: 'family@group.calendar.google.com' } ], account: ['work', 'personal'] }; const result = await handler.runTool(args, multiAccounts); const response = JSON.parse(result.content[0].text); // Work calendar should only be queried for work@example.com expect(workCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ items: [{ id: 'work@example.com' }] }) }); // Personal calendar should only be queried for family calendar expect(personalCalendar.freebusy.query).toHaveBeenCalledWith({ requestBody: expect.objectContaining({ items: [{ id: 'family@group.calendar.google.com' }] }) }); // Both calendars should have results expect(response.calendars['work@example.com'].busy).toHaveLength(1); expect(response.calendars['family@group.calendar.google.com'].busy).toHaveLength(1); }); }); describe('Response Formatting', () => { it('should format response with all required fields', async () => { const mockResponse = { calendars: { 'primary': { busy: [ { start: '2025-01-15T10:00:00Z', end: '2025-01-15T11:00:00Z' } ] } } }; mockCalendar.freebusy.query.mockResolvedValue({ data: mockResponse }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse(result.content[0].text); expect(response).toHaveProperty('timeMin'); expect(response).toHaveProperty('timeMax'); expect(response).toHaveProperty('calendars'); expect(response.calendars.primary).toHaveProperty('busy'); }); it('should format busy periods correctly', async () => { const mockResponse = { calendars: { 'primary': { busy: [ { start: '2025-01-15T10:00:00Z', end: '2025-01-15T11:00:00Z' }, { start: '2025-01-15T14:00:00Z', end: '2025-01-15T15:00:00Z' } ] } } }; mockCalendar.freebusy.query.mockResolvedValue({ data: mockResponse }); const args = { timeMin: '2025-01-15T00:00:00', timeMax: '2025-01-15T23:59:59', calendars: [{ id: 'primary' }] }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse(result.content[0].text); const busyPeriods = response.calendars.primary.busy; expect(busyPeriods[0]).toHaveProperty('start'); expect(busyPeriods[0]).toHaveProperty('end'); expect(busyPeriods[0].start).toBe('2025-01-15T10:00:00Z'); expect(busyPeriods[0].end).toBe('2025-01-15T11:00:00Z'); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nspady/google-calendar-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server