Skip to main content
Glama
SearchEventsHandler.test.ts17.7 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SearchEventsHandler } from '../../../handlers/core/SearchEventsHandler.js'; import { OAuth2Client } from 'google-auth-library'; import { CalendarRegistry } from '../../../services/CalendarRegistry.js'; // Mock the googleapis module vi.mock('googleapis', () => ({ google: { calendar: vi.fn(() => ({ events: { list: vi.fn() } })) }, calendar_v3: {} })); // Mock datetime utils vi.mock('../../../handlers/utils/datetime.js', () => ({ convertToRFC3339: vi.fn((datetime, timezone) => { if (!datetime) return undefined; return `${datetime}Z`; // Simplified for testing }) })); // Mock field mask builder vi.mock('../../../utils/field-mask-builder.js', () => ({ buildListFieldMask: vi.fn((fields) => { if (!fields || fields.length === 0) return undefined; return fields.join(','); }) })); describe('SearchEventsHandler', () => { let handler: SearchEventsHandler; 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 SearchEventsHandler(); mockOAuth2Client = new OAuth2Client(); mockAccounts = new Map([['test', mockOAuth2Client]]); // Setup mock calendar mockCalendar = { events: { list: vi.fn() } }; // Mock the getCalendar method vi.spyOn(handler as any, 'getCalendar').mockReturnValue(mockCalendar); // Mock getClientWithAutoSelection to return the test account vi.spyOn(handler as any, 'getClientWithAutoSelection').mockResolvedValue({ client: mockOAuth2Client, accountId: 'test', calendarId: 'primary', wasAutoSelected: true }); // Mock getCalendarTimezone vi.spyOn(handler as any, 'getCalendarTimezone').mockResolvedValue('America/Los_Angeles'); }); describe('Basic Search', () => { it('should search events with query text', async () => { const mockEvents = [ { id: 'event1', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' } }, { id: 'event2', summary: 'Team Planning', start: { dateTime: '2025-01-16T14:00:00Z' }, end: { dateTime: '2025-01-16T15:00:00Z' } } ]; mockCalendar.events.list.mockResolvedValue({ data: { items: mockEvents } }); const args = { calendarId: 'primary', query: 'Team' }; const result = await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith({ calendarId: 'primary', q: 'Team', timeMin: undefined, timeMax: undefined, singleEvents: true, orderBy: 'startTime' }); expect(result.content[0].type).toBe('text'); const response = JSON.parse(result.content[0].text); expect(response.events).toHaveLength(2); expect(response.totalCount).toBe(2); expect(response.query).toBe('Team'); expect(response.calendarId).toBe('primary'); }); it('should handle no results', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'NonexistentEvent' }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse(result.content[0].text); expect(response.events).toHaveLength(0); expect(response.totalCount).toBe(0); }); }); describe('Time Range Filtering', () => { it('should search with time range', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', timeMin: '2025-01-01T00:00:00', timeMax: '2025-01-31T23:59:59' }; const result = await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith( expect.objectContaining({ calendarId: 'primary', q: 'Meeting', timeMin: '2025-01-01T00:00:00Z', timeMax: '2025-01-31T23:59:59Z' }) ); const response = JSON.parse(result.content[0].text); expect(response.timeRange).toBeDefined(); expect(response.timeRange.start).toBe('2025-01-01T00:00:00Z'); expect(response.timeRange.end).toBe('2025-01-31T23:59:59Z'); }); it('should search with only timeMin', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', timeMin: '2025-01-01T00:00:00' }; await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith( expect.objectContaining({ timeMin: '2025-01-01T00:00:00Z', timeMax: undefined }) ); }); it('should search with only timeMax', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', timeMax: '2025-01-31T23:59:59' }; await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith( expect.objectContaining({ timeMin: undefined, timeMax: '2025-01-31T23:59:59Z' }) ); }); }); describe('Timezone Handling', () => { it('should use custom timezone when specified', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', timeMin: '2025-01-01T10:00:00', timeZone: 'Europe/London' }; await handler.runTool(args, mockAccounts); // Verify getCalendarTimezone was not called when timeZone is specified // The timezone should be used directly by convertToRFC3339 }); it('should use calendar default timezone when not specified', async () => { const spy = vi.spyOn(handler as any, 'getCalendarTimezone'); mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', timeMin: '2025-01-01T10:00:00' }; await handler.runTool(args, mockAccounts); expect(spy).toHaveBeenCalledWith(mockOAuth2Client, 'primary'); }); }); describe('Field Selection', () => { it('should request specific fields when provided', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', fields: ['summary', 'start', 'end'] }; await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith( expect.objectContaining({ fields: 'summary,start,end' }) ); }); it('should not include fields parameter when not specified', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting' }; await handler.runTool(args, mockAccounts); const callArgs = mockCalendar.events.list.mock.calls[0][0]; expect(callArgs.fields).toBeUndefined(); }); }); describe('Extended Properties', () => { it('should search with private extended properties', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', privateExtendedProperty: ['projectId=12345'] }; await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith( expect.objectContaining({ privateExtendedProperty: ['projectId=12345'] }) ); }); it('should search with shared extended properties', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', sharedExtendedProperty: ['category=team'] }; await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith( expect.objectContaining({ sharedExtendedProperty: ['category=team'] }) ); }); it('should search with both private and shared extended properties', async () => { mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', privateExtendedProperty: ['projectId=12345'], sharedExtendedProperty: ['category=team'] }; await handler.runTool(args, mockAccounts); expect(mockCalendar.events.list).toHaveBeenCalledWith( expect.objectContaining({ privateExtendedProperty: ['projectId=12345'], sharedExtendedProperty: ['category=team'] }) ); }); }); describe('Error Handling', () => { it('should handle API errors', async () => { const apiError = new Error('Bad Request'); (apiError as any).code = 400; mockCalendar.events.list.mockRejectedValue(apiError); const args = { calendarId: 'primary', query: 'Meeting' }; // Mock handleGoogleApiError to throw a specific error vi.spyOn(handler as any, 'handleGoogleApiError').mockImplementation(() => { throw new Error('Bad Request'); }); await expect(handler.runTool(args, mockAccounts)).rejects.toThrow('Bad Request'); }); it('should handle not found error', async () => { const apiError = new Error('Calendar not found'); (apiError as any).code = 404; mockCalendar.events.list.mockRejectedValue(apiError); const args = { calendarId: 'nonexistent', query: 'Meeting' }; // Mock handleGoogleApiError to throw a specific error vi.spyOn(handler as any, 'handleGoogleApiError').mockImplementation(() => { throw new Error('Calendar not found'); }); await expect(handler.runTool(args, mockAccounts)).rejects.toThrow('Calendar not found'); }); }); describe('Multi-Account Handling', () => { it('should throw error when no account has access', async () => { // Override the default mock to reject with access error vi.spyOn(handler as any, 'getClientWithAutoSelection').mockRejectedValue( new Error('No account has read access to calendar "primary"') ); const args = { calendarId: 'primary', query: 'Meeting' }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'No account has read access to calendar "primary"' ); }); it('should use specified account when provided', async () => { // Verify getClientWithAutoSelection is called with the account parameter const spy = vi.spyOn(handler as any, 'getClientWithAutoSelection').mockResolvedValue({ client: mockOAuth2Client, accountId: 'test', calendarId: 'primary', wasAutoSelected: false }); mockCalendar.events.list.mockResolvedValue({ data: { items: [] } }); const args = { calendarId: 'primary', query: 'Meeting', account: 'test' }; await handler.runTool(args, mockAccounts); // Verify the account was passed to getClientWithAutoSelection expect(spy).toHaveBeenCalledWith('test', 'primary', mockAccounts, 'read'); }); }); describe('Multi-Calendar Search', () => { let workClient: OAuth2Client; let personalClient: OAuth2Client; let multiAccounts: Map<string, OAuth2Client>; beforeEach(() => { workClient = new OAuth2Client(); personalClient = new OAuth2Client(); multiAccounts = new Map([ ['work', workClient], ['personal', personalClient] ]); // Mock getClientsForAccounts to return all accounts when array is passed vi.spyOn(handler as any, 'getClientsForAccounts').mockImplementation( (accountArg: string | string[] | undefined, accounts: Map<string, OAuth2Client>) => { if (Array.isArray(accountArg)) { const selected = new Map<string, OAuth2Client>(); for (const id of accountArg) { if (accounts.has(id)) selected.set(id, accounts.get(id)!); } return selected; } if (accountArg) { return accounts.has(accountArg) ? new Map([[accountArg, accounts.get(accountArg)!]]) : new Map(); } return accounts; } ); // Mock calendarRegistry.resolveCalendarsToAccounts vi.spyOn((handler as any).calendarRegistry, 'resolveCalendarsToAccounts').mockResolvedValue({ resolved: new Map([ ['work', ['work-calendar']], ['personal', ['personal-calendar']] ]), warnings: [] }); }); it('should search across multiple calendars and merge results', async () => { const workEvents = [ { id: 'work-1', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' } } ]; const personalEvents = [ { id: 'personal-1', summary: 'Team Lunch', start: { dateTime: '2025-01-15T12:00:00Z' }, end: { dateTime: '2025-01-15T13:00:00Z' } } ]; vi.spyOn(handler as any, 'getCalendar').mockImplementation((client: OAuth2Client) => ({ events: { list: vi.fn().mockResolvedValue({ data: { items: client === workClient ? workEvents : personalEvents } }) } })); const result = await handler.runTool({ account: ['work', 'personal'], calendarId: ['work-calendar', 'personal-calendar'], query: 'Team' }, multiAccounts); const parsed = JSON.parse(result.content[0].text); expect(parsed.totalCount).toBe(2); expect(parsed.events).toHaveLength(2); expect(parsed.calendars).toContain('work-calendar'); expect(parsed.calendars).toContain('personal-calendar'); expect(parsed.accounts).toContain('work'); expect(parsed.accounts).toContain('personal'); }); it('should sort merged results chronologically', async () => { const workEvents = [ { id: 'work-1', summary: 'Late Event', start: { dateTime: '2025-01-15T15:00:00Z' }, end: { dateTime: '2025-01-15T16:00:00Z' } } ]; const personalEvents = [ { id: 'personal-1', summary: 'Early Event', start: { dateTime: '2025-01-15T09:00:00Z' }, end: { dateTime: '2025-01-15T10:00:00Z' } } ]; vi.spyOn(handler as any, 'getCalendar').mockImplementation((client: OAuth2Client) => ({ events: { list: vi.fn().mockResolvedValue({ data: { items: client === workClient ? workEvents : personalEvents } }) } })); const result = await handler.runTool({ account: ['work', 'personal'], calendarId: ['work-calendar', 'personal-calendar'], query: 'Event' }, multiAccounts); const parsed = JSON.parse(result.content[0].text); expect(parsed.events[0].summary).toBe('Early Event'); expect(parsed.events[1].summary).toBe('Late Event'); }); it('should include warnings for partial failures in multi-calendar search', async () => { const personalEvents = [ { id: 'personal-1', summary: 'Team Event', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' } } ]; vi.spyOn(handler as any, 'getCalendar').mockImplementation((client: OAuth2Client) => ({ events: { list: vi.fn().mockImplementation(() => { if (client === workClient) { throw new Error('Access denied'); } return { data: { items: personalEvents } }; }) } })); const result = await handler.runTool({ account: ['work', 'personal'], calendarId: ['work-calendar', 'personal-calendar'], query: 'Team' }, multiAccounts); const parsed = JSON.parse(result.content[0].text); expect(parsed.totalCount).toBe(1); expect(parsed.warnings).toBeDefined(); expect(parsed.warnings.length).toBeGreaterThan(0); expect(parsed.warnings[0]).toContain('Failed to search calendar'); }); it('should throw error when no calendars can be resolved', async () => { vi.spyOn((handler as any).calendarRegistry, 'resolveCalendarsToAccounts').mockResolvedValue({ resolved: new Map(), warnings: ['Calendar "missing" not found'] }); vi.spyOn((handler as any).calendarRegistry, 'getUnifiedCalendars').mockResolvedValue([ { displayName: 'Work Calendar', calendarId: 'work-calendar' } ]); await expect(handler.runTool({ account: ['work', 'personal'], calendarId: ['missing-calendar'], query: 'Team' }, multiAccounts)).rejects.toThrow('None of the requested calendars could be found'); }); }); });

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