Skip to main content
Glama
RespondToEventHandler.test.ts22.5 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RespondToEventHandler } from '../../../handlers/core/RespondToEventHandler.js'; import { OAuth2Client } from 'google-auth-library'; import { google } from 'googleapis'; // Mock the googleapis module vi.mock('googleapis', () => ({ google: { calendar: vi.fn(() => ({ events: { get: vi.fn(), patch: vi.fn() }, calendarList: { list: vi.fn(), get: vi.fn() } })) }, calendar_v3: {} })); describe('RespondToEventHandler', () => { let handler: RespondToEventHandler; const mockOAuth2Client = { getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' }) } as unknown as OAuth2Client; let mockAccounts: Map<string, OAuth2Client>; let mockCalendar: any; beforeEach(() => { handler = new RespondToEventHandler(); mockAccounts = new Map([['test', mockOAuth2Client]]); // Setup mock calendar mockCalendar = { events: { get: vi.fn(), patch: vi.fn() }, calendarList: { list: vi.fn().mockResolvedValue({ data: { items: [ { id: 'primary', summary: 'Primary Calendar', primary: true, accessRole: 'owner' } ] } }), get: vi.fn().mockResolvedValue({ data: { timeZone: 'UTC' } }) } }; vi.mocked(google.calendar).mockReturnValue(mockCalendar); }); describe('runTool', () => { it('should successfully accept an event invitation', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; const updatedEvent = { ...mockEvent, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'accepted' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: updatedEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; const result = await handler.runTool(args, mockAccounts); expect(mockCalendar.events.get).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123' }); expect(mockCalendar.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'accepted' } ] }, sendUpdates: 'none' }); expect(result.content[0].type).toBe('text'); const response = JSON.parse((result.content[0] as any).text); expect(response.responseStatus).toBe('accepted'); expect(response.sendUpdates).toBe('none'); expect(response.message).toBe('Your response has been set to "accepted"'); expect(response.event).toBeDefined(); }); it('should successfully decline an event invitation', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; const updatedEvent = { ...mockEvent, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'declined' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: updatedEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'declined' as const }; const result = await handler.runTool(args, mockAccounts); expect(mockCalendar.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { attendees: expect.arrayContaining([ expect.objectContaining({ responseStatus: 'declined', self: true }) ]) }, sendUpdates: 'none' }); const response = JSON.parse((result.content[0] as any).text); expect(response.responseStatus).toBe('declined'); expect(response.sendUpdates).toBe('none'); }); it('should successfully respond with tentative (maybe)', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; const updatedEvent = { ...mockEvent, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'tentative' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: updatedEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'tentative' as const }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse((result.content[0] as any).text); expect(response.responseStatus).toBe('tentative'); expect(response.sendUpdates).toBe('none'); expect(response.message).toBe('Your response has been set to "tentative"'); }); it('should respect custom sendUpdates parameter', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: mockEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const, sendUpdates: 'all' as const }; const result = await handler.runTool(args, mockAccounts); expect(mockCalendar.events.patch).toHaveBeenCalledWith( expect.objectContaining({ sendUpdates: 'all' }) ); // Verify response includes the sendUpdates value const response = JSON.parse((result.content[0] as any).text); expect(response.sendUpdates).toBe('all'); }); it('should throw error when user is not an attendee', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'other@example.com', responseStatus: 'accepted' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'You are not an attendee of this event. Only attendees can respond to event invitations.' ); }); it('should throw error when user is the organizer', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'self@example.com', self: true, organizer: true, responseStatus: 'accepted' }, { email: 'other@example.com', responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'You are the organizer of this event. Organizers do not respond to their own event invitations.' ); }); it('should throw error when event not found', async () => { mockCalendar.events.get.mockResolvedValue({ data: null }); const args = { calendarId: 'primary', eventId: 'nonexistent', response: 'accepted' as const }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow('Event not found'); }); it('should throw error when event has no attendees', async () => { const mockEvent = { id: 'event123', summary: 'Solo Event', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' } // No attendees }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'You are not an attendee of this event. Only attendees can respond to event invitations.' ); }); it('should handle patch failure', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: null }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'Failed to update event response' ); }); it('should handle API errors gracefully', async () => { const apiError = new Error('API Error'); (apiError as any).code = 500; mockCalendar.events.get.mockRejectedValue(apiError); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; // Mock handleGoogleApiError vi.spyOn(handler as any, 'handleGoogleApiError').mockImplementation(() => { throw new Error('Handled API Error'); }); await expect(handler.runTool(args, mockAccounts)).rejects.toThrow('Handled API Error'); }); it('should preserve other attendee properties when updating response', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'needsAction', displayName: 'My Name', optional: false, comment: 'Looking forward to it' } ] }; const updatedEvent = { ...mockEvent }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: updatedEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; await handler.runTool(args, mockAccounts); expect(mockCalendar.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { attendees: expect.arrayContaining([ expect.objectContaining({ email: 'self@example.com', self: true, responseStatus: 'accepted', displayName: 'My Name', optional: false, comment: 'Looking forward to it' }) ]) }, sendUpdates: 'none' }); }); }); describe('Comment Support', () => { it('should include comment when declining', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', start: { dateTime: '2025-01-15T10:00:00Z' }, end: { dateTime: '2025-01-15T11:00:00Z' }, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; const updatedEvent = { ...mockEvent, attendees: [ { email: 'organizer@example.com', organizer: true, responseStatus: 'accepted' }, { email: 'self@example.com', self: true, responseStatus: 'declined', comment: 'I have a conflict' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: updatedEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'declined' as const, comment: 'I have a conflict' }; const result = await handler.runTool(args, mockAccounts); expect(mockCalendar.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { attendees: expect.arrayContaining([ expect.objectContaining({ responseStatus: 'declined', comment: 'I have a conflict' }) ]) }, sendUpdates: 'none' }); const response = JSON.parse((result.content[0] as any).text); expect(response.message).toContain('I have a conflict'); }); it('should include comment when accepting', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: mockEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const, comment: 'Looking forward to it!' }; const result = await handler.runTool(args, mockAccounts); const response = JSON.parse((result.content[0] as any).text); expect(response.message).toContain('Looking forward to it!'); }); it('should not include comment field when comment is not provided', async () => { const mockEvent = { id: 'event123', summary: 'Team Meeting', attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockEvent }); mockCalendar.events.patch.mockResolvedValue({ data: mockEvent }); const args = { calendarId: 'primary', eventId: 'event123', response: 'accepted' as const }; await handler.runTool(args, mockAccounts); const patchCall = mockCalendar.events.patch.mock.calls[0][0]; const updatedAttendee = patchCall.requestBody.attendees[0]; expect(updatedAttendee.responseStatus).toBe('accepted'); expect(updatedAttendee).not.toHaveProperty('comment'); }); }); describe('Recurring Event Support', () => { it('should respond to single instance of recurring event', async () => { const mockRecurringEvent = { id: 'recurring123', summary: 'Weekly Standup', recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'], attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; // Setup mock calendar for recurring event detection mockCalendar.events.get .mockResolvedValueOnce({ data: mockRecurringEvent }) // For detectEventType .mockResolvedValueOnce({ data: mockRecurringEvent }); // For actual get mockCalendar.events.patch.mockResolvedValue({ data: mockRecurringEvent }); const args = { calendarId: 'primary', eventId: 'recurring123', response: 'accepted' as const, modificationScope: 'thisEventOnly' as const, originalStartTime: '2025-01-15T10:00:00Z' }; const result = await handler.runTool(args, mockAccounts); // Verify the instance ID was used (formatted with underscore and timestamp) expect(mockCalendar.events.patch).toHaveBeenCalled(); const patchCall = mockCalendar.events.patch.mock.calls[0][0]; expect(patchCall.eventId).toContain('recurring123_'); expect(patchCall.eventId).toContain('20250115T100000Z'); const response = JSON.parse((result.content[0] as any).text); expect(response.message).toContain('this instance only'); }); it('should respond to all instances of recurring event', async () => { const mockRecurringEvent = { id: 'recurring123', summary: 'Weekly Standup', recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'], attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockRecurringEvent }); mockCalendar.events.patch.mockResolvedValue({ data: mockRecurringEvent }); const args = { calendarId: 'primary', eventId: 'recurring123', response: 'declined' as const, modificationScope: 'all' as const }; const result = await handler.runTool(args, mockAccounts); // Verify the base event ID was used expect(mockCalendar.events.patch).toHaveBeenCalledWith( expect.objectContaining({ eventId: 'recurring123' }) ); const response = JSON.parse((result.content[0] as any).text); expect(response.message).toContain('all instances'); }); it('should throw error when originalStartTime missing for thisEventOnly', async () => { const args = { calendarId: 'primary', eventId: 'recurring123', response: 'accepted' as const, modificationScope: 'thisEventOnly' as const // Missing originalStartTime }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'originalStartTime is required' ); }); it('should throw error when using thisEventOnly on non-recurring event', async () => { const mockSingleEvent = { id: 'single123', summary: 'One-time Meeting', attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] // No recurrence property }; mockCalendar.events.get.mockResolvedValue({ data: mockSingleEvent }); const args = { calendarId: 'primary', eventId: 'single123', response: 'accepted' as const, modificationScope: 'thisEventOnly' as const, originalStartTime: '2025-01-15T10:00:00Z' }; await expect(handler.runTool(args, mockAccounts)).rejects.toThrow( 'can only be used with recurring events' ); }); it('should use base event ID when no modificationScope specified', async () => { const mockRecurringEvent = { id: 'recurring123', summary: 'Weekly Standup', recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'], attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockRecurringEvent }); mockCalendar.events.patch.mockResolvedValue({ data: mockRecurringEvent }); const args = { calendarId: 'primary', eventId: 'recurring123', response: 'tentative' as const // No modificationScope - should default to 'all' behavior }; await handler.runTool(args, mockAccounts); // Verify the base event ID was used (not instance-specific) expect(mockCalendar.events.patch).toHaveBeenCalledWith( expect.objectContaining({ eventId: 'recurring123' }) ); }); it('should extract base event ID when modificationScope is all and instance ID is provided', async () => { const mockRecurringEvent = { id: 'recurring123', summary: 'Weekly Standup', recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'], attendees: [ { email: 'self@example.com', self: true, responseStatus: 'needsAction' } ] }; mockCalendar.events.get.mockResolvedValue({ data: mockRecurringEvent }); mockCalendar.events.patch.mockResolvedValue({ data: mockRecurringEvent }); const args = { calendarId: 'primary', // Pass an instance ID (baseId_timestamp format) eventId: 'recurring123_20250115T100000Z', response: 'declined' as const, modificationScope: 'all' as const }; await handler.runTool(args, mockAccounts); // Should extract base ID and use it for the patch call expect(mockCalendar.events.patch).toHaveBeenCalledWith( expect.objectContaining({ eventId: 'recurring123' // Base ID extracted from instance ID }) ); }); }); });

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