feat(n8n Google My Business Node): New node (#10504)

This commit is contained in:
Valentina Lilova 2024-10-16 17:18:53 +02:00 committed by GitHub
parent c090fcb340
commit bf28fbefe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 3155 additions and 0 deletions

View file

@ -0,0 +1,29 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
const scopes = ['https://www.googleapis.com/auth/business.manage'];
export class GoogleMyBusinessOAuth2Api implements ICredentialType {
name = 'googleMyBusinessOAuth2Api';
extends = ['googleOAuth2Api'];
displayName = 'Google My Business OAuth2 API';
documentationUrl = 'google/oauth-single-service';
properties: INodeProperties[] = [
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: scopes.join(' '),
},
{
displayName:
'Make sure that you have fulfilled the prerequisites and requested access to Google My Business API. <a href="https://developers.google.com/my-business/content/prereqs" target="_blank">More info</a>. Also, make sure that you have enabled the following APIs & Services in the Google Cloud Console: Google My Business API, Google My Business Management API. <a href="https://docs.n8n.io/integrations/builtin/credentials/google/oauth-generic/#scopes" target="_blank">More info</a>.',
name: 'notice',
type: 'notice',
default: '',
},
];
}

View file

@ -0,0 +1,520 @@
import {
NodeApiError,
NodeOperationError,
type DeclarativeRestApiSettings,
type IDataObject,
type IExecutePaginationFunctions,
type IExecuteSingleFunctions,
type IHttpRequestMethods,
type IHttpRequestOptions,
type ILoadOptionsFunctions,
type IN8nHttpFullResponse,
type INodeExecutionData,
type INodeListSearchItems,
type INodeListSearchResult,
type IPollFunctions,
type JsonObject,
} from 'n8n-workflow';
import type { ITimeInterval } from './Interfaces';
const addOptName = 'additionalOptions';
const possibleRootProperties = ['localPosts', 'reviews'];
const getAllParams = (execFns: IExecuteSingleFunctions): Record<string, unknown> => {
const params = execFns.getNode().parameters;
const additionalOptions = execFns.getNodeParameter(addOptName, {}) as Record<string, unknown>;
// Merge standard parameters with additional options from the node parameters
return { ...params, ...additionalOptions };
};
/* Helper function to adjust date-time parameters for API requests */
export async function handleDatesPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
const body = Object.assign({}, opts.body) as IDataObject;
const event = (body.event as IDataObject) ?? ({} as IDataObject);
if (!params.startDateTime && !params.startDate && !params.endDateTime && !params.endDate) {
return opts;
}
const createDateTimeObject = (dateString: string) => {
const date = new Date(dateString);
return {
date: {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
},
time: dateString.includes('T')
? {
hours: date.getUTCHours(),
minutes: date.getUTCMinutes(),
seconds: date.getUTCSeconds(),
nanos: 0,
}
: undefined,
};
};
// Convert start and end date-time parameters if provided
const startDateTime =
params.startDateTime || params.startDate
? createDateTimeObject((params.startDateTime || params.startDate) as string)
: null;
const endDateTime =
params.endDateTime || params.endDate
? createDateTimeObject((params.endDateTime || params.endDate) as string)
: null;
const schedule: Partial<ITimeInterval> = {
startDate: startDateTime?.date,
endDate: endDateTime?.date,
startTime: startDateTime?.time,
endTime: endDateTime?.time,
};
event.schedule = schedule;
Object.assign(body, { event });
opts.body = body;
return opts;
}
/* Helper function adding update mask to the request */
export async function addUpdateMaskPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject;
const propertyMapping: { [key: string]: string } = {
postType: 'topicType',
actionType: 'actionType',
callToActionType: 'callToAction.actionType',
url: 'callToAction.url',
startDateTime: 'event.schedule.startDate,event.schedule.startTime',
endDateTime: 'event.schedule.endDate,event.schedule.endTime',
title: 'event.title',
startDate: 'event.schedule.startDate',
endDate: 'event.schedule.endDate',
couponCode: 'offer.couponCode',
redeemOnlineUrl: 'offer.redeemOnlineUrl',
termsAndConditions: 'offer.termsAndConditions',
};
if (Object.keys(additionalOptions).length) {
const updateMask = Object.keys(additionalOptions)
.map((key) => propertyMapping[key] || key)
.join(',');
opts.qs = {
...opts.qs,
updateMask,
};
}
return opts;
}
/* Helper function to handle pagination */
export async function handlePagination(
this: IExecutePaginationFunctions,
resultOptions: DeclarativeRestApiSettings.ResultOptions,
): Promise<INodeExecutionData[]> {
const aggregatedResult: IDataObject[] = [];
let nextPageToken: string | undefined;
const returnAll = this.getNodeParameter('returnAll') as boolean;
let limit = 100;
if (!returnAll) {
limit = this.getNodeParameter('limit') as number;
resultOptions.maxResults = limit;
}
resultOptions.paginate = true;
do {
if (nextPageToken) {
resultOptions.options.qs = { ...resultOptions.options.qs, pageToken: nextPageToken };
}
const responseData = await this.makeRoutingRequest(resultOptions);
for (const page of responseData) {
for (const prop of possibleRootProperties) {
if (page.json[prop]) {
const currentData = page.json[prop] as IDataObject[];
aggregatedResult.push(...currentData);
}
}
if (!returnAll && aggregatedResult.length >= limit) {
return aggregatedResult.slice(0, limit).map((item) => ({ json: item }));
}
nextPageToken = page.json.nextPageToken as string | undefined;
}
} while (nextPageToken);
return aggregatedResult.map((item) => ({ json: item }));
}
/* Helper functions to handle errors */
export async function handleErrorsDeletePost(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const post = this.getNodeParameter('post', undefined) as IDataObject;
// Provide a better error message
if (post && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The post you are deleting could not be found. Adjust the "post" parameter setting to delete the post correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsGetPost(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const post = this.getNodeParameter('post', undefined) as IDataObject;
// Provide a better error message
if (post && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The post you are requesting could not be found. Adjust the "post" parameter setting to retrieve the post correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsUpdatePost(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const post = this.getNodeParameter('post') as IDataObject;
const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject;
// Provide a better error message
if (post && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The post you are updating could not be found. Adjust the "post" parameter setting to update the post correctly.',
);
}
// Do not throw an error if the user didn't set additional options (a hint will be shown)
if (response.statusCode === 400 && Object.keys(additionalOptions).length === 0) {
return [{ json: { success: true } }];
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsDeleteReply(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const review = this.getNodeParameter('review', undefined) as IDataObject;
// Provide a better error message
if (review && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The review you are deleting could not be found. Adjust the "review" parameter setting to update the review correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsGetReview(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const review = this.getNodeParameter('review', undefined) as IDataObject;
// Provide a better error message
if (review && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The review you are requesting could not be found. Adjust the "review" parameter setting to update the review correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsReplyToReview(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const review = this.getNodeParameter('review', undefined) as IDataObject;
// Provide a better error message
if (review && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The review you are replying to could not be found. Adjust the "review" parameter setting to reply to the review correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
/* Helper function used in listSearch methods */
export async function googleApiRequest(
this: ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
resource: string,
body: IDataObject = {},
qs: IDataObject = {},
url?: string,
): Promise<IDataObject> {
const options: IHttpRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
url: url ?? `https://mybusiness.googleapis.com/v4${resource}`,
json: true,
};
try {
if (Object.keys(body).length === 0) {
delete options.body;
}
return (await this.helpers.httpRequestWithAuthentication.call(
this,
'googleMyBusinessOAuth2Api',
options,
)) as IDataObject;
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
/* listSearch methods */
export async function searchAccounts(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
// Docs for this API call can be found here:
// https://developers.google.com/my-business/reference/accountmanagement/rest/v1/accounts/list
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
'',
{},
{
pageSize: 20,
...query,
},
'https://mybusinessaccountmanagement.googleapis.com/v1/accounts',
);
const accounts = responseData.accounts as Array<{ name: string; accountName: string }>;
const results: INodeListSearchItems[] = accounts
.map((a) => ({
name: a.accountName,
value: a.name,
}))
.filter(
(a) =>
!filter ||
a.name.toLowerCase().includes(filter.toLowerCase()) ||
a.value.toLowerCase().includes(filter.toLowerCase()),
)
.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, paginationToken: responseData.nextPageToken };
}
export async function searchLocations(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
// Docs for this API call can be found here:
// https://developers.google.com/my-business/reference/businessinformation/rest/v1/accounts.locations/list
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const account = (this.getNodeParameter('account') as IDataObject).value as string;
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
'',
{},
{
readMask: 'name',
pageSize: 100,
...query,
},
`https://mybusinessbusinessinformation.googleapis.com/v1/${account}/locations`,
);
const locations = responseData.locations as Array<{ name: string }>;
const results: INodeListSearchItems[] = locations
.map((a) => ({
name: a.name,
value: a.name,
}))
.filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase()))
.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, paginationToken: responseData.nextPageToken };
}
export async function searchReviews(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const account = (this.getNodeParameter('account') as IDataObject).value as string;
const location = (this.getNodeParameter('location') as IDataObject).value as string;
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
`/${account}/${location}/reviews`,
{},
{
pageSize: 50,
...query,
},
);
const reviews = responseData.reviews as Array<{ name: string; comment: string }>;
const results: INodeListSearchItems[] = reviews
.map((a) => ({
name: a.comment,
value: a.name,
}))
.filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase()))
.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, paginationToken: responseData.nextPageToken };
}
export async function searchPosts(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const account = (this.getNodeParameter('account') as IDataObject).value as string;
const location = (this.getNodeParameter('location') as IDataObject).value as string;
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
`/${account}/${location}/localPosts`,
{},
{
pageSize: 100,
...query,
},
);
const localPosts = responseData.localPosts as Array<{ name: string; summary: string }>;
const results: INodeListSearchItems[] = localPosts
.map((a) => ({
name: a.summary,
value: a.name,
}))
.filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase()))
.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, paginationToken: responseData.nextPageToken };
}

View file

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.googleMyBusiness",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Marketing", "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.googlemybusiness/"
}
]
}
}

View file

@ -0,0 +1,77 @@
import { NodeConnectionType, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { searchAccounts, searchLocations, searchPosts, searchReviews } from './GenericFunctions';
import { postFields, postOperations } from './PostDescription';
import { reviewFields, reviewOperations } from './ReviewDescription';
export class GoogleMyBusiness implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google My Business',
name: 'googleMyBusiness',
icon: 'file:googleMyBusines.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google My Business API',
defaults: {
name: 'Google My Business',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
hints: [
{
message: 'Please select a parameter in the options to modify the post',
displayCondition:
'={{$parameter["resource"] === "post" && $parameter["operation"] === "update" && Object.keys($parameter["additionalOptions"]).length === 0}}',
whenToDisplay: 'always',
location: 'outputPane',
type: 'warning',
},
],
credentials: [
{
name: 'googleMyBusinessOAuth2Api',
required: true,
},
],
requestDefaults: {
baseURL: 'https://mybusiness.googleapis.com/v4',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Post',
value: 'post',
},
{
name: 'Review',
value: 'review',
},
],
default: 'post',
},
...postOperations,
...postFields,
...reviewOperations,
...reviewFields,
],
};
methods = {
listSearch: {
searchAccounts,
searchLocations,
searchReviews,
searchPosts,
},
};
}

View file

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.googleMyBusinessTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"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.googlemybusinesstrigger/"
}
]
}
}

View file

@ -0,0 +1,192 @@
import {
NodeApiError,
NodeConnectionType,
type IPollFunctions,
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import { googleApiRequest, searchAccounts, searchLocations } from './GenericFunctions';
export class GoogleMyBusinessTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google My Business Trigger',
name: 'googleMyBusinessTrigger',
icon: 'file:googleMyBusines.svg',
group: ['trigger'],
version: 1,
description:
'Fetches reviews from Google My Business and starts the workflow on specified polling intervals.',
subtitle: '={{"Google My Business Trigger"}}',
defaults: {
name: 'Google My Business Trigger',
},
credentials: [
{
name: 'googleMyBusinessOAuth2Api',
required: true,
},
],
polling: true,
inputs: [],
outputs: [NodeConnectionType.Main],
properties: [
{
displayName: 'Event',
name: 'event',
required: true,
type: 'options',
noDataExpression: true,
default: 'reviewAdded',
options: [
{
name: 'Review Added',
value: 'reviewAdded',
},
],
},
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google My Business account',
displayOptions: { show: { event: ['reviewAdded'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { event: ['reviewAdded'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
],
};
methods = {
listSearch: {
searchAccounts,
searchLocations,
},
};
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
const nodeStaticData = this.getWorkflowStaticData('node');
let responseData;
const qs: IDataObject = {};
const account = (this.getNodeParameter('account') as { value: string; mode: string }).value;
const location = (this.getNodeParameter('location') as { value: string; mode: string }).value;
const manualMode = this.getMode() === 'manual';
if (manualMode) {
qs.pageSize = 1; // In manual mode we only want to fetch the latest review
} else {
qs.pageSize = 50; // Maximal page size for the get reviews endpoint
}
try {
responseData = (await googleApiRequest.call(
this,
'GET',
`/${account}/${location}/reviews`,
{},
qs,
)) as { reviews: IDataObject[]; totalReviewCount: number; nextPageToken?: string };
if (manualMode) {
responseData = responseData.reviews;
} else {
// During the first execution there is no delta
if (!nodeStaticData.totalReviewCountLastTimeChecked) {
nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount;
return null;
}
// When count did't change the node shouldn't trigger
if (
!responseData?.reviews?.length ||
nodeStaticData?.totalReviewCountLastTimeChecked === responseData?.totalReviewCount
) {
return null;
}
const numNewReviews =
// @ts-ignore
responseData.totalReviewCount - nodeStaticData.totalReviewCountLastTimeChecked;
nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount;
// By default the reviews will be sorted by updateTime in descending order
// Return only the delta reviews since last pooling
responseData = responseData.reviews.slice(0, numNewReviews);
}
if (Array.isArray(responseData) && responseData.length) {
return [this.helpers.returnJsonArray(responseData)];
}
return null;
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}
}

View file

@ -0,0 +1,23 @@
interface IDate {
year: number;
month: number;
day: number;
}
interface ITimeOfDay {
hours: number;
minutes: number;
seconds: number;
nanos: number;
}
export interface ITimeInterval {
startDate: IDate;
startTime: ITimeOfDay;
endDate: IDate;
endTime: ITimeOfDay;
}
export interface IReviewReply {
comment: string;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,574 @@
import type { INodeProperties } from 'n8n-workflow';
import {
handleErrorsDeleteReply,
handleErrorsGetReview,
handleErrorsReplyToReview,
handlePagination,
} from './GenericFunctions';
export const reviewOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'get',
noDataExpression: true,
displayOptions: { show: { resource: ['review'] } },
options: [
{
name: 'Delete Reply',
value: 'delete',
action: 'Delete a reply to a review',
description: 'Delete a reply to a review',
routing: {
request: {
method: 'DELETE',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply',
ignoreHttpStatusErrors: true,
},
output: {
postReceive: [handleErrorsDeleteReply],
},
},
},
{
name: 'Get',
value: 'get',
action: 'Get review',
description: 'Retrieve details of a specific review on Google My Business',
routing: {
request: {
method: 'GET',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}',
ignoreHttpStatusErrors: true,
},
output: {
postReceive: [handleErrorsGetReview],
},
},
},
{
name: 'Get Many',
value: 'getAll',
action: 'Get many reviews',
description: 'Retrieve multiple reviews',
routing: {
send: { paginate: true },
operations: { pagination: handlePagination },
request: {
method: 'GET',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews',
qs: {
pageSize:
'={{ $parameter["limit"] ? ($parameter["limit"] < 50 ? $parameter["limit"] : 50) : 50 }}', // Google allows maximum 50 results per page
},
},
},
},
{
name: 'Reply',
value: 'reply',
action: 'Reply to review',
description: 'Reply to a review',
routing: {
request: {
method: 'PUT',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply',
ignoreHttpStatusErrors: true,
},
output: {
postReceive: [handleErrorsReplyToReview],
},
},
},
],
},
];
export const reviewFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* review:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google My Business account',
displayOptions: { show: { resource: ['review'], operation: ['get'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['get'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Review',
name: 'review',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Select the review to retrieve its details',
displayOptions: { show: { resource: ['review'], operation: ['get'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchReviews',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*',
errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. ABC123_review-ID_456xyz',
},
{
displayName: 'By name',
name: 'name',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$',
errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz',
},
],
},
/* -------------------------------------------------------------------------- */
/* review:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google My Business account',
displayOptions: { show: { resource: ['review'], operation: ['delete'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['delete'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Review',
name: 'review',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Select the review to retrieve its details',
displayOptions: { show: { resource: ['review'], operation: ['delete'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchReviews',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*',
errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. ABC123_review-ID_456xyz',
},
{
displayName: 'By name',
name: 'name',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$',
errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz',
},
],
},
/* -------------------------------------------------------------------------- */
/* review:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google My Business account',
displayOptions: { show: { resource: ['review'], operation: ['getAll'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['getAll'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Return All',
name: 'returnAll',
default: false,
description: 'Whether to return all results or only up to a given limit',
displayOptions: { show: { resource: ['review'], operation: ['getAll'] } },
type: 'boolean',
},
{
displayName: 'Limit',
name: 'limit',
required: true,
type: 'number',
typeOptions: {
minValue: 1,
},
default: 20,
description: 'Max number of results to return',
displayOptions: { show: { resource: ['review'], operation: ['getAll'], returnAll: [false] } },
},
/* -------------------------------------------------------------------------- */
/* review:reply */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google My Business account',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Review',
name: 'review',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Select the review to retrieve its details',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchReviews',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*',
errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. ABC123_review-ID_456xyz',
},
{
displayName: 'By name',
name: 'name',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$',
errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz',
},
],
},
{
displayName: 'Reply',
name: 'reply',
type: 'string',
default: '',
description: 'The body of the reply (up to 4096 characters)',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
typeOptions: { rows: 5 },
routing: { send: { type: 'body', property: 'comment' } },
},
];

View file

@ -0,0 +1 @@
<svg height="2185" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0.43 1064 928.69"><linearGradient id="a" x1="0%" x2="99.999%" y1="49.999%" y2="49.999%"><stop offset=".03" stop-color="#4079d8"/><stop offset="1" stop-color="#4989f5"/></linearGradient><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><rect fill="#4989f5" height="696.14" rx="36.88" width="931" x="53.45" y="232.98"/><path d="M936.81 227.75H100.06c-25.92 0-46.09 200.6-46.09 226.52L512.2 929.12h424.61c26-.071 47.059-21.13 47.13-47.13V274.87c-.077-25.996-21.134-47.049-47.13-47.12z" fill="url(#a)"/><path d="M266.03 349.56h266V.44H305.86z" fill="#3c4ba6"/><path d="M798.03 349.56h-266V.44H758.2zM984.45 66.62l.33 1.19c-.08-.42-.24-.81-.33-1.19z" fill="#7babf7"/><path d="M984.78 67.8l-.33-1.19C976.017 27.993 941.837.455 902.31.43H758.2L798 349.56h266z" fill="#3f51b5"/><path d="M79.61 66.62l-.33 1.19c.08-.42.24-.81.33-1.19z" fill="#7babf7"/><path d="M79.27 67.8l.33-1.19C88.033 27.993 122.213.455 161.74.43h144.12L266 349.56H0z" fill="#7babf7"/></g><path d="M266.48 349.47c0 73.412-59.513 132.925-132.925 132.925S.63 422.882.63 349.47z" fill="#709be0"/><path d="M532.33 349.47c0 73.412-59.513 132.925-132.925 132.925S266.48 422.882 266.48 349.47z" fill="#3c4ba6"/><path d="M798.18 349.47c0 73.412-59.513 132.925-132.925 132.925S532.33 422.882 532.33 349.47z" fill="#709be0"/><path d="M1064 349.47c0 73.412-59.513 132.925-132.925 132.925S798.15 422.882 798.15 349.47z" fill="#3c4ba6"/><path d="M931.08 709.6c-.47-6.33-1.25-12.11-2.36-19.49h-145c0 20.28 0 42.41-.08 62.7h84a73.05 73.05 0 0 1-30.75 46.89s0-.35-.06-.36a88 88 0 0 1-34 13.27 99.85 99.85 0 0 1-36.79-.16 91.9 91.9 0 0 1-34.31-14.87 95.72 95.72 0 0 1-33.73-43.1c-.52-1.35-1-2.71-1.49-4.09v-.15l.13-.1a93 93 0 0 1-.05-59.84A96.27 96.27 0 0 1 718.9 654c23.587-24.399 58.829-33.576 91.32-23.78a83 83 0 0 1 33.23 19.56l28.34-28.34c5-5.05 10.19-9.94 15-15.16a149.78 149.78 0 0 0-49.64-30.74 156.08 156.08 0 0 0-103.83-.91c-1.173.4-2.34.817-3.5 1.25A155.18 155.18 0 0 0 646 651a152.61 152.61 0 0 0-13.42 38.78c-16.052 79.772 32.623 158.294 111.21 179.4 25.69 6.88 53 6.71 78.89.83a139.88 139.88 0 0 0 63.14-32.81c18.64-17.15 32-40 39-64.27a179 179 0 0 0 6.26-63.33z" fill="#fff" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,84 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { addUpdateMaskPresend } from '../GenericFunctions';
describe('GenericFunctions - addUpdateMask', () => {
const mockGetNodeParameter = jest.fn();
const mockContext = {
getNodeParameter: mockGetNodeParameter,
} as unknown as IExecuteSingleFunctions;
beforeEach(() => {
mockGetNodeParameter.mockClear();
});
it('should add updateMask with mapped properties to the query string', async () => {
mockGetNodeParameter.mockReturnValue({
postType: 'postTypeValue',
url: 'https://example.com',
startDateTime: '2023-09-15T10:00:00.000Z',
couponCode: 'DISCOUNT123',
});
const opts: Partial<IHttpRequestOptions> = {
qs: {},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({
updateMask:
'topicType,callToAction.url,event.schedule.startDate,event.schedule.startTime,offer.couponCode',
});
});
it('should handle empty additionalOptions and not add updateMask', async () => {
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
qs: {},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({});
});
it('should include unmapped properties in the updateMask', async () => {
mockGetNodeParameter.mockReturnValue({
postType: 'postTypeValue',
unmappedProperty: 'someValue',
});
const opts: Partial<IHttpRequestOptions> = {
qs: {},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({
updateMask: 'topicType,unmappedProperty',
});
});
it('should merge updateMask with existing query string', async () => {
mockGetNodeParameter.mockReturnValue({
postType: 'postTypeValue',
redeemOnlineUrl: 'https://google.example.com',
});
const opts: Partial<IHttpRequestOptions> = {
qs: {
existingQuery: 'existingValue',
},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({
existingQuery: 'existingValue',
updateMask: 'topicType,offer.redeemOnlineUrl',
});
});
});

View file

@ -0,0 +1,84 @@
import { NodeApiError, type ILoadOptionsFunctions, type IPollFunctions } from 'n8n-workflow';
import { googleApiRequest } from '../GenericFunctions';
describe('googleApiRequest', () => {
const mockHttpRequestWithAuthentication = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockHttpRequestWithAuthentication,
},
getNode: jest.fn(),
} as unknown as ILoadOptionsFunctions | IPollFunctions;
beforeEach(() => {
jest.clearAllMocks();
});
it('should make a GET request and return data', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
'googleMyBusinessOAuth2Api',
expect.objectContaining({
method: 'GET',
url: 'https://mybusiness.googleapis.com/v4/test-resource',
qs: {},
json: true,
}),
);
expect(result).toEqual(mockResponse);
});
it('should make a POST request with body and return data', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse);
const requestBody = { key: 'value' };
const result = await googleApiRequest.call(mockContext, 'POST', '/test-resource', requestBody);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
'googleMyBusinessOAuth2Api',
expect.objectContaining({
method: 'POST',
body: requestBody,
url: 'https://mybusiness.googleapis.com/v4/test-resource',
qs: {},
json: true,
}),
);
expect(result).toEqual(mockResponse);
});
it('should remove the body for GET requests', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource', {});
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
'googleMyBusinessOAuth2Api',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.not.objectContaining({ body: expect.anything() }),
);
expect(result).toEqual(mockResponse);
});
it('should throw NodeApiError on API failure', async () => {
const mockError = new Error('API request failed');
mockHttpRequestWithAuthentication.mockRejectedValue(mockError);
await expect(googleApiRequest.call(mockContext, 'GET', '/test-resource')).rejects.toThrow(
NodeApiError,
);
expect(mockContext.getNode).toHaveBeenCalled();
expect(mockHttpRequestWithAuthentication).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,131 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { handleDatesPresend } from '../GenericFunctions';
describe('GenericFunctions - handleDatesPresend', () => {
const mockGetNode = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
getNode: mockGetNode,
getNodeParameter: mockGetNodeParameter,
} as unknown as IExecuteSingleFunctions;
beforeEach(() => {
mockGetNode.mockClear();
mockGetNodeParameter.mockClear();
});
it('should return options unchanged if no date-time parameters are provided', async () => {
mockGetNode.mockReturnValue({
parameters: {},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result).toEqual(opts);
});
it('should merge startDateTime parameter into event schedule', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDateTime: '2023-09-15T10:00:00.000Z',
},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
startTime: { hours: 10, minutes: 0, seconds: 0, nanos: 0 },
},
},
});
});
it('should merge startDate and endDateTime parameters into event schedule', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDate: '2023-09-15',
endDateTime: '2023-09-16T12:30:00.000Z',
},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
endDate: { year: 2023, month: 9, day: 16 },
endTime: { hours: 12, minutes: 30, seconds: 0, nanos: 0 },
},
},
});
});
it('should merge additional options into event schedule', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDate: '2023-09-15',
},
});
mockGetNodeParameter.mockReturnValue({
additionalOption: 'someValue',
});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
},
},
});
});
it('should modify the body with event schedule containing only date', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDate: '2023-09-15',
},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: { event: {} },
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
},
},
});
});
});

View file

@ -0,0 +1,123 @@
import type { DeclarativeRestApiSettings, IExecutePaginationFunctions } from 'n8n-workflow';
import { handlePagination } from '../GenericFunctions';
describe('GenericFunctions - handlePagination', () => {
const mockMakeRoutingRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
makeRoutingRequest: mockMakeRoutingRequest,
getNodeParameter: mockGetNodeParameter,
} as unknown as IExecutePaginationFunctions;
beforeEach(() => {
mockMakeRoutingRequest.mockClear();
mockGetNodeParameter.mockClear();
});
it('should stop fetching when the limit is reached and returnAll is false', async () => {
mockMakeRoutingRequest
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 1 }, { id: 2 }],
nextPageToken: 'nextToken1',
},
},
])
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 3 }, { id: 4 }],
},
},
]);
mockGetNodeParameter.mockReturnValueOnce(false);
mockGetNodeParameter.mockReturnValueOnce(3);
const requestOptions = {
options: {
qs: {},
},
} as unknown as DeclarativeRestApiSettings.ResultOptions;
const result = await handlePagination.call(mockContext, requestOptions);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }]);
});
it('should handle empty results', async () => {
mockMakeRoutingRequest.mockResolvedValueOnce([
{
json: {
localPosts: [],
},
},
]);
mockGetNodeParameter.mockReturnValueOnce(false);
mockGetNodeParameter.mockReturnValueOnce(5);
const requestOptions = {
options: {
qs: {},
},
} as unknown as DeclarativeRestApiSettings.ResultOptions;
const result = await handlePagination.call(mockContext, requestOptions);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1);
expect(result).toEqual([]);
});
it('should fetch all items when returnAll is true', async () => {
mockMakeRoutingRequest
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 1 }, { id: 2 }],
nextPageToken: 'nextToken1',
},
},
])
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 3 }, { id: 4 }],
nextPageToken: 'nextToken2',
},
},
])
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 5 }],
},
},
]);
mockGetNodeParameter.mockReturnValueOnce(true);
const requestOptions = {
options: {
qs: {},
},
} as unknown as DeclarativeRestApiSettings.ResultOptions;
const result = await handlePagination.call(mockContext, requestOptions);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(3);
expect(result).toEqual([
{ json: { id: 1 } },
{ json: { id: 2 } },
{ json: { id: 3 } },
{ json: { id: 4 } },
{ json: { id: 5 } },
]);
});
});

View file

@ -0,0 +1,65 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchAccounts } from '../GenericFunctions';
describe('GenericFunctions - searchAccounts', () => {
const mockGoogleApiRequest = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
});
it('should return accounts with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
accounts: [
{ name: 'accounts/123', accountName: 'Test Account 1' },
{ name: 'accounts/234', accountName: 'Test Account 2' },
],
});
const filter = '123';
const result = await searchAccounts.call(mockContext, filter);
expect(result).toEqual({
results: [{ name: 'Test Account 1', value: 'accounts/123' }],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ accounts: [] });
const result = await searchAccounts.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
accounts: [{ name: 'accounts/123', accountName: 'Test Account 1' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
accounts: [{ name: 'accounts/234', accountName: 'Test Account 2' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
accounts: [{ name: 'accounts/345', accountName: 'Test Account 3' }],
});
const result = await searchAccounts.call(mockContext);
// The request would only return the last result
// N8N handles the pagination and adds the previous results to the results array
expect(result).toEqual({
results: [{ name: 'Test Account 3', value: 'accounts/345' }],
paginationToken: undefined,
});
});
});

View file

@ -0,0 +1,68 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchLocations } from '../GenericFunctions';
describe('GenericFunctions - searchLocations', () => {
const mockGoogleApiRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
getNodeParameter: mockGetNodeParameter,
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
mockGetNodeParameter.mockClear();
mockGetNodeParameter.mockReturnValue('parameterValue');
});
it('should return locations with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/123' }, { name: 'locations/234' }],
});
const filter = '123';
const result = await searchLocations.call(mockContext, filter);
expect(result).toEqual({
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
results: [{ name: 'locations/123', value: 'locations/123' }],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ locations: [] });
const result = await searchLocations.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/123' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/234' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/345' }],
});
const result = await searchLocations.call(mockContext);
// The request would only return the last result
// N8N handles the pagination and adds the previous results to the results array
expect(result).toEqual({
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
results: [{ name: 'locations/345', value: 'locations/345' }],
paginationToken: undefined,
});
});
});

View file

@ -0,0 +1,72 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchPosts } from '../GenericFunctions';
describe('GenericFunctions - searchPosts', () => {
const mockGoogleApiRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
getNodeParameter: mockGetNodeParameter,
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
mockGetNodeParameter.mockClear();
mockGetNodeParameter.mockReturnValue('parameterValue');
});
it('should return posts with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
localPosts: [
{ name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' },
{ name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' },
],
});
const filter = 'First';
const result = await searchPosts.call(mockContext, filter);
expect(result).toEqual({
results: [
{
name: 'First Post',
value: 'accounts/123/locations/123/localPosts/123',
},
],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ localPosts: [] });
const result = await searchPosts.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
localPosts: [{ name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
localPosts: [{ name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
localPosts: [{ name: 'accounts/123/locations/123/localPosts/345', summary: 'Third Post' }],
});
const result = await searchPosts.call(mockContext);
expect(result).toEqual({
results: [{ name: 'Third Post', value: 'accounts/123/locations/123/localPosts/345' }],
paginationToken: undefined,
});
});
});

View file

@ -0,0 +1,73 @@
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchReviews } from '../GenericFunctions';
describe('GenericFunctions - searchReviews', () => {
const mockGoogleApiRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
getNodeParameter: mockGetNodeParameter,
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
mockGetNodeParameter.mockClear();
mockGetNodeParameter.mockReturnValue('parameterValue');
});
it('should return reviews with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
reviews: [
{ name: 'accounts/123/locations/123/reviews/123', comment: 'Great service!' },
{ name: 'accounts/123/locations/123/reviews/234', comment: 'Good experience.' },
],
});
const filter = 'Great';
const result = await searchReviews.call(mockContext, filter);
expect(result).toEqual({
results: [
{
name: 'Great service!',
value: 'accounts/123/locations/123/reviews/123',
},
],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ reviews: [] });
const result = await searchReviews.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
reviews: [{ name: 'accounts/123/locations/123/reviews/123', comment: 'First Review' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
reviews: [{ name: 'accounts/123/locations/123/reviews/234', comment: 'Second Review' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
reviews: [{ name: 'accounts/123/locations/123/reviews/345', comment: 'Third Review' }],
});
const result = await searchReviews.call(mockContext);
expect(result).toEqual({
results: [{ name: 'Third Review', value: 'accounts/123/locations/123/reviews/345' }],
paginationToken: undefined,
});
});
});

View file

@ -140,6 +140,7 @@
"dist/credentials/GoogleDriveOAuth2Api.credentials.js",
"dist/credentials/GoogleFirebaseCloudFirestoreOAuth2Api.credentials.js",
"dist/credentials/GoogleFirebaseRealtimeDatabaseOAuth2Api.credentials.js",
"dist/credentials/GoogleMyBusinessOAuth2Api.credentials.js",
"dist/credentials/GoogleOAuth2Api.credentials.js",
"dist/credentials/GooglePerspectiveOAuth2Api.credentials.js",
"dist/credentials/GoogleSheetsOAuth2Api.credentials.js",
@ -538,6 +539,8 @@
"dist/nodes/Google/Gmail/Gmail.node.js",
"dist/nodes/Google/Gmail/GmailTrigger.node.js",
"dist/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.js",
"dist/nodes/Google/MyBusiness/GoogleMyBusiness.node.js",
"dist/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.js",
"dist/nodes/Google/Perspective/GooglePerspective.node.js",
"dist/nodes/Google/Sheet/GoogleSheets.node.js",
"dist/nodes/Google/Sheet/GoogleSheetsTrigger.node.js",