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
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:
@@ -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 n8n’s 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;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"available": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
+94
@@ -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
|
||||
}
|
||||
+94
@@ -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
|
||||
}
|
||||
+94
@@ -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¶m=value';
|
||||
const result = encodeURIComponentOnce(uri);
|
||||
expect(result).toBe('test%2Bcalendar%26param%3Dvalue');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const uri = '';
|
||||
const result = encodeURIComponentOnce(uri);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCalendars', () => {
|
||||
let mockLoadOptionsFunctions: jest.Mocked<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' }] },
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user