first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,851 @@
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<IExecuteFunctions>;
let mockNode: INode;
let requestOAuth2Spy: jest.SpyInstance;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
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<IExecuteFunctions>;
let requestOAuth2Spy: jest.SpyInstance;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
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&param=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<ILoadOptionsFunctions>;
let requestOAuth2Spy: jest.SpyInstance;
beforeEach(() => {
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
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<ILoadOptionsFunctions>;
beforeEach(() => {
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
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<IExecuteFunctions>;
let mockNode: INode;
let requestOAuth2Spy: jest.SpyInstance;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
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),
}),
);
});
});
@@ -0,0 +1,657 @@
import { mockDeep } from 'jest-mock-extended';
import moment from 'moment-timezone';
import type { IPollFunctions, INode } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import * as GenericFunctions from '../GenericFunctions';
import { GoogleCalendarTrigger } from '../GoogleCalendarTrigger.node';
jest.mock('../GenericFunctions', () => ({
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(),
encodeURIComponentOnce: jest.fn(),
getCalendars: jest.fn(),
}));
describe('GoogleCalendarTrigger', () => {
let trigger: GoogleCalendarTrigger;
let mockPollFunctions: jest.Mocked<IPollFunctions>;
let mockNode: INode;
const googleApiRequestSpy = jest.spyOn(GenericFunctions, 'googleApiRequest');
const googleApiRequestAllItemsSpy = jest.spyOn(GenericFunctions, 'googleApiRequestAllItems');
const encodeURIComponentOnceSpy = jest.spyOn(GenericFunctions, 'encodeURIComponentOnce');
beforeEach(() => {
trigger = new GoogleCalendarTrigger();
mockPollFunctions = mockDeep<IPollFunctions>();
mockNode = {
id: 'test-node-id',
name: 'Google Calendar Trigger Test',
type: 'n8n-nodes-base.googleCalendarTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
jest.clearAllMocks();
mockPollFunctions.getNode.mockReturnValue(mockNode);
mockPollFunctions.getWorkflowStaticData.mockReturnValue({});
(mockPollFunctions.helpers.returnJsonArray as jest.Mock).mockImplementation((data: any[]) =>
data.map((item: any, index: number) => ({ json: item, pairedItem: { item: index } })),
);
encodeURIComponentOnceSpy.mockImplementation((uri) => encodeURIComponent(uri));
});
afterEach(() => {
jest.resetAllMocks();
});
describe('Node Description', () => {
it('should have correct basic properties', () => {
expect(trigger.description.displayName).toBe('Google Calendar Trigger');
expect(trigger.description.name).toBe('googleCalendarTrigger');
expect(trigger.description.group).toEqual(['trigger']);
expect(trigger.description.version).toBe(1);
expect(trigger.description.polling).toBe(true);
});
it('should have correct credentials configuration', () => {
expect(trigger.description.credentials).toEqual([
{
name: 'googleCalendarOAuth2Api',
required: true,
},
]);
});
it('should have correct node structure', () => {
expect(trigger.description.inputs).toEqual([]);
expect(trigger.description.outputs).toHaveLength(1);
});
it('should have required properties defined', () => {
const properties = trigger.description.properties;
expect(properties).toBeDefined();
expect(properties.length).toBe(3);
const calendarProp = properties.find((p) => p.name === 'calendarId');
expect(calendarProp).toBeDefined();
expect(calendarProp?.required).toBe(true);
const triggerProp = properties.find((p) => p.name === 'triggerOn');
expect(triggerProp).toBeDefined();
expect(triggerProp?.required).toBe(true);
});
it('should have correct trigger options', () => {
const triggerProp = trigger.description.properties.find((p) => p.name === 'triggerOn');
expect(triggerProp?.type).toBe('options');
expect(triggerProp?.options).toEqual([
{ name: 'Event Cancelled', value: 'eventCancelled' },
{ name: 'Event Created', value: 'eventCreated' },
{ name: 'Event Ended', value: 'eventEnded' },
{ name: 'Event Started', value: 'eventStarted' },
{ name: 'Event Updated', value: 'eventUpdated' },
]);
});
});
describe('Methods', () => {
it('should have listSearch methods defined', () => {
expect(trigger.methods?.listSearch?.getCalendars).toBe(GenericFunctions.getCalendars);
});
});
describe('Poll Function - Parameter Validation', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9, minute: 0 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
});
it('should throw error when no poll times are set', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'pollTimes.item') return [];
return 'test-value';
});
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('Please set a poll time');
});
it('should throw error when triggerOn is empty', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return '';
if (paramName === 'pollTimes.item') return [{ hour: 9 }];
return 'test-value';
});
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('Please select an event');
});
it('should throw error when calendarId is empty', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'calendarId') return '';
if (paramName === 'pollTimes.item') return [{ hour: 9 }];
if (paramName === 'triggerOn') return 'eventCreated';
return 'test-value';
});
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow(
'Please select a calendar',
);
});
});
describe('Poll Function - Event Created Trigger', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9, minute: 0 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
});
it('should fetch events and filter by created date', async () => {
const now = moment();
const webhookData = { lastTimeChecked: now.clone().subtract(2, 'hours').format() };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
const mockEvents = [
{
id: '1',
summary: 'Test Event 1',
created: now.clone().subtract(1, 'hour').format(),
updated: now.format(),
},
{
id: '2',
summary: 'Test Event 2',
created: now.clone().subtract(3, 'hours').format(),
updated: now.format(),
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/test%40example.com/events',
{},
expect.objectContaining({
showDeleted: false,
orderBy: 'updated',
}),
);
expect(result).toBeDefined();
expect(result?.[0]).toHaveLength(1);
expect(result?.[0][0].json.id).toBe('1');
});
it('should include match term in query when provided', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': 'meeting',
};
return params[paramName] ?? '';
});
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/test%40example.com/events',
{},
expect.objectContaining({
q: 'meeting',
}),
);
});
it('should return null when no events found', async () => {
googleApiRequestAllItemsSpy.mockResolvedValue([]);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
});
});
describe('Poll Function - Event Updated Trigger', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventUpdated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
});
it('should filter out events that were not actually updated', async () => {
const baseTime = moment();
const mockEvents = [
{
id: '1',
summary: 'Actually Updated Event',
created: baseTime.clone().subtract(2, 'hours').format('YYYY-MM-DDTHH:mm:ss'),
updated: baseTime.clone().subtract(1, 'hour').format('YYYY-MM-DDTHH:mm:ss'),
},
{
id: '2',
summary: 'Not Updated Event',
created: baseTime.clone().subtract(2, 'hours').format('YYYY-MM-DDTHH:mm:ss'),
updated: baseTime.clone().subtract(2, 'hours').format('YYYY-MM-DDTHH:mm:ss'),
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('1');
});
});
describe('Poll Function - Event Cancelled Trigger', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCancelled',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
});
it('should set showDeleted to true and filter cancelled events', async () => {
const mockEvents = [
{
id: '1',
summary: 'Cancelled Event',
status: 'cancelled',
created: moment().subtract(2, 'hours').format('YYYY-MM-DDTHH:mm:ss'),
updated: moment().subtract(1, 'hour').format('YYYY-MM-DDTHH:mm:ss'),
},
{
id: '2',
summary: 'Active Event',
status: 'confirmed',
created: moment().subtract(2, 'hours').format('YYYY-MM-DDTHH:mm:ss'),
updated: moment().subtract(1, 'hour').format('YYYY-MM-DDTHH:mm:ss'),
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/test%40example.com/events',
{},
expect.objectContaining({
showDeleted: true,
}),
);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('1');
expect(result![0][0].json.status).toBe('cancelled');
});
});
describe('Poll Function - Event Started/Ended Triggers', () => {
beforeEach(() => {
mockPollFunctions.getMode.mockReturnValue('trigger');
});
it('should handle eventStarted trigger with time-based filtering', async () => {
const now = moment();
const webhookData = { lastTimeChecked: now.clone().subtract(1, 'hour').format() };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventStarted',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
const mockEvents = [
{
id: '1',
summary: 'Started Event',
start: { dateTime: now.clone().subtract(30, 'minutes').format() },
end: { dateTime: now.clone().add(30, 'minutes').format() },
},
{
id: '2',
summary: 'Future Event',
start: { dateTime: now.clone().add(2, 'hours').format() },
end: { dateTime: now.clone().add(3, 'hours').format() },
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/test%40example.com/events',
{},
expect.objectContaining({
singleEvents: true,
orderBy: 'startTime',
}),
);
expect(result).toBeDefined();
expect(result?.[0]).toHaveLength(1);
expect(result?.[0][0].json.id).toBe('1');
});
it('should handle eventEnded trigger with time-based filtering', async () => {
const now = moment();
const webhookData = { lastTimeChecked: now.clone().subtract(1, 'hour').format() };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventEnded',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
const mockEvents = [
{
id: '1',
summary: 'Ended Event',
start: { dateTime: now.clone().subtract(2, 'hours').format() },
end: { dateTime: now.clone().subtract(30, 'minutes').format() },
},
{
id: '2',
summary: 'Ongoing Event',
start: { dateTime: now.clone().subtract(1, 'hour').format() },
end: { dateTime: now.clone().add(1, 'hour').format() },
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeDefined();
expect(result?.[0]).toHaveLength(1);
expect(result?.[0][0].json.id).toBe('1');
});
});
describe('Poll Function - Manual Mode', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('manual');
});
it('should fetch single event in manual mode', async () => {
const mockResponse = {
items: [
{
id: '1',
summary: 'Test Event',
},
],
};
googleApiRequestSpy.mockResolvedValue(mockResponse);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestSpy).toHaveBeenCalledWith(
'GET',
'/calendar/v3/calendars/test%40example.com/events',
{},
expect.objectContaining({
maxResults: 1,
}),
);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('1');
});
it('should throw NodeApiError when no data found in manual mode', async () => {
googleApiRequestSpy.mockResolvedValue({ items: [] });
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow(NodeApiError);
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow(
'No data with the current filter could be found',
);
});
});
describe('Poll Function - Error Handling', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
});
it('should handle API request errors', async () => {
const apiError = new Error('API Error');
googleApiRequestAllItemsSpy.mockRejectedValue(apiError);
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('API Error');
});
it('should handle invalid calendar ID', async () => {
encodeURIComponentOnceSpy.mockImplementation((uri) => {
if (!uri) throw new Error('Invalid calendar ID');
return encodeURIComponent(uri);
});
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'calendarId') return null;
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('Invalid calendar ID');
});
});
describe('Poll Function - State Management', () => {
it('should update lastTimeChecked in webhook data', async () => {
const mockWebhookData = { lastTimeChecked: moment().subtract(1, 'day').format() };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWebhookData);
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(mockWebhookData.lastTimeChecked).toBeDefined();
expect(moment(mockWebhookData.lastTimeChecked).isValid()).toBe(true);
});
it('should use current time as startDate when no lastTimeChecked exists', async () => {
const mockWebhookData = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWebhookData);
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/test%40example.com/events',
{},
expect.objectContaining({
updatedMin: expect.any(String),
}),
);
});
});
describe('Edge Cases', () => {
it('should handle empty events array', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
googleApiRequestAllItemsSpy.mockResolvedValue([]);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
});
it('should handle events without required fields', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated', // Use eventCreated to avoid dateTime access issues
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
const mockEvents = [
{
id: '1',
summary: 'Event without created field',
// Missing created and updated fields
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
// This should not throw an error but handle gracefully
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
});
it('should handle malformed date strings gracefully', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, string | any[]> = {
'pollTimes.item': [{ hour: 9 }],
triggerOn: 'eventCreated',
calendarId: 'test@example.com',
'options.matchTerm': '',
};
return params[paramName] ?? '';
});
mockPollFunctions.getMode.mockReturnValue('trigger');
const mockEvents = [
{
id: '1',
summary: 'Event with invalid date',
created: 'invalid-date',
updated: 'invalid-date',
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,89 @@
import moment from 'moment-timezone';
import type { RecurrentEvent } from '../GenericFunctions';
import { addNextOccurrence } from '../GenericFunctions';
const mockNow = '2024-09-06T16:30:00+03:00';
jest.spyOn(global.Date, 'now').mockImplementation(() => moment(mockNow).valueOf());
describe('addNextOccurrence', () => {
it('should not modify event if no recurrence exists', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: [],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle event with no RRULE correctly', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['FREQ=WEEKLY;COUNT=2'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should ignore recurrence if until date is in the past', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-08-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-08-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['RRULE:FREQ=DAILY;UNTIL=20240805T000000Z'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle errors gracefully without breaking and return unchanged event', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-06T17:30:00+03:00',
timeZone: 'Europe/Berlin',
},
end: {
dateTime: '2024-09-06T18:00:00+03:00',
timeZone: 'Europe/Berlin',
},
recurrence: ['xxxxx'],
},
];
const result = addNextOccurrence(event);
expect(result).toEqual(event);
});
});
@@ -0,0 +1,219 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions, IDataObject } from 'n8n-workflow';
import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node';
let response: IDataObject[] | undefined = [];
let responseWithRetries: IDataObject | undefined = {};
jest.mock('../../GenericFunctions', () => {
const originalModule = jest.requireActual('../../GenericFunctions');
return {
...originalModule,
getTimezones: jest.fn(),
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(async function () {
return (() => response)();
}),
googleApiRequestWithRetries: jest.fn(async function () {
return (() => responseWithRetries)();
}),
addNextOccurrence: jest.fn(function (data: IDataObject[]) {
return data;
}),
};
});
describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
googleCalendar = new GoogleCalendar();
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn(),
getNode: jest.fn(),
getNodeParameter: jest.fn(),
getTimezone: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockReturnValue([]),
},
});
response = undefined;
responseWithRetries = undefined;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Google Calendar > Event > Get Many', () => {
it('should configure get all request parameters in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
query: 'test query',
recurringEventHandling: 'expand',
showDeleted: true,
showHiddenInvitations: true,
updatedMin: '2024-12-21T00:00:00',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
q: 'test query',
showDeleted: true,
showHiddenInvitations: true,
singleEvents: true,
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
updatedMin: '2024-12-20T23:00:00Z',
},
);
});
it('should configure get all recurringEventHandling equals next in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'next',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
},
];
responseWithRetries = { items: [] };
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
},
);
expect(genericFunctions.googleApiRequestWithRetries).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
itemIndex: 0,
resource: '/calendar/v3/calendars/myCalendar/events/undefined/instances',
}),
);
expect(result).toEqual([[]]);
});
it('should configure get all recurringEventHandling equals first in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'first',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-19T00:00:00',
},
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-27T00:00:00',
},
];
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
});
it('should configure get all should have hint in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(''); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-25T00:00:00',
recurringEventId: '1',
start: { dateTime: '2027-12-25T00:00:00' },
},
];
await googleCalendar.execute.call(mockExecuteFunctions);
expect(mockExecuteFunctions.addExecutionHints).toHaveBeenCalledWith({
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
});
});
});
});
@@ -0,0 +1,127 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions } from 'n8n-workflow';
import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node';
jest.mock('../../GenericFunctions', () => ({
getTimezones: jest.fn(),
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(),
addTimezoneToDate: jest.fn(),
addNextOccurrence: jest.fn(),
encodeURIComponentOnce: jest.fn(),
}));
describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
googleCalendar = new GoogleCalendar();
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn(),
getNode: jest.fn(),
getNodeParameter: jest.fn(),
getTimezone: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockReturnValue([]),
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Google Calendar > Event > Update', () => {
it('should update replace attendees in version 1.1', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('update');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myEvent');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
attendees: ['email1@mail.com'],
});
await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/calendar/v3/calendars/undefined/events/myEvent',
{ attendees: [{ email: 'email1@mail.com' }] },
{},
);
});
it('should update replace attendees', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('update');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.2 }));
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myEvent');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
attendeesUi: {
values: {
mode: 'replace',
attendees: ['email1@mail.com'],
},
},
});
await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/calendar/v3/calendars/undefined/events/myEvent',
{ attendees: [{ email: 'email1@mail.com' }] },
{},
);
});
it('should update add attendees', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('update');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.2 }));
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myEvent');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
attendeesUi: {
values: {
mode: 'add',
attendees: ['email1@mail.com'],
},
},
});
(genericFunctions.googleApiRequest as jest.Mock).mockResolvedValueOnce({
attendees: [{ email: 'email2@mail.com' }],
});
await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledTimes(2);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith(
'GET',
'/calendar/v3/calendars/undefined/events/myEvent',
);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/calendar/v3/calendars/undefined/events/myEvent',
{ attendees: [{ email: 'email2@mail.com' }, { email: 'email1@mail.com' }] },
{},
);
});
});
});