Skip to main content
Glama

Google Calendar MCP

RecurringEventHelpers.test.ts24.1 kB
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: 'uid123@google.com', 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: 'user1@example.com' }, { email: 'user2@example.com' } ], 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: 'uid123@google.com', 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: 'creator@example.com' }, organizer: { email: 'organizer@example.com' }, 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: 'attendee@example.com' }], 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: 'attendee@example.com' }]); expect(result.recurrence).toEqual(['RRULE:FREQ=WEEKLY']); }); it('should handle buildUpdateRequestBody with complex nested objects', () => { const complexArgs = { summary: 'Complex Meeting', attendees: [ { email: 'user1@example.com', displayName: 'User One', responseStatus: 'accepted' }, { email: 'user2@example.com', 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 }); }); });

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