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,217 @@
import type { INodeProperties } from 'n8n-workflow';
import { TIMEZONE_VALIDATION_REGEX } from './GenericFunctions';
export const calendarOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['calendar'],
},
},
options: [
{
name: 'Availability',
value: 'availability',
description: 'If a time-slot is available in a calendar',
action: 'Get availability in a calendar',
},
],
default: 'availability',
},
];
export const calendarFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* calendar:availability */
/* -------------------------------------------------------------------------- */
{
displayName: 'Calendar',
name: 'calendar',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'Google Calendar to operate on',
modes: [
{
displayName: 'Calendar',
name: 'list',
type: 'list',
placeholder: 'Select a Calendar...',
typeOptions: {
searchListMethod: 'getCalendars',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
// calendar ids are emails. W3C email regex with optional trailing whitespace.
regex:
'(^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:[ \t]+)*$)',
errorMessage: 'Not a valid Google Calendar ID',
},
},
],
extractValue: {
type: 'regex',
regex: '(^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)',
},
placeholder: 'name@google.com',
},
],
displayOptions: {
show: {
resource: ['calendar'],
},
},
},
{
displayName: 'Start Time',
name: 'timeMin',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'Start of the interval',
},
{
displayName: 'End Time',
name: 'timeMax',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'End of the interval',
},
{
displayName: 'Start Time',
name: 'timeMin',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End Time',
name: 'timeMax',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
},
},
default: {},
options: [
{
displayName: 'Output Format',
name: 'outputFormat',
type: 'options',
options: [
{
name: 'Availability',
value: 'availability',
description: 'Returns if there are any events in the given time or not',
},
{
name: 'Booked Slots',
value: 'bookedSlots',
description: 'Returns the booked slots',
},
{
name: 'RAW',
value: 'raw',
description: 'Returns the RAW data from the API',
},
],
default: 'availability',
description: 'The format to return the data in',
},
{
displayName: 'Timezone',
name: 'timezone',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Time zone used in the response. By default n8n timezone is used.',
modes: [
{
displayName: 'Timezone',
name: 'list',
type: 'list',
placeholder: 'Select a Timezone...',
typeOptions: {
searchListMethod: 'getTimezones',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: TIMEZONE_VALIDATION_REGEX,
errorMessage: 'Not a valid Timezone',
},
},
],
extractValue: {
type: 'regex',
regex: '([-+/_a-zA-Z0-9]*)',
},
placeholder: 'Europe/Berlin',
},
],
},
],
},
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import type { IDataObject } from 'n8n-workflow';
export interface IReminder {
useDefault?: boolean;
overrides?: IDataObject[];
}
export interface IConferenceData {
createRequest?: {
requestId: string;
conferenceSolution: {
type: string;
};
};
}
export interface IEvent {
attendees?: IDataObject[];
colorId?: string;
description?: string;
end?: IDataObject;
guestsCanInviteOthers?: boolean;
guestsCanModify?: boolean;
guestsCanSeeOtherGuests?: boolean;
id?: string;
location?: string;
maxAttendees?: number;
recurrence?: string[];
reminders?: IReminder;
sendUpdates?: string;
start?: IDataObject;
summary?: string;
transparency?: string;
visibility?: string;
conferenceData?: IConferenceData;
}
export type RecurringEventInstance = {
recurringEventId?: string;
start: { dateTime: string; date: string };
};
@@ -0,0 +1,304 @@
import { DateTime } from 'luxon';
import moment from 'moment-timezone';
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
INode,
INodeListSearchItems,
INodeListSearchResult,
IPollFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError, sleep } from 'n8n-workflow';
import { RRule } from 'rrule';
import type { RecurringEventInstance } from './EventInterface';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
resource: string,
body: any = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
): Promise<any> {
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `https://www.googleapis.com${resource}`,
json: true,
};
try {
if (Object.keys(headers).length !== 0) {
options.headers = Object.assign({}, options.headers, headers);
}
if (Object.keys(body as IDataObject).length === 0) {
delete options.body;
}
return await this.helpers.requestOAuth2.call(this, 'googleCalendarOAuth2Api', options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function googleApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
query.maxResults = 100;
do {
responseData = await googleApiRequest.call(this, method, endpoint, body, query);
query.pageToken = responseData.nextPageToken;
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (responseData.nextPageToken !== undefined && responseData.nextPageToken !== '');
return returnData;
}
export function encodeURIComponentOnce(uri: string) {
// load options used to save encoded uri strings
return encodeURIComponent(decodeURIComponent(uri));
}
export async function getCalendars(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const calendars = (await googleApiRequestAllItems.call(
this,
'items',
'GET',
'/calendar/v3/users/me/calendarList',
)) as Array<{ id: string; summary: string }>;
const results: INodeListSearchItems[] = calendars
.map((c) => ({
name: c.summary,
value: c.id,
}))
.filter(
(c) =>
!filter ||
c.name.toLowerCase().includes(filter.toLowerCase()) ||
c.value?.toString() === filter,
)
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results };
}
export const TIMEZONE_VALIDATION_REGEX = `(${moment.tz
.names()
.map((t) => t.replace('+', '\\+'))
.join('|')})[ \t]*`;
export async function getTimezones(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const results: INodeListSearchItems[] = moment.tz
.names()
.map((timezone) => ({
name: timezone,
value: timezone,
}))
.filter(
(c) =>
!filter ||
c.name.toLowerCase().includes(filter.toLowerCase()) ||
c.value?.toString() === filter,
);
return { results };
}
export type RecurrentEvent = {
start: {
date?: string;
dateTime?: string;
timeZone?: string;
};
end: {
date?: string;
dateTime?: string;
timeZone?: string;
};
recurrence: string[];
nextOccurrence?: {
start: {
dateTime: string;
timeZone?: string;
};
end: {
dateTime: string;
timeZone?: string;
};
};
};
export function addNextOccurrence(items: RecurrentEvent[]) {
for (const item of items) {
if (item.recurrence) {
let eventRecurrence;
try {
eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE'));
if (!eventRecurrence) continue;
const start = moment(item.start.dateTime || item.end.date).utc();
const end = moment(item.end.dateTime || item.end.date).utc();
const rruleWithStartDate = `DTSTART:${start.format(
'YYYYMMDDTHHmmss',
)}Z\n${eventRecurrence}`;
const rrule = RRule.fromString(rruleWithStartDate);
const until = rrule.options?.until;
const now = moment().utc();
if (until && moment(until).isBefore(now)) {
continue;
}
const nextDate = rrule.after(now.toDate(), false);
if (nextDate) {
const nextStart = moment(nextDate);
const duration = moment.duration(moment(end).diff(moment(start)));
const nextEnd = moment(nextStart).add(duration);
item.nextOccurrence = {
start: {
dateTime: nextStart.format(),
timeZone: item.start.timeZone,
},
end: {
dateTime: nextEnd.format(),
timeZone: item.end.timeZone,
},
};
}
} catch (error) {
console.log(`Error adding next occurrence ${eventRecurrence}`);
}
}
}
return items;
}
const hasTimezone = (date: string) => date.endsWith('Z') || /\+\d{2}:\d{2}$/.test(date);
export function addTimezoneToDate(date: string, timezone: string) {
if (hasTimezone(date)) return date;
return moment.tz(date, timezone).utc().format();
}
async function requestWithRetries(
node: INode,
requestFn: () => Promise<any>,
retryCount: number = 0,
maxRetries: number = 10,
itemIndex: number = 0,
): Promise<any> {
try {
return await requestFn();
} catch (error) {
if (!(error instanceof NodeApiError)) {
throw new NodeOperationError(node, error.message, { itemIndex });
}
if (retryCount >= maxRetries) throw error;
if (error.httpCode === '403' || error.httpCode === '429') {
const delay = 1000 * Math.pow(2, retryCount);
console.log(`Rate limit hit. Retrying in ${delay}ms... (Attempt ${retryCount + 1})`);
await sleep(delay);
return await requestWithRetries(node, requestFn, retryCount + 1, maxRetries, itemIndex);
}
throw error;
}
}
export async function googleApiRequestWithRetries({
context,
method,
resource,
body = {},
qs = {},
uri,
headers = {},
itemIndex = 0,
}: {
context: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
method: IHttpRequestMethods;
resource: string;
body?: any;
qs?: IDataObject;
uri?: string;
headers?: IDataObject;
itemIndex?: number;
}) {
const requestFn = async (): Promise<any> => {
return await googleApiRequest.call(context, method, resource, body, qs, uri, headers);
};
const retryCount = 0;
const maxRetries = 10;
return await requestWithRetries(context.getNode(), requestFn, retryCount, maxRetries, itemIndex);
}
export const eventExtendYearIntoFuture = (
data: RecurringEventInstance[],
timezone: string,
currentYear?: number, // for testing purposes
) => {
const thisYear = currentYear || moment().tz(timezone).year();
return data.some((event) => {
if (!event.recurringEventId) return false;
const eventStart = event.start.dateTime || event.start.date;
const eventDateTime = moment(eventStart).tz(timezone);
if (!eventDateTime.isValid()) return false;
const targetYear = eventDateTime.year();
if (targetYear - thisYear >= 1) {
return true;
} else {
return false;
}
});
};
export function dateObjectToISO<T>(date: T): string {
if (date instanceof DateTime) return date.toISO();
if (date instanceof Date) return date.toISOString();
return date as string;
}
@@ -0,0 +1,50 @@
{
"node": "n8n-nodes-base.googleCalendar",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlecalendar/"
}
],
"generic": [
{
"label": "How to host virtual coffee breaks with n8n",
"icon": "☕️",
"url": "https://n8n.io/blog/how-to-host-virtual-coffee-breaks-with-n8n/"
},
{
"label": "Supercharging your conference registration process with n8n",
"icon": "🎫",
"url": "https://n8n.io/blog/supercharging-your-conference-registration-process-with-n8n/"
},
{
"label": "15 Google apps you can combine and automate to increase productivity",
"icon": "💡",
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
},
{
"label": "Hey founders! Your business doesn't need you to operate",
"icon": " 🖥️",
"url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/"
},
{
"label": "5 workflow automation for Mattermost that we love at n8n",
"icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
},
{
"label": "Tracking Time Spent in Meetings With Google Calendar, Twilio, and n8n",
"icon": "🗓",
"url": "https://n8n.io/blog/tracking-time-spent-in-meetings-with-google-calendar-twilio-and-n8n/"
}
]
}
}
@@ -0,0 +1,823 @@
import moment from 'moment-timezone';
import type {
IExecuteFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
JsonObject,
NodeExecutionHint,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { calendarFields, calendarOperations } from './CalendarDescription';
import { eventFields, eventOperations } from './EventDescription';
import type { IEvent, RecurringEventInstance } from './EventInterface';
import {
addNextOccurrence,
addTimezoneToDate,
dateObjectToISO,
encodeURIComponentOnce,
eventExtendYearIntoFuture,
getCalendars,
getTimezones,
googleApiRequest,
googleApiRequestAllItems,
googleApiRequestWithRetries,
type RecurrentEvent,
} from './GenericFunctions';
import { sortItemKeysByPriorityList } from '../../../utils/utilities';
export class GoogleCalendar implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Calendar',
name: 'googleCalendar',
icon: 'file:googleCalendar.svg',
group: ['input'],
version: [1, 1.1, 1.2, 1.3],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API',
schemaPath: 'Google/Calendar',
defaults: {
name: 'Google Calendar',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
builderHint: {
relatedNodes: [
{
nodeType: 'n8n-nodes-base.googleCalendarTool',
relationHint: 'Tool version for AI Agent use',
},
],
},
usableAsTool: true,
credentials: [
{
name: 'googleCalendarOAuth2Api',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Calendar',
value: 'calendar',
},
{
name: 'Event',
value: 'event',
},
],
default: 'event',
},
...calendarOperations,
...calendarFields,
...eventOperations,
...eventFields,
{
displayName:
'This node will use the time zone set in n8ns settings, but you can override this in the workflow settings',
name: 'useN8nTimeZone',
type: 'notice',
default: '',
},
],
};
methods = {
listSearch: {
getCalendars,
getTimezones,
},
loadOptions: {
// Get all the calendars to display them to user so that they can
// select them easily
async getConferenceSolutions(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const calendar = this.getCurrentNodeParameter('calendar', { extractValue: true }) as string;
const possibleSolutions: IDataObject = {
eventHangout: 'Google Hangout',
eventNamedHangout: 'Google Hangout Classic',
hangoutsMeet: 'Google Meet',
};
const {
conferenceProperties: { allowedConferenceSolutionTypes },
} = await googleApiRequest.call(
this,
'GET',
`/calendar/v3/users/me/calendarList/${calendar}`,
);
for (const solution of allowedConferenceSolutionTypes) {
returnData.push({
name: possibleSolutions[solution] as string,
value: solution,
});
}
return returnData;
},
// Get all the colors to display them to user so that they can
// select them easily
async getColors(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { event } = await googleApiRequest.call(this, 'GET', '/calendar/v3/colors');
for (const key of Object.keys(event as IDataObject)) {
const colorName = `Background: ${event[key].background} - Foreground: ${event[key].foreground}`;
const colorId = key;
returnData.push({
name: `${colorName}`,
value: colorId,
});
}
return returnData;
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const length = items.length;
const qs: IDataObject = {};
const hints: NodeExecutionHint[] = [];
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const timezone = this.getTimezone();
const nodeVersion = this.getNode().typeVersion;
for (let i = 0; i < length; i++) {
try {
if (resource === 'calendar') {
//https://developers.google.com/calendar/v3/reference/freebusy/query
if (operation === 'availability') {
// we need to decode once because calendar used to be saved encoded
const calendarId = decodeURIComponent(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
const options = this.getNodeParameter('options', i);
const outputFormat = options.outputFormat || 'availability';
const tz = this.getNodeParameter('options.timezone', i, '', {
extractValue: true,
}) as string;
const body: IDataObject = {
timeMin: moment(timeMin).utc().format(),
timeMax: moment(timeMax).utc().format(),
items: [
{
id: calendarId,
},
],
timeZone: tz || timezone,
};
responseData = await googleApiRequest.call(
this,
'POST',
'/calendar/v3/freeBusy',
body,
{},
);
if (responseData.calendars[calendarId].errors) {
throw new NodeApiError(
this.getNode(),
responseData.calendars[calendarId] as JsonObject,
{
itemIndex: i,
},
);
}
if (outputFormat === 'availability') {
responseData = {
available: !responseData.calendars[calendarId].busy.length,
};
} else if (outputFormat === 'bookedSlots') {
responseData = responseData.calendars[calendarId].busy;
}
}
}
if (resource === 'event') {
//https://developers.google.com/calendar/v3/reference/events/insert
if (operation === 'create') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const start = dateObjectToISO(this.getNodeParameter('start', i));
const end = dateObjectToISO(this.getNodeParameter('end', i));
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i);
if (additionalFields.maxAttendees) {
qs.maxAttendees = additionalFields.maxAttendees as number;
}
if (additionalFields.sendNotifications) {
qs.sendNotifications = additionalFields.sendNotifications as boolean;
}
if (additionalFields.sendUpdates) {
qs.sendUpdates = additionalFields.sendUpdates as string;
}
const body: IEvent = {
start: {
dateTime: moment.tz(start, timezone).utc().format(),
timeZone: timezone,
},
end: {
dateTime: moment.tz(end, timezone).utc().format(),
timeZone: timezone,
},
};
if (additionalFields.attendees) {
body.attendees = [];
(additionalFields.attendees as string[]).forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
}
if (additionalFields.color) {
body.colorId = additionalFields.color as string;
}
if (additionalFields.description) {
body.description = additionalFields.description as string;
}
if (additionalFields.guestsCanInviteOthers) {
body.guestsCanInviteOthers = additionalFields.guestsCanInviteOthers as boolean;
}
if (additionalFields.guestsCanModify) {
body.guestsCanModify = additionalFields.guestsCanModify as boolean;
}
if (additionalFields.guestsCanSeeOtherGuests) {
body.guestsCanSeeOtherGuests = additionalFields.guestsCanSeeOtherGuests as boolean;
}
if (additionalFields.id) {
body.id = additionalFields.id as string;
}
if (additionalFields.location) {
body.location = additionalFields.location as string;
}
if (additionalFields.summary) {
body.summary = additionalFields.summary as string;
}
if (additionalFields.showMeAs) {
body.transparency = additionalFields.showMeAs as string;
}
if (additionalFields.visibility) {
body.visibility = additionalFields.visibility as string;
}
if (!useDefaultReminders) {
const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject)
.remindersValues as IDataObject[];
body.reminders = {
useDefault: false,
};
if (reminders) {
body.reminders.overrides = reminders;
}
}
if (additionalFields.allday === 'yes') {
body.start = {
date: timezone
? moment.tz(start, timezone).utc(true).format('YYYY-MM-DD')
: moment.tz(start, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
body.end = {
date: timezone
? moment.tz(end, timezone).utc(true).format('YYYY-MM-DD')
: moment.tz(end, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
}
//exampel: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z
//https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html
body.recurrence = [];
if (additionalFields.rrule) {
body.recurrence = [`RRULE:${additionalFields.rrule}`];
} else {
if (additionalFields.repeatHowManyTimes && additionalFields.repeatUntil) {
throw new NodeOperationError(
this.getNode(),
"You can set either 'Repeat How Many Times' or 'Repeat Until' but not both",
{ itemIndex: i },
);
}
if (additionalFields.repeatFrecuency) {
body.recurrence?.push(
`FREQ=${(additionalFields.repeatFrecuency as string).toUpperCase()};`,
);
}
if (additionalFields.repeatHowManyTimes) {
body.recurrence?.push(`COUNT=${additionalFields.repeatHowManyTimes};`);
}
if (additionalFields.repeatUntil) {
const repeatUntil = moment(additionalFields.repeatUntil as string)
.utc()
.format('YYYYMMDDTHHmmss');
body.recurrence?.push(`UNTIL=${repeatUntil}Z`);
}
if (body.recurrence.length !== 0) {
body.recurrence = [`RRULE:${body.recurrence.join('')}`];
}
}
if (additionalFields.conferenceDataUi) {
const conferenceData = (additionalFields.conferenceDataUi as IDataObject)
.conferenceDataValues as IDataObject;
if (conferenceData) {
qs.conferenceDataVersion = 1;
body.conferenceData = {
createRequest: {
requestId: uuid(),
conferenceSolution: {
type: conferenceData.conferenceSolution as string,
},
},
};
}
}
responseData = await googleApiRequest.call(
this,
'POST',
`/calendar/v3/calendars/${calendarId}/events`,
body,
qs,
);
}
//https://developers.google.com/calendar/v3/reference/events/delete
if (operation === 'delete') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const eventId = this.getNodeParameter('eventId', i) as string;
const options = this.getNodeParameter('options', i);
if (options.sendUpdates) {
qs.sendUpdates = options.sendUpdates as number;
}
responseData = await googleApiRequest.call(
this,
'DELETE',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
);
responseData = { success: true };
}
//https://developers.google.com/calendar/v3/reference/events/get
if (operation === 'get') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const eventId = this.getNodeParameter('eventId', i) as string;
const options = this.getNodeParameter('options', i);
const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true,
}) as string;
if (options.maxAttendees) {
qs.maxAttendees = options.maxAttendees as number;
}
if (tz) {
qs.timeZone = tz;
}
responseData = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
)) as IDataObject;
if (responseData) {
if (nodeVersion >= 1.3 && options.returnNextInstance && responseData.recurrence) {
const eventInstances =
((
(await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${responseData.id}/instances`,
{},
{
timeMin: new Date().toISOString(),
maxResults: 1,
},
)) as IDataObject
).items as IDataObject[]) || [];
responseData = eventInstances[0] ? [eventInstances[0]] : [responseData];
} else {
responseData = addNextOccurrence([responseData as RecurrentEvent]);
}
}
}
//https://developers.google.com/calendar/v3/reference/events/list
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i);
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const options = this.getNodeParameter('options', i);
const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true,
}) as string;
if (nodeVersion >= 1.3) {
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
if (timeMin) {
qs.timeMin = addTimezoneToDate(timeMin, tz || timezone);
}
if (timeMax) {
qs.timeMax = addTimezoneToDate(timeMax, tz || timezone);
}
if (!options.recurringEventHandling || options.recurringEventHandling === 'expand') {
qs.singleEvents = true;
}
}
if (options.iCalUID) {
qs.iCalUID = options.iCalUID as string;
}
if (options.maxAttendees) {
qs.maxAttendees = options.maxAttendees as number;
}
if (options.orderBy) {
qs.orderBy = options.orderBy as number;
}
if (options.query) {
qs.q = options.query as number;
}
if (options.showDeleted) {
qs.showDeleted = options.showDeleted as boolean;
}
if (options.showHiddenInvitations) {
qs.showHiddenInvitations = options.showHiddenInvitations as boolean;
}
if (options.singleEvents) {
qs.singleEvents = options.singleEvents as boolean;
}
if (options.timeMax) {
qs.timeMax = addTimezoneToDate(dateObjectToISO(options.timeMax), tz || timezone);
}
if (options.timeMin) {
qs.timeMin = addTimezoneToDate(dateObjectToISO(options.timeMin), tz || timezone);
}
if (tz) {
qs.timeZone = tz;
}
if (options.updatedMin) {
qs.updatedMin = addTimezoneToDate(
dateObjectToISO(options.updatedMin),
tz || timezone,
);
}
if (options.fields) {
qs.fields = options.fields as string;
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'items',
'GET',
`/calendar/v3/calendars/${calendarId}/events`,
{},
qs,
);
} else {
qs.maxResults = this.getNodeParameter('limit', i);
responseData = await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events`,
{},
qs,
);
responseData = responseData.items;
}
if (responseData) {
if (nodeVersion >= 1.3 && options.recurringEventHandling === 'next') {
const updatedEvents: IDataObject[] = [];
for (const event of responseData) {
if (event.recurrence) {
const eventInstances =
((
(await googleApiRequestWithRetries({
context: this,
method: 'GET',
resource: `/calendar/v3/calendars/${calendarId}/events/${event.id}/instances`,
qs: {
timeMin: new Date().toISOString(),
maxResults: 1,
},
itemIndex: i,
})) as IDataObject
).items as IDataObject[]) || [];
updatedEvents.push(eventInstances[0] || event);
continue;
}
updatedEvents.push(event);
}
responseData = updatedEvents;
} else if (nodeVersion >= 1.3 && options.recurringEventHandling === 'first') {
responseData = responseData.filter((event: IDataObject) => {
if (
qs.timeMin &&
event.recurrence &&
event.created &&
event.created < qs.timeMin
) {
return false;
}
if (
qs.timeMax &&
event.recurrence &&
event.created &&
event.created > qs.timeMax
) {
return false;
}
return true;
});
} else if (nodeVersion < 1.3) {
// in node version above or equal to 1.3, this would correspond to the 'expand' option,
// so no need to add the next occurrence as event instances returned by the API
responseData = addNextOccurrence(responseData);
}
if (
!qs.timeMax &&
(!options.recurringEventHandling || options.recurringEventHandling === 'expand')
) {
const suggestTrim = eventExtendYearIntoFuture(
responseData as RecurringEventInstance[],
timezone,
);
if (suggestTrim) {
hints.push({
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',
});
}
}
}
}
//https://developers.google.com/calendar/v3/reference/events/patch
if (operation === 'update') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
let eventId = this.getNodeParameter('eventId', i) as string;
if (nodeVersion >= 1.3) {
const modifyTarget = this.getNodeParameter('modifyTarget', i, 'instance') as string;
if (modifyTarget === 'event') {
const instance = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
)) as IDataObject;
eventId = instance.recurringEventId as string;
}
}
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const updateFields = this.getNodeParameter('updateFields', i);
let updateTimezone = updateFields.timezone as string;
if (nodeVersion > 1 && updateTimezone === undefined) {
updateTimezone = timezone;
}
if (updateFields.maxAttendees) {
qs.maxAttendees = updateFields.maxAttendees as number;
}
if (updateFields.sendNotifications) {
qs.sendNotifications = updateFields.sendNotifications as boolean;
}
if (updateFields.sendUpdates) {
qs.sendUpdates = updateFields.sendUpdates as string;
}
const body: IEvent = {};
if (updateFields.start) {
body.start = {
dateTime: moment.tz(updateFields.start, updateTimezone).utc().format(),
timeZone: updateTimezone,
};
}
if (updateFields.end) {
body.end = {
dateTime: moment.tz(updateFields.end, updateTimezone).utc().format(),
timeZone: updateTimezone,
};
}
// nodeVersion < 1.2
if (updateFields.attendees) {
body.attendees = [];
(updateFields.attendees as string[]).forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
}
// nodeVersion >= 1.2
if (updateFields.attendeesUi) {
const { mode, attendees } = (
updateFields.attendeesUi as {
values: {
mode: string;
attendees: string[];
};
}
).values;
body.attendees = [];
if (mode === 'add') {
const event = await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
);
((event?.attendees as IDataObject[]) || []).forEach((attendee) => {
body.attendees?.push(attendee);
});
}
attendees.forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
}
if (updateFields.color) {
body.colorId = updateFields.color as string;
}
if (updateFields.description) {
body.description = updateFields.description as string;
}
if (updateFields.guestsCanInviteOthers) {
body.guestsCanInviteOthers = updateFields.guestsCanInviteOthers as boolean;
}
if (updateFields.guestsCanModify) {
body.guestsCanModify = updateFields.guestsCanModify as boolean;
}
if (updateFields.guestsCanSeeOtherGuests) {
body.guestsCanSeeOtherGuests = updateFields.guestsCanSeeOtherGuests as boolean;
}
if (updateFields.id) {
body.id = updateFields.id as string;
}
if (updateFields.location) {
body.location = updateFields.location as string;
}
if (updateFields.summary) {
body.summary = updateFields.summary as string;
}
if (updateFields.showMeAs) {
body.transparency = updateFields.showMeAs as string;
}
if (updateFields.visibility) {
body.visibility = updateFields.visibility as string;
}
if (!useDefaultReminders) {
const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject)
.remindersValues as IDataObject[];
body.reminders = {
useDefault: false,
};
if (reminders) {
body.reminders.overrides = reminders;
}
}
if (updateFields.allday === 'yes' && updateFields.start && updateFields.end) {
body.start = {
date: updateTimezone
? moment.tz(updateFields.start, updateTimezone).utc(true).format('YYYY-MM-DD')
: moment.tz(updateFields.start, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
body.end = {
date: updateTimezone
? moment.tz(updateFields.end, updateTimezone).utc(true).format('YYYY-MM-DD')
: moment.tz(updateFields.end, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
}
//example: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z
//https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html
body.recurrence = [];
if (updateFields.rrule) {
body.recurrence = [`RRULE:${updateFields.rrule}`];
} else {
if (updateFields.repeatHowManyTimes && updateFields.repeatUntil) {
throw new NodeOperationError(
this.getNode(),
"You can set either 'Repeat How Many Times' or 'Repeat Until' but not both",
{ itemIndex: i },
);
}
if (updateFields.repeatFrecuency) {
body.recurrence?.push(
`FREQ=${(updateFields.repeatFrecuency as string).toUpperCase()};`,
);
}
if (updateFields.repeatHowManyTimes) {
body.recurrence?.push(`COUNT=${updateFields.repeatHowManyTimes};`);
}
if (updateFields.repeatUntil) {
const repeatUntil = moment(updateFields.repeatUntil as string)
.utc()
.format('YYYYMMDDTHHmmss');
body.recurrence?.push(`UNTIL=${repeatUntil}Z`);
}
if (body.recurrence.length !== 0) {
body.recurrence = [`RRULE:${body.recurrence.join('')}`];
} else {
delete body.recurrence;
}
}
responseData = await googleApiRequest.call(
this,
'PATCH',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
body,
qs,
);
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (!this.continueOnFail()) {
throw error;
} else {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
}
}
const keysPriorityList = [
'id',
'summary',
'start',
'end',
'attendees',
'creator',
'organizer',
'description',
'location',
'created',
'updated',
];
let nodeExecutionData = returnData;
if (nodeVersion >= 1.3) {
nodeExecutionData = sortItemKeysByPriorityList(returnData, keysPriorityList);
}
if (hints.length) {
this.addExecutionHints(...hints);
}
return [nodeExecutionData];
}
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.googleCalendarTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googlecalendartrigger/"
}
]
}
}
@@ -0,0 +1,254 @@
import moment from 'moment-timezone';
import type {
IDataObject,
INodeExecutionData,
INodeType,
INodeTypeDescription,
IPollFunctions,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeApiError, NodeOperationError } from 'n8n-workflow';
import {
encodeURIComponentOnce,
getCalendars,
googleApiRequest,
googleApiRequestAllItems,
} from './GenericFunctions';
export class GoogleCalendarTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Calendar Trigger',
name: 'googleCalendarTrigger',
icon: 'file:googleCalendar.svg',
group: ['trigger'],
version: 1,
subtitle: '={{$parameter["triggerOn"]}}',
description: 'Starts the workflow when Google Calendar events occur',
defaults: {
name: 'Google Calendar Trigger',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleCalendarOAuth2Api',
required: true,
},
],
polling: true,
properties: [
{
displayName: 'Calendar',
name: 'calendarId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'Google Calendar to operate on',
modes: [
{
displayName: 'Calendar',
name: 'list',
type: 'list',
placeholder: 'Select a Calendar...',
typeOptions: {
searchListMethod: 'getCalendars',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
// calendar ids are emails. W3C email regex with optional trailing whitespace.
regex:
'(^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:[ \t]+)*$)',
errorMessage: 'Not a valid Google Calendar ID',
},
},
],
extractValue: {
type: 'regex',
regex: '(^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)',
},
placeholder: 'name@google.com',
},
],
},
{
displayName: 'Trigger On',
name: 'triggerOn',
type: 'options',
required: true,
default: '',
options: [
{
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',
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Match Term',
name: 'matchTerm',
type: 'string',
default: '',
description:
'Free text search terms to filter events that match these terms in any field, except for extended properties',
},
],
},
],
};
methods = {
listSearch: {
getCalendars,
},
};
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
const poolTimes = this.getNodeParameter('pollTimes.item', []) as IDataObject[];
const triggerOn = this.getNodeParameter('triggerOn', '') as string;
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendarId', '', { extractValue: true }) as string,
);
const webhookData = this.getWorkflowStaticData('node');
const matchTerm = this.getNodeParameter('options.matchTerm', '') as string;
if (poolTimes.length === 0) {
throw new NodeOperationError(this.getNode(), 'Please set a poll time');
}
if (triggerOn === '') {
throw new NodeOperationError(this.getNode(), 'Please select an event');
}
if (calendarId === '') {
throw new NodeOperationError(this.getNode(), 'Please select a calendar');
}
const now = moment().utc().format();
const startDate = (webhookData.lastTimeChecked as string) || now;
const endDate = now;
const qs: IDataObject = {
showDeleted: false,
};
if (matchTerm !== '') {
qs.q = matchTerm;
}
let events;
if (
triggerOn === 'eventCreated' ||
triggerOn === 'eventUpdated' ||
triggerOn === 'eventCancelled'
) {
Object.assign(qs, {
updatedMin: startDate,
orderBy: 'updated',
showDeleted: triggerOn === 'eventCancelled',
});
} else if (triggerOn === 'eventStarted' || triggerOn === 'eventEnded') {
Object.assign(qs, {
singleEvents: true,
timeMin: moment(startDate).startOf('second').utc().format(),
timeMax: moment(endDate).endOf('second').utc().format(),
orderBy: 'startTime',
});
}
if (this.getMode() === 'manual') {
delete qs.updatedMin;
delete qs.timeMin;
delete qs.timeMax;
qs.maxResults = 1;
events = await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events`,
{},
qs,
);
events = events.items;
} else {
events = await googleApiRequestAllItems.call(
this,
'items',
'GET',
`/calendar/v3/calendars/${calendarId}/events`,
{},
qs,
);
if (triggerOn === 'eventCreated') {
events = events.filter((event: { created: string }) =>
moment(event.created).isBetween(startDate, endDate),
);
} else if (triggerOn === 'eventUpdated' || triggerOn === 'eventCancelled') {
events = events.filter(
(event: { created: string; updated: string }) =>
!moment(moment(event.created).format('YYYY-MM-DDTHH:mm:ss')).isSame(
moment(event.updated).format('YYYY-MM-DDTHH:mm:ss'),
),
);
if (triggerOn === 'eventCancelled') {
events = events.filter((event: { status: string }) => event.status === 'cancelled');
}
} else if (triggerOn === 'eventStarted') {
events = events.filter((event: { start: { dateTime: string } }) =>
moment(event.start.dateTime).isBetween(startDate, endDate, null, '[]'),
);
} else if (triggerOn === 'eventEnded') {
events = events.filter((event: { end: { dateTime: string } }) =>
moment(event.end.dateTime).isBetween(startDate, endDate, null, '[]'),
);
}
}
webhookData.lastTimeChecked = endDate;
if (Array.isArray(events) && events.length) {
return [this.helpers.returnJsonArray(events)];
}
if (this.getMode() === 'manual') {
throw new NodeApiError(this.getNode(), {
message: 'No data with the current filter could be found',
});
}
return null;
}
}
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"available": {
"type": "boolean"
}
},
"version": 1
}
@@ -0,0 +1,94 @@
{
"type": "object",
"properties": {
"created": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"description": {
"type": "string"
},
"end": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"etag": {
"type": "string"
},
"eventType": {
"type": "string"
},
"htmlLink": {
"type": "string"
},
"iCalUID": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"organizer": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"reminders": {
"type": "object",
"properties": {
"useDefault": {
"type": "boolean"
}
}
},
"sequence": {
"type": "integer"
},
"start": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"status": {
"type": "string"
},
"summary": {
"type": "string"
},
"updated": {
"type": "string"
}
},
"version": 4
}
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
},
"version": 1
}
@@ -0,0 +1,100 @@
{
"type": "object",
"properties": {
"created": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"description": {
"type": "string"
},
"end": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"etag": {
"type": "string"
},
"eventType": {
"type": "string"
},
"htmlLink": {
"type": "string"
},
"iCalUID": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"location": {
"type": "string"
},
"organizer": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"reminders": {
"type": "object",
"properties": {
"useDefault": {
"type": "boolean"
}
}
},
"sequence": {
"type": "integer"
},
"start": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"status": {
"type": "string"
},
"summary": {
"type": "string"
},
"updated": {
"type": "string"
}
},
"version": 3
}
@@ -0,0 +1,94 @@
{
"type": "object",
"properties": {
"created": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"description": {
"type": "string"
},
"end": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"etag": {
"type": "string"
},
"eventType": {
"type": "string"
},
"htmlLink": {
"type": "string"
},
"iCalUID": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"organizer": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"reminders": {
"type": "object",
"properties": {
"useDefault": {
"type": "boolean"
}
}
},
"sequence": {
"type": "integer"
},
"start": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"status": {
"type": "string"
},
"summary": {
"type": "string"
},
"updated": {
"type": "string"
}
},
"version": 8
}
@@ -0,0 +1,94 @@
{
"type": "object",
"properties": {
"created": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"end": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"etag": {
"type": "string"
},
"eventType": {
"type": "string"
},
"htmlLink": {
"type": "string"
},
"iCalUID": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"type": "string"
},
"organizer": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"email": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"reminders": {
"type": "object",
"properties": {
"useDefault": {
"type": "boolean"
}
}
},
"sequence": {
"type": "integer"
},
"start": {
"type": "object",
"properties": {
"dateTime": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"status": {
"type": "string"
},
"summary": {
"type": "string"
},
"updated": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 81 82"><use xlink:href="#a" x=".5" y=".5"/><symbol id="a" overflow="visible"><g fill-rule="nonzero" stroke="none"><path d="M61.052 18.947H18.947v42.105h42.105z"/><path fill="#ea4335" d="M61.053 80 80 61.053H61.053z"/><path fill="#fbbc04" d="M80 18.947H61.053v42.105H80z"/><path fill="#34a853" d="M61.052 61.053H18.947V80h42.105z"/><path fill="#188038" d="M0 61.053v12.632A6.314 6.314 0 0 0 6.316 80h12.632V61.053z"/><path fill="#1967d2" d="M80 18.947V6.316A6.314 6.314 0 0 0 73.685 0H61.053v18.947z"/><path fill="#4285f4" d="M61.053 0H6.316A6.314 6.314 0 0 0 0 6.316v54.737h18.947V18.947h42.105V0zM27.584 51.611c-1.574-1.063-2.663-2.616-3.258-4.668l3.653-1.505q.498 1.894 1.737 2.937c1.239 1.043 1.821 1.037 2.989 1.037q1.792 0 3.079-1.089c1.287-1.089 1.29-1.653 1.29-2.774a3.44 3.44 0 0 0-1.358-2.811c-.905-.727-2.042-1.089-3.4-1.089h-2.111v-3.616H32.1q1.752 0 2.953-.947c1.201-.947 1.2-1.495 1.2-2.595q0-1.467-1.074-2.342c-1.074-.875-1.621-.879-2.721-.879q-1.61-.002-2.558.858c-.948.86-1.106 1.301-1.379 2.111l-3.616-1.505c.479-1.358 1.358-2.558 2.647-3.595s2.937-1.558 4.937-1.558q2.22-.002 3.989.858c1.769.86 2.105 1.368 2.774 2.379s1 2.153 1 3.416q0 1.932-.932 3.274c-.932 1.342-1.384 1.579-2.289 2.058v.216a6.95 6.95 0 0 1 2.937 2.289q1.146 1.538 1.147 3.684c.001 2.146-.363 2.711-1.089 3.832s-1.732 2.005-3.005 2.647c-1.279.642-2.716.968-4.311.968-1.847.005-3.553-.526-5.126-1.589zm22.437-18.126-4.01 2.9-2.005-3.042 7.195-5.189h2.758v24.479h-3.937V33.484z"/></g></symbol></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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' }] },
{},
);
});
});
});