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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
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://people.googleapis.com/v1${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, 'googleContactsOAuth2Api', options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function googleApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
query.pageSize = 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 const allFields = [
'addresses',
'biographies',
'birthdays',
'coverPhotos',
'emailAddresses',
'events',
'genders',
'imClients',
'interests',
'locales',
'memberships',
'metadata',
'names',
'nicknames',
'occupations',
'organizations',
'phoneNumbers',
'photos',
'relations',
'residences',
'sipAddresses',
'skills',
'urls',
'userDefined',
];
export function cleanData(responseData: any) {
const fields = ['emailAddresses', 'phoneNumbers', 'relations', 'events', 'addresses'];
const newResponseData = [];
if (!Array.isArray(responseData)) {
responseData = [responseData];
}
for (let y = 0; y < responseData.length; y++) {
const object: { [key: string]: any } = {};
for (const key of Object.keys(responseData[y] as IDataObject)) {
if (key === 'metadata') {
continue;
}
if (key === 'photos') {
responseData[y][key] = responseData[y][key].map((photo: IDataObject) => photo.url);
}
if (key === 'names') {
delete responseData[y][key][0].metadata;
responseData[y][key] = responseData[y][key][0];
}
if (key === 'memberships') {
for (let i = 0; i < responseData[y][key].length; i++) {
responseData[y][key][i] = responseData[y][key][i].metadata.source.id;
}
}
if (key === 'birthdays') {
for (let i = 0; i < responseData[y][key].length; i++) {
const { year, month, day } = responseData[y][key][i].date;
responseData[y][key][i] = `${month}/${day}/${year}`;
}
responseData[y][key] = responseData[y][key][0];
}
if (key === 'userDefined' || key === 'organizations' || key === 'biographies') {
for (let i = 0; i < responseData[y][key].length; i++) {
delete responseData[y][key][i].metadata;
}
}
if (fields.includes(key)) {
const value: { [key: string]: any } = {};
for (const data of responseData[y][key]) {
let result;
if (value[data.type] === undefined) {
value[data.type] = [];
}
if (key === 'relations') {
result = data.person;
} else if (key === 'events') {
const { year, month, day } = data.date;
result = `${month}/${day}/${year}`;
} else if (key === 'addresses') {
delete data.metadata;
result = data;
} else {
result = data.value;
}
value[data.type].push(result);
delete data.type;
}
if (Object.keys(value).length > 0) {
object[key] = value;
}
} else {
object[key] = responseData[y][key];
}
}
newResponseData.push(object);
}
return newResponseData;
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.googleContacts",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Miscellaneous "],
"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.googlecontacts/"
}
]
}
}
@@ -0,0 +1,540 @@
import moment from 'moment-timezone';
import type {
IExecuteFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { contactFields, contactOperations } from './ContactDescription';
import {
allFields,
cleanData,
googleApiRequest,
googleApiRequestAllItems,
} from './GenericFunctions';
export class GoogleContacts implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Contacts',
name: 'googleContacts',
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
icon: 'file:googleContacts.png',
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Contacts API',
schemaPath: 'Google/Contacts',
defaults: {
name: 'Google Contacts',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleContactsOAuth2Api',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Contact',
value: 'contact',
},
],
default: 'contact',
},
...contactOperations,
...contactFields,
],
};
methods = {
loadOptions: {
// Get all the calendars to display them to user so that they can
// select them easily
async getGroups(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const groups = await googleApiRequestAllItems.call(
this,
'contactGroups',
'GET',
'/contactGroups',
);
for (const group of groups) {
const groupName = group.name;
const groupId = group.resourceName;
returnData.push({
name: groupName,
value: groupId,
});
}
return returnData;
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const length = items.length;
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
// Warmup cache
// https://developers.google.com/people/v1/contacts#protocol_1
if (resource === 'contact' && operation === 'getAll') {
await googleApiRequest.call(this, 'GET', '/people:searchContacts', undefined, {
query: '',
readMask: 'names',
});
await googleApiRequest.call(this, 'GET', '/people/me/connections', undefined, {
personFields: 'names',
});
}
for (let i = 0; i < length; i++) {
try {
if (resource === 'contact') {
//https://developers.google.com/calendar/v3/reference/events/insert
if (operation === 'create') {
const familyName = this.getNodeParameter('familyName', i) as string;
const givenName = this.getNodeParameter('givenName', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
const body: IDataObject = {
names: [
{
familyName,
givenName,
middleName: '',
},
],
};
if (additionalFields.middleName) {
//@ts-ignore
body.names[0].middleName = additionalFields.middleName as string;
}
if (additionalFields.honorificPrefix) {
//@ts-ignore
body.names[0].honorificPrefix = additionalFields.honorificPrefix as string;
}
if (additionalFields.honorificSuffix) {
//@ts-ignore
body.names[0].honorificSuffix = additionalFields.honorificSuffix as string;
}
if (additionalFields.companyUi) {
const companyValues = (additionalFields.companyUi as IDataObject)
.companyValues as IDataObject[];
body.organizations = companyValues;
}
if (additionalFields.phoneUi) {
const phoneValues = (additionalFields.phoneUi as IDataObject)
.phoneValues as IDataObject[];
body.phoneNumbers = phoneValues;
}
if (additionalFields.addressesUi) {
const addressesValues = (additionalFields.addressesUi as IDataObject)
.addressesValues as IDataObject[];
body.addresses = addressesValues;
}
if (additionalFields.relationsUi) {
const relationsValues = (additionalFields.relationsUi as IDataObject)
.relationsValues as IDataObject[];
body.relations = relationsValues;
}
if (additionalFields.eventsUi) {
const eventsValues = (additionalFields.eventsUi as IDataObject)
.eventsValues as IDataObject[];
for (let index = 0; index < eventsValues.length; index++) {
const [month, day, year] = moment(eventsValues[index].date as string)
.format('MM/DD/YYYY')
.split('/');
eventsValues[index] = {
date: {
day,
month,
year,
},
type: eventsValues[index].type,
};
}
body.events = eventsValues;
}
if (additionalFields.birthday) {
const [month, day, year] = moment(additionalFields.birthday as string)
.format('MM/DD/YYYY')
.split('/');
body.birthdays = [
{
date: {
day,
month,
year,
},
},
];
}
if (additionalFields.emailsUi) {
const emailsValues = (additionalFields.emailsUi as IDataObject)
.emailsValues as IDataObject[];
body.emailAddresses = emailsValues;
}
if (additionalFields.biographies) {
body.biographies = [
{
value: additionalFields.biographies,
contentType: 'TEXT_PLAIN',
},
];
}
if (additionalFields.customFieldsUi) {
const customFieldsValues = (additionalFields.customFieldsUi as IDataObject)
.customFieldsValues as IDataObject[];
body.userDefined = customFieldsValues;
}
if (additionalFields.group) {
const memberships = (additionalFields.group as string[]).map((groupId: string) => {
return {
contactGroupMembership: {
contactGroupResourceName: groupId,
},
};
});
body.memberships = memberships;
}
responseData = await googleApiRequest.call(
this,
'POST',
'/people:createContact',
body,
qs,
);
responseData.contactId = responseData.resourceName.split('/')[1];
}
//https://developers.google.com/people/api/rest/v1/people/deleteContact
if (operation === 'delete') {
const contactId = this.getNodeParameter('contactId', i) as string;
responseData = await googleApiRequest.call(
this,
'DELETE',
`/people/${contactId}:deleteContact`,
{},
);
responseData = { success: true };
}
//https://developers.google.com/people/api/rest/v1/people/get
if (operation === 'get') {
const contactId = this.getNodeParameter('contactId', i) as string;
const fields = this.getNodeParameter('fields', i) as string[];
const rawData = this.getNodeParameter('rawData', i);
if (fields.includes('*')) {
qs.personFields = allFields.join(',');
} else {
qs.personFields = fields.join(',');
}
responseData = await googleApiRequest.call(this, 'GET', `/people/${contactId}`, {}, qs);
if (!rawData) {
responseData = cleanData(responseData)[0];
}
responseData.contactId = responseData.resourceName.split('/')[1];
}
//https://developers.google.com/people/api/rest/v1/people.connections/list
//https://developers.google.com/people/api/rest/v1/people/searchContacts
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i);
const fields = this.getNodeParameter('fields', i) as string[];
const options = this.getNodeParameter('options', i, {});
const rawData = this.getNodeParameter('rawData', i);
const useQuery = this.getNodeParameter('useQuery', i) as boolean;
const endpoint = useQuery ? ':searchContacts' : '/me/connections';
if (useQuery) {
qs.query = this.getNodeParameter('query', i) as string;
}
if (options.sortOrder) {
qs.sortOrder = options.sortOrder as number;
}
if (fields.includes('*')) {
qs.personFields = allFields.join(',');
} else {
qs.personFields = fields.join(',');
}
if (useQuery) {
qs.readMask = qs.personFields;
delete qs.personFields;
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
useQuery ? 'results' : 'connections',
'GET',
`/people${endpoint}`,
{},
qs,
);
if (useQuery) {
responseData = responseData.map((result: IDataObject) => result.person);
}
} else {
qs.pageSize = this.getNodeParameter('limit', i);
responseData = await googleApiRequest.call(this, 'GET', `/people${endpoint}`, {}, qs);
responseData =
responseData.connections ||
responseData.results?.map((result: IDataObject) => result.person) ||
[];
}
if (!rawData) {
responseData = cleanData(responseData);
}
for (let index = 0; index < responseData.length; index++) {
responseData[index].contactId = responseData[index].resourceName.split('/')[1];
}
}
//https://developers.google.com/people/api/rest/v1/people/updateContact
if (operation === 'update') {
const updatePersonFields = [];
const contactId = this.getNodeParameter('contactId', i) as string;
const fields = this.getNodeParameter('fields', i) as string[];
const updateFields = this.getNodeParameter('updateFields', i);
let etag;
if (updateFields.etag) {
etag = updateFields.etag as string;
} else {
const data = await googleApiRequest.call(
this,
'GET',
`/people/${contactId}`,
{},
{ personFields: 'Names' },
);
etag = data.etag;
}
if (fields.includes('*')) {
qs.personFields = allFields.join(',');
} else {
qs.personFields = fields.join(',');
}
const body: IDataObject = {
etag,
names: [{}],
};
if (updateFields.givenName) {
//@ts-ignore
body.names[0].givenName = updateFields.givenName as string;
}
if (updateFields.familyName) {
//@ts-ignore
body.names[0].familyName = updateFields.familyName as string;
}
if (updateFields.middleName) {
//@ts-ignore
body.names[0].middleName = updateFields.middleName as string;
}
if (updateFields.honorificPrefix) {
//@ts-ignore
body.names[0].honorificPrefix = updateFields.honorificPrefix as string;
}
if (updateFields.honorificSuffix) {
//@ts-ignore
body.names[0].honorificSuffix = updateFields.honorificSuffix as string;
}
if (updateFields.companyUi) {
const companyValues = (updateFields.companyUi as IDataObject)
.companyValues as IDataObject[];
body.organizations = companyValues;
updatePersonFields.push('organizations');
}
if (updateFields.phoneUi) {
const phoneValues = (updateFields.phoneUi as IDataObject)
.phoneValues as IDataObject[];
body.phoneNumbers = phoneValues;
updatePersonFields.push('phoneNumbers');
}
if (updateFields.addressesUi) {
const addressesValues = (updateFields.addressesUi as IDataObject)
.addressesValues as IDataObject[];
body.addresses = addressesValues;
updatePersonFields.push('addresses');
}
if (updateFields.relationsUi) {
const relationsValues = (updateFields.relationsUi as IDataObject)
.relationsValues as IDataObject[];
body.relations = relationsValues;
updatePersonFields.push('relations');
}
if (updateFields.eventsUi) {
const eventsValues = (updateFields.eventsUi as IDataObject)
.eventsValues as IDataObject[];
for (let index = 0; index < eventsValues.length; index++) {
const [month, day, year] = moment(eventsValues[index].date as string)
.format('MM/DD/YYYY')
.split('/');
eventsValues[index] = {
date: {
day,
month,
year,
},
type: eventsValues[index].type,
};
}
body.events = eventsValues;
updatePersonFields.push('events');
}
if (updateFields.birthday) {
const [month, day, year] = moment(updateFields.birthday as string)
.format('MM/DD/YYYY')
.split('/');
body.birthdays = [
{
date: {
day,
month,
year,
},
},
];
updatePersonFields.push('birthdays');
}
if (updateFields.emailsUi) {
const emailsValues = (updateFields.emailsUi as IDataObject)
.emailsValues as IDataObject[];
body.emailAddresses = emailsValues;
updatePersonFields.push('emailAddresses');
}
if (updateFields.biographies) {
body.biographies = [
{
value: updateFields.biographies,
contentType: 'TEXT_PLAIN',
},
];
updatePersonFields.push('biographies');
}
if (updateFields.customFieldsUi) {
const customFieldsValues = (updateFields.customFieldsUi as IDataObject)
.customFieldsValues as IDataObject[];
body.userDefined = customFieldsValues;
updatePersonFields.push('userDefined');
}
if (updateFields.group) {
const memberships = (updateFields.group as string[]).map((groupId: string) => {
return {
contactGroupMembership: {
contactGroupResourceName: groupId,
},
};
});
body.memberships = memberships;
updatePersonFields.push('memberships');
}
if ((body.names as IDataObject[]).length > 0) {
updatePersonFields.push('names');
}
qs.updatePersonFields = updatePersonFields.join(',');
responseData = await googleApiRequest.call(
this,
'PATCH',
`/people/${contactId}:updateContact`,
body,
qs,
);
responseData.contactId = responseData.resourceName.split('/')[1];
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return [returnData];
}
}
@@ -0,0 +1,278 @@
{
"type": "object",
"properties": {
"contactId": {
"type": "string"
},
"coverPhotos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"default": {
"type": "boolean"
},
"metadata": {
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
},
"url": {
"type": "string"
}
}
}
},
"emailAddresses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"formattedType": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
},
"type": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"etag": {
"type": "string"
},
"memberships": {
"type": "array",
"items": {
"type": "object",
"properties": {
"contactGroupMembership": {
"type": "object",
"properties": {
"contactGroupId": {
"type": "string"
},
"contactGroupResourceName": {
"type": "string"
}
}
},
"metadata": {
"type": "object",
"properties": {
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
}
}
}
},
"metadata": {
"type": "object",
"properties": {
"objectType": {
"type": "string"
},
"sources": {
"type": "array",
"items": {
"type": "object",
"properties": {
"etag": {
"type": "string"
},
"id": {
"type": "string"
},
"profileMetadata": {
"type": "object",
"properties": {
"objectType": {
"type": "string"
},
"userTypes": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"type": {
"type": "string"
},
"updateTime": {
"type": "string"
}
}
}
}
}
},
"names": {
"type": "array",
"items": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"displayNameLastFirst": {
"type": "string"
},
"familyName": {
"type": "string"
},
"givenName": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"sourcePrimary": {
"type": "boolean"
}
}
},
"unstructuredName": {
"type": "string"
}
}
}
},
"phoneNumbers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"canonicalForm": {
"type": "string"
},
"formattedType": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
},
"type": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"photos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"default": {
"type": "boolean"
},
"metadata": {
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
},
"url": {
"type": "string"
}
}
}
},
"resourceName": {
"type": "string"
}
},
"version": 4
}
@@ -0,0 +1,35 @@
{
"type": "object",
"properties": {
"contactId": {
"type": "string"
},
"etag": {
"type": "string"
},
"names": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"displayNameLastFirst": {
"type": "string"
},
"familyName": {
"type": "string"
},
"givenName": {
"type": "string"
},
"unstructuredName": {
"type": "string"
}
}
},
"resourceName": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,46 @@
{
"type": "object",
"properties": {
"contactId": {
"type": "string"
},
"etag": {
"type": "string"
},
"names": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"displayNameLastFirst": {
"type": "string"
},
"familyName": {
"type": "string"
},
"givenName": {
"type": "string"
},
"unstructuredName": {
"type": "string"
}
}
},
"phoneNumbers": {
"type": "object",
"properties": {
"mobile": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"resourceName": {
"type": "string"
}
},
"version": 6
}
@@ -0,0 +1,150 @@
{
"type": "object",
"properties": {
"contactId": {
"type": "string"
},
"etag": {
"type": "string"
},
"memberships": {
"type": "array",
"items": {
"type": "object",
"properties": {
"contactGroupMembership": {
"type": "object",
"properties": {
"contactGroupId": {
"type": "string"
},
"contactGroupResourceName": {
"type": "string"
}
}
},
"metadata": {
"type": "object",
"properties": {
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
}
}
}
},
"metadata": {
"type": "object",
"properties": {
"objectType": {
"type": "string"
},
"sources": {
"type": "array",
"items": {
"type": "object",
"properties": {
"etag": {
"type": "string"
},
"id": {
"type": "string"
},
"type": {
"type": "string"
},
"updateTime": {
"type": "string"
}
}
}
}
}
},
"organizations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"current": {
"type": "boolean"
},
"domain": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
},
"name": {
"type": "string"
},
"title": {
"type": "string"
}
}
}
},
"photos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"default": {
"type": "boolean"
},
"metadata": {
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"source": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
},
"url": {
"type": "string"
}
}
}
},
"resourceName": {
"type": "string"
}
},
"version": 1
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,787 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import * as GenericFunctions from '../GenericFunctions';
describe('Google Contacts GenericFunctions', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;
let mockNode: INode;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
mockNode = {
id: 'test-node-id',
name: 'Google Contacts Test',
type: 'n8n-nodes-base.googleContacts',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
jest.clearAllMocks();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockLoadOptionsFunctions.getNode.mockReturnValue(mockNode);
// Properly mock the requestOAuth2 helper
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock) = jest.fn();
(mockLoadOptionsFunctions.helpers.requestOAuth2 as jest.Mock) = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
describe('googleApiRequest', () => {
it('should make successful API request with default parameters', async () => {
const mockResponse = { id: 'person123', names: [{ displayName: 'John Doe' }] };
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(mockResponse);
const result = await GenericFunctions.googleApiRequest.call(
mockExecuteFunctions,
'GET',
'/people/me',
);
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledWith(
'googleContactsOAuth2Api',
{
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
qs: {},
uri: 'https://people.googleapis.com/v1/people/me',
json: true,
},
);
expect(result).toEqual(mockResponse);
});
it('should make API request with custom URI', async () => {
const customUri = 'https://custom.api.com/endpoint';
const mockResponse = { data: 'custom response' };
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(mockResponse);
const result = await GenericFunctions.googleApiRequest.call(
mockExecuteFunctions,
'POST',
'/custom',
{ data: 'test' },
{ param: 'value' },
customUri,
);
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledWith(
'googleContactsOAuth2Api',
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: { data: 'test' },
qs: { param: 'value' },
uri: customUri,
json: true,
},
);
expect(result).toEqual(mockResponse);
});
it('should include custom headers when provided', async () => {
const customHeaders = { 'X-Custom-Header': 'custom-value' };
const mockResponse = { success: true };
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(mockResponse);
await GenericFunctions.googleApiRequest.call(
mockExecuteFunctions,
'PUT',
'/people/123',
{ name: 'Updated Name' },
{},
undefined,
customHeaders,
);
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledWith(
'googleContactsOAuth2Api',
{
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value',
},
method: 'PUT',
body: { name: 'Updated Name' },
qs: {},
uri: 'https://people.googleapis.com/v1/people/123',
json: true,
},
);
});
it('should remove empty body from request options', async () => {
const mockResponse = { data: 'response' };
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(mockResponse);
await GenericFunctions.googleApiRequest.call(mockExecuteFunctions, 'GET', '/people', {});
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledWith(
'googleContactsOAuth2Api',
expect.objectContaining({
method: 'GET',
qs: {},
uri: 'https://people.googleapis.com/v1/people',
json: true,
headers: {
'Content-Type': 'application/json',
},
}),
);
const callArgs = (mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mock.calls[0][1];
expect(callArgs).not.toHaveProperty('body');
});
it('should work with ILoadOptionsFunctions context', async () => {
const mockResponse = { connections: [] };
(mockLoadOptionsFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(mockResponse);
const result = await GenericFunctions.googleApiRequest.call(
mockLoadOptionsFunctions,
'GET',
'/people/connections',
);
expect(mockLoadOptionsFunctions.helpers.requestOAuth2).toHaveBeenCalledWith(
'googleContactsOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://people.googleapis.com/v1/people/connections',
}),
);
expect(result).toEqual(mockResponse);
});
it('should throw NodeApiError on request failure', async () => {
const apiError = new Error('API Error');
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockRejectedValue(apiError);
await expect(
GenericFunctions.googleApiRequest.call(mockExecuteFunctions, 'GET', '/people/invalid'),
).rejects.toThrow(NodeApiError);
expect(mockExecuteFunctions.getNode).toHaveBeenCalled();
});
it('should handle authentication errors', async () => {
const authError = { code: 401, message: 'Unauthorized' };
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockRejectedValue(authError);
await expect(
GenericFunctions.googleApiRequest.call(mockExecuteFunctions, 'POST', '/people'),
).rejects.toThrow(NodeApiError);
});
it('should handle network errors', async () => {
const networkError = { code: 'ECONNREFUSED', message: 'Connection refused' };
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockRejectedValue(networkError);
await expect(
GenericFunctions.googleApiRequest.call(mockExecuteFunctions, 'GET', '/people'),
).rejects.toThrow(NodeApiError);
});
it('should preserve query string parameters', async () => {
const mockResponse = { data: 'filtered results' };
const queryParams = { personFields: 'names,emailAddresses', pageSize: '50' };
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(mockResponse);
await GenericFunctions.googleApiRequest.call(
mockExecuteFunctions,
'GET',
'/people/connections',
{},
queryParams,
);
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledWith(
'googleContactsOAuth2Api',
expect.objectContaining({
qs: queryParams,
}),
);
});
});
describe('googleApiRequestAllItems', () => {
it('should fetch all items with pagination', async () => {
// Mock the requestOAuth2 helper to simulate pagination responses
let callCount = 0;
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockImplementation(
async (_, options) => {
callCount++;
if (callCount === 1) {
// First call should not have pageToken
expect(options.qs.pageToken).toBeUndefined();
return {
connections: [
{ resourceName: 'people/1', names: [{ displayName: 'John' }] },
{ resourceName: 'people/2', names: [{ displayName: 'Jane' }] },
],
nextPageToken: 'token123',
};
} else {
// Second call should have pageToken from first response
expect(options.qs.pageToken).toBe('token123');
return {
connections: [{ resourceName: 'people/3', names: [{ displayName: 'Bob' }] }],
nextPageToken: '',
};
}
},
);
const result = await GenericFunctions.googleApiRequestAllItems.call(
mockExecuteFunctions,
'connections',
'GET',
'/people/connections',
{},
{ personFields: 'names' },
);
expect(result).toHaveLength(3);
expect(result[0].resourceName).toBe('people/1');
expect(result[1].resourceName).toBe('people/2');
expect(result[2].resourceName).toBe('people/3');
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledTimes(2);
});
it('should handle single page response with no pagination', async () => {
const singlePageResponse = {
connections: [{ resourceName: 'people/1', names: [{ displayName: 'Only Contact' }] }],
};
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(
singlePageResponse,
);
const result = await GenericFunctions.googleApiRequestAllItems.call(
mockExecuteFunctions,
'connections',
'GET',
'/people/connections',
);
expect(result).toHaveLength(1);
expect(result[0].resourceName).toBe('people/1');
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledTimes(1);
});
it('should handle empty response', async () => {
const emptyResponse = {
connections: [],
};
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(emptyResponse);
const result = await GenericFunctions.googleApiRequestAllItems.call(
mockExecuteFunctions,
'connections',
'GET',
'/people/connections',
);
expect(result).toEqual([]);
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledTimes(1);
});
it('should work with ILoadOptionsFunctions context', async () => {
const mockResponse = {
items: [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
],
};
(mockLoadOptionsFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(mockResponse);
const result = await GenericFunctions.googleApiRequestAllItems.call(
mockLoadOptionsFunctions,
'items',
'GET',
'/custom/endpoint',
{ filter: 'active' },
{ sortBy: 'name' },
);
expect(result).toHaveLength(2);
expect(mockLoadOptionsFunctions.helpers.requestOAuth2).toHaveBeenCalledWith(
'googleContactsOAuth2Api',
expect.objectContaining({
body: { filter: 'active' },
qs: expect.objectContaining({
sortBy: 'name',
pageSize: 100,
}),
}),
);
});
it('should handle pagination with undefined nextPageToken', async () => {
const responseWithUndefinedToken = {
connections: [{ resourceName: 'people/1', names: [{ displayName: 'Contact' }] }],
nextPageToken: undefined,
};
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockResolvedValue(
responseWithUndefinedToken,
);
const result = await GenericFunctions.googleApiRequestAllItems.call(
mockExecuteFunctions,
'connections',
'GET',
'/people/connections',
);
expect(result).toHaveLength(1);
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledTimes(1);
});
it('should handle large datasets with multiple pages', async () => {
const createResponse = (pageNum: number, hasNext: boolean) => ({
connections: Array.from({ length: 10 }, (_, i) => ({
resourceName: `people/${pageNum * 10 + i + 1}`,
names: [{ displayName: `Contact ${pageNum * 10 + i + 1}` }],
})),
nextPageToken: hasNext ? `token${pageNum + 1}` : '',
});
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock)
.mockResolvedValueOnce(createResponse(0, true))
.mockResolvedValueOnce(createResponse(1, true))
.mockResolvedValueOnce(createResponse(2, false));
const result = await GenericFunctions.googleApiRequestAllItems.call(
mockExecuteFunctions,
'connections',
'GET',
'/people/connections',
);
expect(result).toHaveLength(30);
expect(mockExecuteFunctions.helpers.requestOAuth2).toHaveBeenCalledTimes(3);
});
it('should propagate errors from underlying API requests', async () => {
const apiError = new Error('Rate limit exceeded');
(mockExecuteFunctions.helpers.requestOAuth2 as jest.Mock).mockRejectedValue(apiError);
await expect(
GenericFunctions.googleApiRequestAllItems.call(
mockExecuteFunctions,
'connections',
'GET',
'/people/connections',
),
).rejects.toThrow('Rate limit exceeded');
});
});
describe('allFields', () => {
it('should contain all expected field names', () => {
const expectedFields = [
'addresses',
'biographies',
'birthdays',
'coverPhotos',
'emailAddresses',
'events',
'genders',
'imClients',
'interests',
'locales',
'memberships',
'metadata',
'names',
'nicknames',
'occupations',
'organizations',
'phoneNumbers',
'photos',
'relations',
'residences',
'sipAddresses',
'skills',
'urls',
'userDefined',
];
expect(GenericFunctions.allFields).toEqual(expectedFields);
});
it('should be a read-only array', () => {
expect(Array.isArray(GenericFunctions.allFields)).toBe(true);
expect(GenericFunctions.allFields).toHaveLength(24);
});
it('should contain unique field names', () => {
const uniqueFields = [...new Set(GenericFunctions.allFields)];
expect(uniqueFields).toHaveLength(GenericFunctions.allFields.length);
});
});
describe('cleanData', () => {
it('should clean single contact data correctly', () => {
const rawData = {
resourceName: 'people/123',
metadata: {
sources: [{ type: 'CONTACT', id: '123' }],
},
names: [
{
metadata: { primary: true, verified: true },
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
},
],
emailAddresses: [
{
metadata: { primary: true },
value: 'john@example.com',
type: 'work',
},
{
metadata: { primary: false },
value: 'john.personal@example.com',
type: 'home',
},
],
phoneNumbers: [
{
metadata: { primary: true },
value: '+1234567890',
type: 'mobile',
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('metadata');
expect(result[0].names).toEqual({
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
});
expect(result[0].emailAddresses).toEqual({
work: ['john@example.com'],
home: ['john.personal@example.com'],
});
expect(result[0].phoneNumbers).toEqual({
mobile: ['+1234567890'],
});
});
it('should handle array input', () => {
const rawData = [
{
resourceName: 'people/1',
names: [{ displayName: 'Person 1', metadata: { primary: true } }],
},
{
resourceName: 'people/2',
names: [{ displayName: 'Person 2', metadata: { primary: true } }],
},
];
const result = GenericFunctions.cleanData(rawData);
expect(result).toHaveLength(2);
expect(result[0].names.displayName).toBe('Person 1');
expect(result[1].names.displayName).toBe('Person 2');
});
it('should clean photos field correctly', () => {
const rawData = {
resourceName: 'people/123',
photos: [
{ url: 'https://example.com/photo1.jpg', metadata: { primary: true } },
{ url: 'https://example.com/photo2.jpg', metadata: { primary: false } },
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].photos).toEqual([
'https://example.com/photo1.jpg',
'https://example.com/photo2.jpg',
]);
});
it('should clean memberships field correctly', () => {
const rawData = {
resourceName: 'people/123',
memberships: [
{
metadata: {
source: { type: 'CONTACT', id: 'group1' },
},
contactGroupMembership: { contactGroupId: 'group1' },
},
{
metadata: {
source: { type: 'CONTACT', id: 'group2' },
},
contactGroupMembership: { contactGroupId: 'group2' },
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].memberships).toEqual(['group1', 'group2']);
});
it('should clean birthdays field correctly', () => {
const rawData = {
resourceName: 'people/123',
birthdays: [
{
metadata: { primary: true },
date: { year: 1990, month: 6, day: 15 },
},
{
metadata: { primary: false },
date: { year: 1985, month: 12, day: 25 },
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].birthdays).toBe('6/15/1990');
});
it('should clean userDefined, organizations, and biographies fields', () => {
const rawData = {
resourceName: 'people/123',
userDefined: [
{
metadata: { primary: true },
key: 'customField',
value: 'customValue',
},
],
organizations: [
{
metadata: { primary: true },
name: 'Company Name',
title: 'Job Title',
},
],
biographies: [
{
metadata: { primary: true },
value: 'This is a biography',
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].userDefined[0]).not.toHaveProperty('metadata');
expect(result[0].organizations[0]).not.toHaveProperty('metadata');
expect(result[0].biographies[0]).not.toHaveProperty('metadata');
expect(result[0].userDefined[0].key).toBe('customField');
expect(result[0].organizations[0].name).toBe('Company Name');
expect(result[0].biographies[0].value).toBe('This is a biography');
});
it('should handle relations field correctly', () => {
const rawData = {
resourceName: 'people/123',
relations: [
{
metadata: { primary: true },
person: 'Jane Doe',
type: 'spouse',
},
{
metadata: { primary: false },
person: 'John Doe Sr.',
type: 'father',
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].relations).toEqual({
spouse: ['Jane Doe'],
father: ['John Doe Sr.'],
});
});
it('should handle events field correctly', () => {
const rawData = {
resourceName: 'people/123',
events: [
{
metadata: { primary: true },
date: { year: 2020, month: 6, day: 15 },
type: 'anniversary',
},
{
metadata: { primary: false },
date: { year: 2021, month: 12, day: 25 },
type: 'anniversary',
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].events).toEqual({
anniversary: ['6/15/2020', '12/25/2021'],
});
});
it('should handle addresses field correctly', () => {
const rawData = {
resourceName: 'people/123',
addresses: [
{
metadata: { primary: true },
formattedValue: '123 Main St, City, State 12345',
type: 'home',
streetAddress: '123 Main St',
city: 'City',
region: 'State',
postalCode: '12345',
},
{
metadata: { primary: false },
formattedValue: '456 Work Ave, Work City, Work State 67890',
type: 'work',
streetAddress: '456 Work Ave',
city: 'Work City',
region: 'Work State',
postalCode: '67890',
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].addresses.home).toHaveLength(1);
expect(result[0].addresses.work).toHaveLength(1);
expect(result[0].addresses.home[0]).not.toHaveProperty('metadata');
expect(result[0].addresses.home[0].formattedValue).toBe('123 Main St, City, State 12345');
expect(result[0].addresses.work[0].streetAddress).toBe('456 Work Ave');
});
it('should handle empty data gracefully', () => {
const rawData = {
resourceName: 'people/123',
metadata: { sources: [] },
};
const result = GenericFunctions.cleanData(rawData);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ resourceName: 'people/123' });
});
it('should handle missing fields gracefully', () => {
const rawData = {
resourceName: 'people/123',
someOtherField: 'value',
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0]).toEqual({
resourceName: 'people/123',
someOtherField: 'value',
});
});
it('should skip fields that result in empty objects', () => {
const rawData = {
resourceName: 'people/123',
emailAddresses: [], // Empty array should not create emailAddresses field
phoneNumbers: [
{
metadata: { primary: true },
value: '+1234567890',
type: 'mobile',
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0]).not.toHaveProperty('emailAddresses');
expect(result[0]).toHaveProperty('phoneNumbers');
});
it('should handle complex nested data structures', () => {
const rawData = {
resourceName: 'people/123',
names: [
{
metadata: { primary: true, verified: true, source: { type: 'CONTACT' } },
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
middleName: 'Michael',
honorificPrefix: 'Mr.',
honorificSuffix: 'Jr.',
},
],
emailAddresses: [
{
metadata: { primary: true, verified: true },
value: 'john@example.com',
type: 'work',
displayName: 'John Work Email',
},
],
addresses: [
{
metadata: { primary: true },
type: 'home',
formattedValue: '123 Main St\nAnytown, ST 12345\nUSA',
streetAddress: '123 Main St',
city: 'Anytown',
region: 'ST',
postalCode: '12345',
country: 'USA',
countryCode: 'US',
},
],
};
const result = GenericFunctions.cleanData(rawData);
expect(result[0].names).toEqual({
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
middleName: 'Michael',
honorificPrefix: 'Mr.',
honorificSuffix: 'Jr.',
});
expect(result[0].emailAddresses.work[0]).toBe('john@example.com');
expect(result[0].addresses.home[0]).not.toHaveProperty('metadata');
expect(result[0].addresses.home[0].country).toBe('USA');
});
});
});
@@ -0,0 +1,437 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import { jsonParse } from 'n8n-workflow';
import nock from 'nock';
describe('Google Contacts', () => {
const credentials = {
googleContactsOAuth2Api: {
scope: 'https://www.googleapis.com/auth/contacts',
oauthTokenData: {
access_token: 'test-access-token',
},
},
};
describe('Contact Create Operation', () => {
beforeAll(() => {
const mock = nock('https://people.googleapis.com/v1');
// Mock successful contact creation - handle all variations
mock
.post('/people:createContact')
.reply(function (_, requestBody: any) {
const parsedBody = typeof requestBody === 'string' ? jsonParse(requestBody) : requestBody;
if (parsedBody.names && parsedBody.names[0].givenName === 'John') {
return [
201,
{
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
displayNameLastFirst: 'Doe, John',
},
],
},
];
}
// For any other create request (Jane or any other contact)
return [
201,
{
resourceName: 'people/c987654321',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c987654321' },
},
displayName: 'Dr. Jane Marie Smith Jr.',
familyName: 'Smith',
givenName: 'Jane',
middleName: 'Marie',
honorificPrefix: 'Dr.',
honorificSuffix: 'Jr.',
},
],
emailAddresses: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c987654321' } },
value: 'jane@example.com',
type: 'work',
},
],
phoneNumbers: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c987654321' } },
value: '+1234567890',
type: 'mobile',
},
],
},
];
})
.persist();
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['contact-create-basic.workflow.json', 'contact-create-full.workflow.json'],
});
});
describe('Contact Delete Operation', () => {
beforeAll(() => {
const mock = nock('https://people.googleapis.com/v1');
// Mock successful contact deletion
mock.delete('/people/c123456789:deleteContact').reply(200, {});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['contact-delete.workflow.json'],
});
});
describe('Contact Get Operation', () => {
beforeAll(() => {
const mock = nock('https://people.googleapis.com/v1');
// Mock get contact with specific fields
mock
.get('/people/c123456789')
.query({
personFields: 'names,emailAddresses,phoneNumbers',
})
.reply(200, {
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
},
],
emailAddresses: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
value: 'john@example.com',
type: 'work',
},
],
phoneNumbers: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
value: '+1234567890',
type: 'mobile',
},
],
});
// Mock get contact with all fields
mock
.get('/people/c123456789')
.query({
personFields:
'addresses,biographies,birthdays,coverPhotos,emailAddresses,events,genders,imClients,interests,locales,memberships,metadata,names,nicknames,occupations,organizations,phoneNumbers,photos,relations,residences,sipAddresses,skills,urls,userDefined',
})
.reply(200, {
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
},
],
emailAddresses: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
value: 'john@example.com',
type: 'work',
},
],
phoneNumbers: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
value: '+1234567890',
type: 'mobile',
},
],
addresses: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
type: 'home',
formattedValue: '123 Main St\nAnytown, CA 12345',
streetAddress: '123 Main St',
city: 'Anytown',
region: 'CA',
postalCode: '12345',
},
],
organizations: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
name: 'Example Corp',
title: 'Software Engineer',
current: true,
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['contact-get-fields.workflow.json', 'contact-get-all.workflow.json'],
});
});
describe('Contact Get All Operation', () => {
beforeAll(() => {
const mock = nock('https://people.googleapis.com/v1');
// Mock cache warmup requests with specific empty query
mock
.get('/people:searchContacts')
.query((actualQuery) => actualQuery.query === '')
.reply(200, {
results: [],
})
.persist();
mock
.get('/people/me/connections')
.query(true) // Match any query parameters
.reply(function (uri) {
const url = new URL(uri, 'https://people.googleapis.com');
const params = url.searchParams;
// Handle different scenarios based on query params
if (params.get('pageSize') === '1') {
// Limited results case
return [
200,
{
connections: [
{
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
},
],
},
],
nextPageToken: '',
},
];
} else if (params.get('personFields') === 'names,emailAddresses') {
// Full results case with email addresses
return [
200,
{
connections: [
{
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
},
],
emailAddresses: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
value: 'john@example.com',
type: 'work',
},
],
},
{
resourceName: 'people/c987654321',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c987654321' },
},
displayName: 'Jane Smith',
familyName: 'Smith',
givenName: 'Jane',
},
],
emailAddresses: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c987654321' } },
value: 'jane@example.com',
type: 'work',
},
],
},
],
nextPageToken: '',
},
];
}
// Default response for cache warmup and other requests
return [200, { connections: [] }];
})
.persist();
// Mock search contacts using query with function to better handle
mock
.get('/people:searchContacts')
.query(true)
.reply(function (uri) {
const url = new URL(uri, 'https://people.googleapis.com');
const params = url.searchParams;
// Handle search query specifically
if (params.get('query') === 'John' && params.get('readMask')) {
return [
200,
{
results: [
{
person: {
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
},
],
emailAddresses: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
value: 'john@example.com',
type: 'work',
},
],
},
},
],
nextPageToken: '',
},
];
}
// Default response for cache warmup
return [200, { results: [] }];
})
.persist();
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: [
'contact-getall-connections.workflow.json',
'contact-getall-search.workflow.json',
'contact-getall-limit.workflow.json',
],
});
});
describe('Contact Update Operation', () => {
beforeAll(() => {
const mock = nock('https://people.googleapis.com/v1');
// Mock etag fetch for update
mock
.get('/people/c123456789')
.query({
personFields: 'Names',
})
.reply(200, {
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Doe',
familyName: 'Doe',
givenName: 'John',
},
],
});
// Mock contact update with more flexible query matching
mock
.patch('/people/c123456789:updateContact')
.query(true) // Match any query parameters
.reply(200, {
resourceName: 'people/c123456789',
etag: '%EgYBAgMEBQYHCCESBCAFIAI=.',
names: [
{
metadata: {
primary: true,
source: { type: 'CONTACT', id: 'c123456789' },
},
displayName: 'John Updated Doe',
familyName: 'Doe',
givenName: 'John Updated',
},
],
emailAddresses: [
{
metadata: { primary: true, source: { type: 'CONTACT', id: 'c123456789' } },
value: 'john.updated@example.com',
type: 'work',
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['contact-update.workflow.json'],
});
});
});
@@ -0,0 +1,72 @@
{
"name": "Google Contacts Create Basic Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "create",
"familyName": "Doe",
"givenName": "John"
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-create-basic",
"name": "Create Contact Basic",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Create Contact Basic": [
{
"json": {
"resourceName": "people/c123456789",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": [
{
"metadata": {
"primary": true,
"source": { "type": "CONTACT", "id": "c123456789" }
},
"displayName": "John Doe",
"familyName": "Doe",
"givenName": "John",
"displayNameLastFirst": "Doe, John"
}
],
"contactId": "c123456789"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Create Contact Basic",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,158 @@
{
"name": "Google Contacts Create Full Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "create",
"familyName": "Smith",
"givenName": "Jane",
"additionalFields": {
"middleName": "Marie",
"honorificPrefix": "Dr.",
"honorificSuffix": "Jr.",
"emailsUi": {
"emailsValues": [
{
"value": "jane@example.com",
"type": "work"
}
]
},
"phoneUi": {
"phoneValues": [
{
"value": "+1234567890",
"type": "mobile"
}
]
},
"addressesUi": {
"addressesValues": [
{
"streetAddress": "123 Main St",
"city": "Anytown",
"region": "CA",
"postalCode": "12345",
"countryCode": "US",
"type": "home"
}
]
},
"companyUi": {
"companyValues": [
{
"name": "Test Company",
"title": "Software Engineer",
"current": true,
"domain": "testcompany.com"
}
]
},
"birthday": "1990-06-15T00:00:00.000Z",
"biographies": "Test biography",
"relationsUi": {
"relationsValues": [
{
"person": "John Smith",
"type": "spouse"
}
]
},
"eventsUi": {
"eventsValues": [
{
"date": "2020-06-15T00:00:00.000Z",
"type": "anniversary"
}
]
},
"customFieldsUi": {
"customFieldsValues": [
{
"key": "customField",
"value": "customValue"
}
]
},
"group": ["contactGroups/group123"]
}
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-create-full",
"name": "Create Contact Full",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Create Contact Full": [
{
"json": {
"resourceName": "people/c987654321",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": [
{
"metadata": {
"primary": true,
"source": { "type": "CONTACT", "id": "c987654321" }
},
"displayName": "Dr. Jane Marie Smith Jr.",
"familyName": "Smith",
"givenName": "Jane",
"middleName": "Marie",
"honorificPrefix": "Dr.",
"honorificSuffix": "Jr."
}
],
"emailAddresses": [
{
"metadata": { "primary": true, "source": { "type": "CONTACT", "id": "c987654321" } },
"value": "jane@example.com",
"type": "work"
}
],
"phoneNumbers": [
{
"metadata": { "primary": true, "source": { "type": "CONTACT", "id": "c987654321" } },
"value": "+1234567890",
"type": "mobile"
}
],
"contactId": "c987654321"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Create Contact Full",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,57 @@
{
"name": "Google Contacts Delete Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "delete",
"contactId": "c123456789"
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-delete",
"name": "Delete Contact",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Delete Contact": [
{
"json": {
"success": true
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Delete Contact",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,90 @@
{
"name": "Google Contacts Get All Fields Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "get",
"contactId": "c123456789",
"fields": ["*"],
"rawData": false
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-get-all",
"name": "Get Contact All Fields",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Get Contact All Fields": [
{
"json": {
"resourceName": "people/c123456789",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": {
"displayName": "John Doe",
"familyName": "Doe",
"givenName": "John"
},
"emailAddresses": {
"work": ["john@example.com"]
},
"phoneNumbers": {
"mobile": ["+1234567890"]
},
"addresses": {
"home": [
{
"formattedValue": "123 Main St\nAnytown, CA 12345",
"streetAddress": "123 Main St",
"city": "Anytown",
"region": "CA",
"postalCode": "12345"
}
]
},
"organizations": [
{
"name": "Example Corp",
"title": "Software Engineer",
"current": true
}
],
"contactId": "c123456789"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Contact All Fields",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,72 @@
{
"name": "Google Contacts Get Fields Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "get",
"contactId": "c123456789",
"fields": ["names", "emailAddresses", "phoneNumbers"],
"rawData": false
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-get-fields",
"name": "Get Contact Fields",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Get Contact Fields": [
{
"json": {
"resourceName": "people/c123456789",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": {
"displayName": "John Doe",
"familyName": "Doe",
"givenName": "John"
},
"emailAddresses": {
"work": ["john@example.com"]
},
"phoneNumbers": {
"mobile": ["+1234567890"]
},
"contactId": "c123456789"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Contact Fields",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,85 @@
{
"name": "Google Contacts Get All Connections Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "getAll",
"returnAll": true,
"fields": ["names", "emailAddresses"],
"useQuery": false,
"rawData": false
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-getall-connections",
"name": "Get All Contacts Connections",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Get All Contacts Connections": [
{
"json": {
"resourceName": "people/c123456789",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": {
"displayName": "John Doe",
"familyName": "Doe",
"givenName": "John"
},
"emailAddresses": {
"work": ["john@example.com"]
},
"contactId": "c123456789"
}
},
{
"json": {
"resourceName": "people/c987654321",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": {
"displayName": "Jane Smith",
"familyName": "Smith",
"givenName": "Jane"
},
"emailAddresses": {
"work": ["jane@example.com"]
},
"contactId": "c987654321"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get All Contacts Connections",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,68 @@
{
"name": "Google Contacts Get All Limit Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "getAll",
"returnAll": false,
"limit": 1,
"fields": ["names"],
"useQuery": false,
"rawData": false
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-getall-limit",
"name": "Get All Contacts Limit",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Get All Contacts Limit": [
{
"json": {
"resourceName": "people/c123456789",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": {
"displayName": "John Doe",
"familyName": "Doe",
"givenName": "John"
},
"contactId": "c123456789"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get All Contacts Limit",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,71 @@
{
"name": "Google Contacts Get All Search Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "getAll",
"returnAll": true,
"fields": ["names", "emailAddresses"],
"useQuery": true,
"query": "John",
"rawData": false
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-getall-search",
"name": "Get All Contacts Search",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Get All Contacts Search": [
{
"json": {
"resourceName": "people/c123456789",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": {
"displayName": "John Doe",
"familyName": "Doe",
"givenName": "John"
},
"emailAddresses": {
"work": ["john@example.com"]
},
"contactId": "c123456789"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get All Contacts Search",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,89 @@
{
"name": "Google Contacts Update Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "contact",
"operation": "update",
"contactId": "c123456789",
"fields": ["names", "emailAddresses"],
"updateFields": {
"givenName": "John Updated",
"emailsUi": {
"emailsValues": [
{
"value": "john.updated@example.com",
"type": "work"
}
]
}
}
},
"type": "n8n-nodes-base.googleContacts",
"typeVersion": 1,
"position": [200, 0],
"id": "contact-update",
"name": "Update Contact",
"credentials": {
"googleContactsOAuth2Api": {
"id": "test-credential-id",
"name": "Test Google Contacts OAuth2"
}
}
}
],
"pinData": {
"Update Contact": [
{
"json": {
"resourceName": "people/c123456789",
"etag": "%EgYBAgMEBQYHCCESBCAFIAI=.",
"names": [
{
"metadata": {
"primary": true,
"source": { "type": "CONTACT", "id": "c123456789" }
},
"displayName": "John Updated Doe",
"familyName": "Doe",
"givenName": "John Updated"
}
],
"emailAddresses": [
{
"metadata": { "primary": true, "source": { "type": "CONTACT", "id": "c123456789" } },
"value": "john.updated@example.com",
"type": "work"
}
],
"contactId": "c123456789"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Update Contact",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}