import { DateTime } from 'luxon'; import { mockDeep } from 'jest-mock-extended'; import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; import type { RecurringEventInstance } from '../EventInterface'; import { addNextOccurrence, addTimezoneToDate, dateObjectToISO, encodeURIComponentOnce, eventExtendYearIntoFuture, getCalendars, getTimezones, googleApiRequest, googleApiRequestAllItems, googleApiRequestWithRetries, } from '../GenericFunctions'; describe('addTimezoneToDate', () => { it('should add timezone to date', () => { const dateWithTimezone = '2021-09-01T12:00:00.000Z'; const result1 = addTimezoneToDate(dateWithTimezone, 'Europe/Prague'); expect(result1).toBe('2021-09-01T12:00:00.000Z'); const dateWithoutTimezone = '2021-09-01T12:00:00'; const result2 = addTimezoneToDate(dateWithoutTimezone, 'Europe/Prague'); expect(result2).toBe('2021-09-01T10:00:00Z'); const result3 = addTimezoneToDate(dateWithoutTimezone, 'Asia/Tokyo'); expect(result3).toBe('2021-09-01T03:00:00Z'); const dateWithDifferentTimezone = '2021-09-01T12:00:00.000+08:00'; const result4 = addTimezoneToDate(dateWithDifferentTimezone, 'Europe/Prague'); expect(result4).toBe('2021-09-01T12:00:00.000+08:00'); }); }); describe('dateObjectToISO', () => { test('should return ISO string for DateTime instance', () => { const mockDateTime = DateTime.fromISO('2025-01-07T12:00:00'); const result = dateObjectToISO(mockDateTime); expect(result).toBe('2025-01-07T12:00:00.000+00:00'); }); test('should return ISO string for Date instance', () => { const mockDate = new Date('2025-01-07T12:00:00Z'); const result = dateObjectToISO(mockDate); expect(result).toBe('2025-01-07T12:00:00.000Z'); }); test('should return string when input is not a DateTime or Date instance', () => { const inputString = '2025-01-07T12:00:00'; const result = dateObjectToISO(inputString); expect(result).toBe(inputString); }); }); describe('eventExtendYearIntoFuture', () => { const timezone = 'UTC'; it('should return true if any event extends into the next year', () => { const events = [ { recurringEventId: '123', start: { dateTime: '2026-01-01T00:00:00Z', date: null }, }, ] as unknown as RecurringEventInstance[]; const result = eventExtendYearIntoFuture(events, timezone, 2025); expect(result).toBe(true); }); it('should return false if no event extends into the next year', () => { const events = [ { recurringEventId: '123', start: { dateTime: '2025-12-31T23:59:59Z', date: null }, }, ] as unknown as RecurringEventInstance[]; const result = eventExtendYearIntoFuture(events, timezone, 2025); expect(result).toBe(false); }); it('should return false for invalid event start dates', () => { const events = [ { recurringEventId: '123', start: { dateTime: 'invalid-date', date: null }, }, ] as unknown as RecurringEventInstance[]; const result = eventExtendYearIntoFuture(events, timezone, 2025); expect(result).toBe(false); }); it('should return false for events without a recurringEventId', () => { const events = [ { recurringEventId: null, start: { dateTime: '2025-01-01T00:00:00Z', date: null }, }, ] as unknown as RecurringEventInstance[]; const result = eventExtendYearIntoFuture(events, timezone, 2025); expect(result).toBe(false); }); it('should handle events with only a date and no time', () => { const events = [ { recurringEventId: '123', start: { dateTime: null, date: '2026-01-01' }, }, ] as unknown as RecurringEventInstance[]; const result = eventExtendYearIntoFuture(events, timezone, 2025); expect(result).toBe(true); }); }); describe('googleApiRequest', () => { let mockExecuteFunctions: jest.Mocked; let mockNode: INode; let requestOAuth2Spy: jest.SpyInstance; beforeEach(() => { mockExecuteFunctions = mockDeep(); mockNode = { id: 'test-node', name: 'Test Google Calendar Node', type: 'n8n-nodes-base.googleCalendar', typeVersion: 1, position: [0, 0], parameters: {}, }; mockExecuteFunctions.getNode.mockReturnValue(mockNode); requestOAuth2Spy = jest.spyOn(mockExecuteFunctions.helpers, 'requestOAuth2'); jest.clearAllMocks(); }); it('should make a successful GET request with default parameters', async () => { const mockResponse = { id: 'test-calendar', summary: 'Test Calendar' }; requestOAuth2Spy.mockResolvedValue(mockResponse); const result = await googleApiRequest.call( mockExecuteFunctions, 'GET', '/calendar/v3/users/me/calendarList', ); expect(requestOAuth2Spy).toHaveBeenCalledWith('googleCalendarOAuth2Api', { headers: { 'Content-Type': 'application/json', }, method: 'GET', qs: {}, uri: 'https://www.googleapis.com/calendar/v3/users/me/calendarList', json: true, }); expect(result).toEqual(mockResponse); }); it('should make a POST request with body data', async () => { const requestBody = { summary: 'New Calendar' }; const mockResponse = { id: 'new-calendar-id', summary: 'New Calendar' }; requestOAuth2Spy.mockResolvedValue(mockResponse); const result = await googleApiRequest.call( mockExecuteFunctions, 'POST', '/calendar/v3/calendars', requestBody, ); expect(requestOAuth2Spy).toHaveBeenCalledWith('googleCalendarOAuth2Api', { headers: { 'Content-Type': 'application/json', }, method: 'POST', body: requestBody, qs: {}, uri: 'https://www.googleapis.com/calendar/v3/calendars', json: true, }); expect(result).toEqual(mockResponse); }); it('should include query string parameters', async () => { const mockResponse = { items: [] }; const queryParams = { maxResults: 10, orderBy: 'startTime' }; requestOAuth2Spy.mockResolvedValue(mockResponse); await googleApiRequest.call( mockExecuteFunctions, 'GET', '/calendar/v3/calendars/primary/events', {}, queryParams, ); expect(requestOAuth2Spy).toHaveBeenCalledWith( 'googleCalendarOAuth2Api', expect.objectContaining({ qs: queryParams, }), ); }); it('should merge custom headers with default headers', async () => { const mockResponse = { success: true }; const customHeaders = { 'X-Custom-Header': 'test-value', Authorization: 'Bearer test' }; requestOAuth2Spy.mockResolvedValue(mockResponse); await googleApiRequest.call( mockExecuteFunctions, 'PUT', '/calendar/v3/calendars/test', { summary: 'Updated' }, {}, undefined, customHeaders, ); expect(requestOAuth2Spy).toHaveBeenCalledWith( 'googleCalendarOAuth2Api', expect.objectContaining({ headers: { 'Content-Type': 'application/json', 'X-Custom-Header': 'test-value', Authorization: 'Bearer test', }, }), ); }); it('should use custom URI when provided', async () => { const mockResponse = { success: true }; const customUri = 'https://custom.googleapis.com/v1/test'; requestOAuth2Spy.mockResolvedValue(mockResponse); await googleApiRequest.call( mockExecuteFunctions, 'GET', '/calendar/v3/test', {}, {}, customUri, ); expect(requestOAuth2Spy).toHaveBeenCalledWith( 'googleCalendarOAuth2Api', expect.objectContaining({ uri: customUri, }), ); }); it('should remove empty body from request', async () => { const mockResponse = { items: [] }; requestOAuth2Spy.mockResolvedValue(mockResponse); await googleApiRequest.call(mockExecuteFunctions, 'GET', '/calendar/v3/calendars', {}); const callOptions = requestOAuth2Spy.mock.calls[0][1]; expect(callOptions.body).toBeUndefined(); }); it('should throw NodeApiError when request fails', async () => { const originalError = new Error('API request failed'); requestOAuth2Spy.mockRejectedValue(originalError); await expect( googleApiRequest.call(mockExecuteFunctions, 'GET', '/calendar/v3/calendars'), ).rejects.toThrow(NodeApiError); }); }); describe('googleApiRequestAllItems', () => { let mockExecuteFunctions: jest.Mocked; let requestOAuth2Spy: jest.SpyInstance; beforeEach(() => { mockExecuteFunctions = mockDeep(); mockExecuteFunctions.getNode.mockReturnValue({ id: 'test-node', name: 'Test Node', type: 'n8n-nodes-base.googleCalendar', typeVersion: 1, position: [0, 0], parameters: {}, }); requestOAuth2Spy = jest.spyOn(mockExecuteFunctions.helpers, 'requestOAuth2'); jest.clearAllMocks(); }); it('should fetch all items across multiple pages', async () => { const mockPage1 = { items: [ { id: '1', summary: 'Calendar 1' }, { id: '2', summary: 'Calendar 2' }, ], nextPageToken: 'token123', }; const mockPage2 = { items: [{ id: '3', summary: 'Calendar 3' }], nextPageToken: '', }; requestOAuth2Spy.mockResolvedValueOnce(mockPage1).mockResolvedValueOnce(mockPage2); const result = await googleApiRequestAllItems.call( mockExecuteFunctions, 'items', 'GET', '/calendar/v3/users/me/calendarList', ); expect(result).toEqual([ { id: '1', summary: 'Calendar 1' }, { id: '2', summary: 'Calendar 2' }, { id: '3', summary: 'Calendar 3' }, ]); expect(requestOAuth2Spy).toHaveBeenCalledTimes(2); expect(requestOAuth2Spy).toHaveBeenNthCalledWith( 1, 'googleCalendarOAuth2Api', expect.objectContaining({ qs: expect.objectContaining({ maxResults: 100 }), }), ); }); it('should handle single page response', async () => { const mockResponse = { items: [{ id: '1', summary: 'Calendar 1' }], }; requestOAuth2Spy.mockResolvedValue(mockResponse); const result = await googleApiRequestAllItems.call( mockExecuteFunctions, 'items', 'GET', '/calendar/v3/users/me/calendarList', ); expect(result).toEqual([{ id: '1', summary: 'Calendar 1' }]); expect(requestOAuth2Spy).toHaveBeenCalledTimes(1); }); it('should handle empty response', async () => { const mockResponse = { items: [], }; requestOAuth2Spy.mockResolvedValue(mockResponse); const result = await googleApiRequestAllItems.call( mockExecuteFunctions, 'items', 'GET', '/calendar/v3/users/me/calendarList', ); expect(result).toEqual([]); }); it('should pass through body and query parameters', async () => { const mockResponse = { items: [] }; const body = { timeMin: '2023-01-01T00:00:00Z' }; const query = { singleEvents: true }; requestOAuth2Spy.mockResolvedValue(mockResponse); await googleApiRequestAllItems.call( mockExecuteFunctions, 'items', 'GET', '/calendar/v3/calendars/primary/events', body, query, ); expect(requestOAuth2Spy).toHaveBeenCalledWith( 'googleCalendarOAuth2Api', expect.objectContaining({ body, qs: { ...query, maxResults: 100 }, }), ); }); }); describe('encodeURIComponentOnce', () => { it('should encode unencoded URI', () => { const uri = 'test calendar@example.com'; const result = encodeURIComponentOnce(uri); expect(result).toBe('test%20calendar%40example.com'); }); it('should not double-encode already encoded URI', () => { const uri = 'test%20calendar%40example.com'; const result = encodeURIComponentOnce(uri); expect(result).toBe('test%20calendar%40example.com'); }); it('should handle mixed encoded/unencoded URI', () => { const uri = 'test%20calendar@example.com'; const result = encodeURIComponentOnce(uri); expect(result).toBe('test%20calendar%40example.com'); }); it('should handle special characters', () => { const uri = 'test+calendar¶m=value'; const result = encodeURIComponentOnce(uri); expect(result).toBe('test%2Bcalendar%26param%3Dvalue'); }); it('should handle empty string', () => { const uri = ''; const result = encodeURIComponentOnce(uri); expect(result).toBe(''); }); }); describe('getCalendars', () => { let mockLoadOptionsFunctions: jest.Mocked; let requestOAuth2Spy: jest.SpyInstance; beforeEach(() => { mockLoadOptionsFunctions = mockDeep(); mockLoadOptionsFunctions.getNode.mockReturnValue({ id: 'test-node', name: 'Test Node', type: 'n8n-nodes-base.googleCalendar', typeVersion: 1, position: [0, 0], parameters: {}, }); requestOAuth2Spy = jest.spyOn(mockLoadOptionsFunctions.helpers, 'requestOAuth2'); jest.clearAllMocks(); }); it('should return all calendars without filter', async () => { const mockCalendars = [ { id: 'cal1', summary: 'Personal Calendar' }, { id: 'cal2', summary: 'Work Calendar' }, { id: 'cal3', summary: 'Project Calendar' }, ]; requestOAuth2Spy .mockResolvedValueOnce({ items: mockCalendars.slice(0, 2), nextPageToken: 'token' }) .mockResolvedValueOnce({ items: mockCalendars.slice(2) }); const result = await getCalendars.call(mockLoadOptionsFunctions); expect(result).toEqual({ results: [ { name: 'Personal Calendar', value: 'cal1' }, { name: 'Project Calendar', value: 'cal3' }, { name: 'Work Calendar', value: 'cal2' }, ], }); }); it('should filter calendars by name', async () => { const mockCalendars = [ { id: 'cal1', summary: 'Personal Calendar' }, { id: 'cal2', summary: 'Work Calendar' }, { id: 'cal3', summary: 'Personal Tasks' }, ]; requestOAuth2Spy.mockResolvedValue({ items: mockCalendars, }); const result = await getCalendars.call(mockLoadOptionsFunctions, 'personal'); expect(result).toEqual({ results: [ { name: 'Personal Calendar', value: 'cal1' }, { name: 'Personal Tasks', value: 'cal3' }, ], }); }); it('should filter calendars by exact ID match', async () => { const mockCalendars = [ { id: 'cal1', summary: 'Personal Calendar' }, { id: 'cal2', summary: 'Work Calendar' }, ]; requestOAuth2Spy.mockResolvedValue({ items: mockCalendars, }); const result = await getCalendars.call(mockLoadOptionsFunctions, 'cal2'); expect(result).toEqual({ results: [{ name: 'Work Calendar', value: 'cal2' }], }); }); it('should sort calendars alphabetically', async () => { const mockCalendars = [ { id: 'cal1', summary: 'Zebra Calendar' }, { id: 'cal2', summary: 'Alpha Calendar' }, { id: 'cal3', summary: 'Beta Calendar' }, ]; requestOAuth2Spy.mockResolvedValue({ items: mockCalendars, }); const result = await getCalendars.call(mockLoadOptionsFunctions); expect(result).toEqual({ results: [ { name: 'Alpha Calendar', value: 'cal2' }, { name: 'Beta Calendar', value: 'cal3' }, { name: 'Zebra Calendar', value: 'cal1' }, ], }); }); it('should handle empty calendar list', async () => { requestOAuth2Spy.mockResolvedValue({ items: [], }); const result = await getCalendars.call(mockLoadOptionsFunctions); expect(result).toEqual({ results: [], }); }); }); describe('getTimezones', () => { let mockLoadOptionsFunctions: jest.Mocked; beforeEach(() => { mockLoadOptionsFunctions = mockDeep(); jest.clearAllMocks(); }); it('should return all timezones without filter', async () => { const result = await getTimezones.call(mockLoadOptionsFunctions); expect(result.results).toBeDefined(); expect(result.results.length).toBeGreaterThan(0); expect(result.results).toContainEqual({ name: 'UTC', value: 'UTC' }); expect(result.results).toContainEqual({ name: 'America/New_York', value: 'America/New_York' }); }); it('should filter timezones by name', async () => { const result = await getTimezones.call(mockLoadOptionsFunctions, 'america'); expect(result.results).toBeDefined(); expect(result.results.length).toBeGreaterThan(0); expect(result.results.every((tz) => tz.name.toLowerCase().includes('america'))).toBe(true); expect(result.results).toContainEqual({ name: 'America/New_York', value: 'America/New_York' }); }); it('should filter timezones by exact match', async () => { const result = await getTimezones.call(mockLoadOptionsFunctions, 'UTC'); expect(result.results).toContainEqual({ name: 'UTC', value: 'UTC' }); expect(result.results.length).toBeGreaterThan(0); }); it('should return empty results for non-existent timezone', async () => { const result = await getTimezones.call(mockLoadOptionsFunctions, 'NonExistent/Timezone'); expect(result.results).toEqual([]); }); it('should handle case insensitive filtering', async () => { const result = await getTimezones.call(mockLoadOptionsFunctions, 'EUROPE/LONDON'); expect(result.results.some((tz) => tz.value === 'Europe/London')).toBe(true); }); }); describe('addNextOccurrence', () => { it('should add next occurrence for recurring events', () => { const items = [ { start: { dateTime: '2025-01-01T10:00:00Z', date: '2025-01-01' }, end: { dateTime: '2025-01-01T11:00:00Z', date: '2025-01-01' }, recurrence: ['RRULE:FREQ=DAILY;UNTIL=20501231T235959Z'], }, ]; const result = addNextOccurrence(items); expect(result).toHaveLength(1); if (result[0].nextOccurrence) { expect(result[0].nextOccurrence.start.dateTime).toBeDefined(); expect(result[0].nextOccurrence.end.dateTime).toBeDefined(); } }); it('should handle weekly recurring events', () => { const items = [ { start: { dateTime: '2025-01-01T10:00:00Z', date: '2025-01-01' }, end: { dateTime: '2025-01-01T11:00:00Z', date: '2025-01-01' }, recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=SU;UNTIL=20501231T235959Z'], }, ]; const result = addNextOccurrence(items); expect(result[0].nextOccurrence).toBeDefined(); }); it('should handle events with end date (all-day events)', () => { const items = [ { start: { date: '2025-01-01' }, end: { date: '2025-01-02' }, recurrence: ['RRULE:FREQ=MONTHLY;BYMONTHDAY=1;UNTIL=20501231T235959Z'], }, ]; const result = addNextOccurrence(items); expect(result[0].nextOccurrence).toBeDefined(); }); it('should preserve timezone information', () => { const items = [ { start: { dateTime: '2025-01-01T10:00:00Z', date: '2025-01-01', timeZone: 'America/New_York', }, end: { dateTime: '2025-01-01T11:00:00Z', date: '2025-01-01', timeZone: 'America/New_York' }, recurrence: ['RRULE:FREQ=DAILY;UNTIL=20501231T235959Z'], }, ]; const result = addNextOccurrence(items); if (result[0].nextOccurrence) { expect(result[0].nextOccurrence.start.timeZone).toBe('America/New_York'); expect(result[0].nextOccurrence.end.timeZone).toBe('America/New_York'); } }); it('should skip events without RRULE', () => { const items = [ { start: { dateTime: '2025-01-01T10:00:00Z' }, end: { dateTime: '2025-01-01T11:00:00Z' }, recurrence: ['EXDATE:20250102T100000Z'], }, ]; const result = addNextOccurrence(items); expect(result[0].nextOccurrence).toBeUndefined(); }); it('should skip events with past UNTIL date', () => { const items = [ { start: { dateTime: '2020-01-01T10:00:00Z' }, end: { dateTime: '2020-01-01T11:00:00Z' }, recurrence: ['RRULE:FREQ=DAILY;UNTIL=20201231T235959Z'], }, ]; const result = addNextOccurrence(items); expect(result[0].nextOccurrence).toBeUndefined(); }); it('should handle events without recurrence', () => { const items = [ { start: { dateTime: '2025-01-01T10:00:00Z' }, end: { dateTime: '2025-01-01T11:00:00Z' }, recurrence: [], }, ]; const result = addNextOccurrence(items); expect(result[0].nextOccurrence).toBeUndefined(); }); it('should handle invalid recurrence rules gracefully', () => { const items = [ { start: { dateTime: '2025-01-01T10:00:00Z' }, end: { dateTime: '2025-01-01T11:00:00Z' }, recurrence: ['RRULE:INVALID_RULE'], }, ]; const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const result = addNextOccurrence(items); expect(result[0].nextOccurrence).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith('Error adding next occurrence RRULE:INVALID_RULE'); consoleSpy.mockRestore(); }); }); describe('googleApiRequestWithRetries', () => { let mockExecuteFunctions: jest.Mocked; let mockNode: INode; let requestOAuth2Spy: jest.SpyInstance; beforeEach(() => { mockExecuteFunctions = mockDeep(); mockNode = { id: 'test-node', name: 'Test Node', type: 'n8n-nodes-base.googleCalendar', typeVersion: 1, position: [0, 0], parameters: {}, }; mockExecuteFunctions.getNode.mockReturnValue(mockNode); requestOAuth2Spy = jest.spyOn(mockExecuteFunctions.helpers, 'requestOAuth2'); jest.clearAllMocks(); }); it('should make successful request without retries', async () => { const mockResponse = { id: 'test-calendar' }; requestOAuth2Spy.mockResolvedValue(mockResponse); const result = await googleApiRequestWithRetries({ context: mockExecuteFunctions, method: 'GET', resource: '/calendar/v3/calendars/test', }); expect(result).toEqual(mockResponse); expect(requestOAuth2Spy).toHaveBeenCalledTimes(1); }); it('should retry on 429 rate limit error', async () => { const rateLimitError = new NodeApiError(mockNode, { message: 'Rate limit exceeded' }); rateLimitError.httpCode = '429'; const mockResponse = { id: 'test-calendar' }; requestOAuth2Spy.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(mockResponse); const result = await googleApiRequestWithRetries({ context: mockExecuteFunctions, method: 'GET', resource: '/calendar/v3/calendars/test', }); expect(result).toEqual(mockResponse); expect(requestOAuth2Spy).toHaveBeenCalledTimes(2); }); it('should retry on 403 forbidden error', async () => { const forbiddenError = new NodeApiError(mockNode, { message: 'Forbidden' }); forbiddenError.httpCode = '403'; const mockResponse = { id: 'test-calendar' }; requestOAuth2Spy.mockRejectedValueOnce(forbiddenError).mockResolvedValueOnce(mockResponse); const result = await googleApiRequestWithRetries({ context: mockExecuteFunctions, method: 'GET', resource: '/calendar/v3/calendars/test', }); expect(result).toEqual(mockResponse); expect(requestOAuth2Spy).toHaveBeenCalledTimes(2); }); it('should not retry on non-retryable errors', async () => { const notFoundError = new NodeApiError(mockNode, { message: 'Not found' }); notFoundError.httpCode = '404'; requestOAuth2Spy.mockRejectedValue(notFoundError); await expect( googleApiRequestWithRetries({ context: mockExecuteFunctions, method: 'GET', resource: '/calendar/v3/calendars/test', }), ).rejects.toThrow(NodeApiError); expect(requestOAuth2Spy).toHaveBeenCalledTimes(1); }); it('should throw NodeApiError for generic errors', async () => { // Mock a generic error - googleApiRequest will wrap it in NodeApiError const genericError = new Error('Generic error'); requestOAuth2Spy.mockRejectedValue(genericError); await expect( googleApiRequestWithRetries({ context: mockExecuteFunctions, method: 'GET', resource: '/calendar/v3/calendars/test', itemIndex: 5, }), ).rejects.toThrow(NodeApiError); }); it('should pass through request parameters', async () => { const mockResponse = { success: true }; const body = { summary: 'Test Calendar' }; const qs = { maxResults: 10 }; const headers = { 'X-Custom': 'test' }; const uri = 'https://custom.googleapis.com/test'; requestOAuth2Spy.mockResolvedValue(mockResponse); await googleApiRequestWithRetries({ context: mockExecuteFunctions, method: 'POST', resource: '/calendar/v3/calendars', body, qs, uri, headers, itemIndex: 2, }); expect(requestOAuth2Spy).toHaveBeenCalledWith( 'googleCalendarOAuth2Api', expect.objectContaining({ method: 'POST', body, qs, uri, headers: expect.objectContaining(headers), }), ); }); });