From 7f530c0d84a71b715ae18cdb50e94385bea0ae77 Mon Sep 17 00:00:00 2001 From: Joe Birch Date: Sun, 26 Jul 2026 18:27:20 +0100 Subject: [PATCH 1/2] Refactor files for handling network properties + validation --- jest.config.js | 6 + nodes/Buffer/Buffer.node.ts | 1312 +-- nodes/Buffer/__tests__/batch.test.ts | 69 + nodes/Buffer/__tests__/bluesky.test.ts | 58 + nodes/Buffer/__tests__/continueOnFail.test.ts | 36 + nodes/Buffer/__tests__/core.test.ts | 317 + nodes/Buffer/__tests__/credentials.test.ts | 38 + nodes/Buffer/__tests__/errors.test.ts | 89 + nodes/Buffer/__tests__/facebook.test.ts | 108 + .../Buffer/__tests__/google-business.test.ts | 285 + nodes/Buffer/__tests__/graphql.test.ts | 66 + nodes/Buffer/__tests__/idea.test.ts | 70 + nodes/Buffer/__tests__/instagram.test.ts | 149 + nodes/Buffer/__tests__/linkedin.test.ts | 58 + nodes/Buffer/__tests__/loadOptions.test.ts | 120 + nodes/Buffer/__tests__/mastodon.test.ts | 70 + nodes/Buffer/__tests__/pinterest.test.ts | 94 + nodes/Buffer/__tests__/startpage.test.ts | 67 + nodes/Buffer/__tests__/test-helpers.ts | 133 + nodes/Buffer/__tests__/threads.test.ts | 58 + nodes/Buffer/__tests__/tiktok.test.ts | 47 + nodes/Buffer/__tests__/twitter.test.ts | 58 + nodes/Buffer/__tests__/youtube.test.ts | 106 + nodes/Buffer/actions/idea/create.ts | 98 + nodes/Buffer/actions/post/create.ts | 221 + .../Buffer/actions/post/networks/facebook.ts | 21 + .../actions/post/networks/google-business.ts | 92 + .../Buffer/actions/post/networks/instagram.ts | 39 + .../Buffer/actions/post/networks/mastodon.ts | 18 + .../Buffer/actions/post/networks/pinterest.ts | 45 + .../Buffer/actions/post/networks/startpage.ts | 25 + nodes/Buffer/actions/post/networks/tiktok.ts | 13 + nodes/Buffer/actions/post/networks/youtube.ts | 30 + nodes/Buffer/actions/post/scheduling.ts | 20 + .../descriptions/facebook.description.ts | 75 + .../google-business.description.ts | 309 + nodes/Buffer/descriptions/idea.description.ts | 76 + nodes/Buffer/descriptions/index.ts | 22 + .../descriptions/instagram.description.ts | 92 + .../descriptions/pinterest.description.ts | 50 + nodes/Buffer/descriptions/post.description.ts | 411 + .../descriptions/youtube.description.ts | 128 + nodes/Buffer/helpers/dateTime.ts | 14 + nodes/Buffer/helpers/urlValidation.ts | 51 + nodes/Buffer/methods/loadOptions.ts | 115 + nodes/Buffer/transport/graphql.ts | 27 + package-lock.json | 9829 +++++++++++------ package.json | 6 +- 48 files changed, 10668 insertions(+), 4573 deletions(-) create mode 100644 jest.config.js create mode 100644 nodes/Buffer/__tests__/batch.test.ts create mode 100644 nodes/Buffer/__tests__/bluesky.test.ts create mode 100644 nodes/Buffer/__tests__/continueOnFail.test.ts create mode 100644 nodes/Buffer/__tests__/core.test.ts create mode 100644 nodes/Buffer/__tests__/credentials.test.ts create mode 100644 nodes/Buffer/__tests__/errors.test.ts create mode 100644 nodes/Buffer/__tests__/facebook.test.ts create mode 100644 nodes/Buffer/__tests__/google-business.test.ts create mode 100644 nodes/Buffer/__tests__/graphql.test.ts create mode 100644 nodes/Buffer/__tests__/idea.test.ts create mode 100644 nodes/Buffer/__tests__/instagram.test.ts create mode 100644 nodes/Buffer/__tests__/linkedin.test.ts create mode 100644 nodes/Buffer/__tests__/loadOptions.test.ts create mode 100644 nodes/Buffer/__tests__/mastodon.test.ts create mode 100644 nodes/Buffer/__tests__/pinterest.test.ts create mode 100644 nodes/Buffer/__tests__/startpage.test.ts create mode 100644 nodes/Buffer/__tests__/test-helpers.ts create mode 100644 nodes/Buffer/__tests__/threads.test.ts create mode 100644 nodes/Buffer/__tests__/tiktok.test.ts create mode 100644 nodes/Buffer/__tests__/twitter.test.ts create mode 100644 nodes/Buffer/__tests__/youtube.test.ts create mode 100644 nodes/Buffer/actions/idea/create.ts create mode 100644 nodes/Buffer/actions/post/create.ts create mode 100644 nodes/Buffer/actions/post/networks/facebook.ts create mode 100644 nodes/Buffer/actions/post/networks/google-business.ts create mode 100644 nodes/Buffer/actions/post/networks/instagram.ts create mode 100644 nodes/Buffer/actions/post/networks/mastodon.ts create mode 100644 nodes/Buffer/actions/post/networks/pinterest.ts create mode 100644 nodes/Buffer/actions/post/networks/startpage.ts create mode 100644 nodes/Buffer/actions/post/networks/tiktok.ts create mode 100644 nodes/Buffer/actions/post/networks/youtube.ts create mode 100644 nodes/Buffer/actions/post/scheduling.ts create mode 100644 nodes/Buffer/descriptions/facebook.description.ts create mode 100644 nodes/Buffer/descriptions/google-business.description.ts create mode 100644 nodes/Buffer/descriptions/idea.description.ts create mode 100644 nodes/Buffer/descriptions/index.ts create mode 100644 nodes/Buffer/descriptions/instagram.description.ts create mode 100644 nodes/Buffer/descriptions/pinterest.description.ts create mode 100644 nodes/Buffer/descriptions/post.description.ts create mode 100644 nodes/Buffer/descriptions/youtube.description.ts create mode 100644 nodes/Buffer/helpers/dateTime.ts create mode 100644 nodes/Buffer/helpers/urlValidation.ts create mode 100644 nodes/Buffer/methods/loadOptions.ts create mode 100644 nodes/Buffer/transport/graphql.ts diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..42083c5 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/nodes/'], + testMatch: ['**/*.test.ts'], +}; diff --git a/nodes/Buffer/Buffer.node.ts b/nodes/Buffer/Buffer.node.ts index 277f5d3..4b29e1a 100644 --- a/nodes/Buffer/Buffer.node.ts +++ b/nodes/Buffer/Buffer.node.ts @@ -1,14 +1,14 @@ import type { IExecuteFunctions, - ILoadOptionsFunctions, INodeExecutionData, - INodePropertyOptions, INodeType, INodeTypeDescription, - IDataObject, - IHttpRequestMethods, } from 'n8n-workflow'; -import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import { getApiUrl } from './transport/graphql'; +import { properties } from './descriptions'; +import { loadOptions } from './methods/loadOptions'; +import { createIdea } from './actions/idea/create'; +import { createPost } from './actions/post/create'; export class Buffer implements INodeType { description: INodeTypeDescription = { @@ -34,877 +34,11 @@ export class Buffer implements INodeType { required: true, }, ], - properties: [ - // ---------------------------------- - // Resources - // ---------------------------------- - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Idea', - value: 'idea', - }, - { - name: 'Post', - value: 'post', - }, - ], - default: 'idea', - }, - // ---------------------------------- - // Operations - // ---------------------------------- - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { - show: { - resource: ['idea'], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a new idea', - action: 'Create an idea', - }, - ], - default: 'create', - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { - show: { - resource: ['post'], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a new post', - action: 'Create a post', - }, - ], - default: 'create', - }, - // ---------------------------------- - // Idea: Create - // ---------------------------------- - { - displayName: 'Organization Name or ID', - name: 'organizationId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getOrganizations', - }, - required: true, - displayOptions: { - show: { - resource: ['idea'], - operation: ['create'], - }, - }, - default: '', - description: 'Choose from the list, or specify an ID using an expression', - }, - { - displayName: 'Text', - name: 'text', - type: 'string', - typeOptions: { - rows: 4, - }, - displayOptions: { - show: { - resource: ['idea'], - operation: ['create'], - }, - }, - default: '', - description: 'The main body text or description of the idea', - }, - { - displayName: 'Title', - name: 'title', - type: 'string', - displayOptions: { - show: { - resource: ['idea'], - operation: ['create'], - }, - }, - default: '', - description: 'Title or headline of the idea (optional)', - }, - // ---------------------------------- - // Post: Create - // ---------------------------------- - { - displayName: 'Organization Name or ID', - name: 'organizationId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getOrganizations', - }, - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - }, - }, - default: '', - description: 'Choose from the list, or specify an ID using an expression', - }, - { - displayName: 'Channel Name or ID', - name: 'channelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getChannels', - loadOptionsDependsOn: ['organizationId'], - }, - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - }, - }, - default: '', - description: 'Choose from the list, or specify an ID using an expression', - }, - { - displayName: 'Channel Service', - name: 'channelService', - type: 'hidden', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - }, - }, - default: '={{ $parameter["channelId"].split("|")[1] }}', - description: 'The service type of the selected channel (auto-populated)', - }, - { - displayName: 'Text', - name: 'postText', - type: 'string', - typeOptions: { - rows: 4, - }, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - }, - }, - default: '', - description: 'The text content of the post (optional for image posts)', - }, - { - displayName: 'Share Mode', - name: 'shareMode', - type: 'options', - options: [ - { - name: 'Share Now', - value: 'shareNow', - description: 'Publish the post immediately', - }, - { - name: 'Add to Queue', - value: 'addToQueue', - description: 'Add the post to the publishing queue', - }, - { - name: 'Share Next', - value: 'shareNext', - description: 'Add the post to the front of the queue', - }, - { - name: 'Custom Schedule', - value: 'customScheduled', - description: 'Schedule the post for a specific time', - }, - ], - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - }, - }, - default: 'shareNow', - description: 'When to publish the post', - }, - { - displayName: 'Scheduled Time', - name: 'dueAt', - type: 'dateTime', - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - shareMode: ['customScheduled'], - }, - }, - default: '', - description: 'The date and time to publish the post. Example: 2026-03-26T10:28:47.545Z.', - }, - { - displayName: 'Scheduling Mode', - name: 'schedulingType', - type: 'options', - options: [ - { - name: 'Automatic', - value: 'automatic', - description: 'Post will be published automatically at the scheduled time', - }, - { - name: 'Notification', - value: 'notification', - description: 'You will receive a notification to manually publish the post', - }, - ], - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['instagram', 'tiktok', 'youtube', 'Instagram', 'TikTok', 'YouTube', 'INSTAGRAM', 'TIKTOK', 'YOUTUBE', 'facebook_group', 'Facebook_Group', 'FACEBOOK_GROUP'], - }, - }, - default: 'automatic', - description: 'How the post should be scheduled', - }, - { - displayName: 'Google Business Post Type', - name: 'googlePostType', - type: 'options', - options: [ - { - name: "What's New", - value: 'whats_new', - description: 'A standard Google Business post', - }, - { - name: 'Offer', - value: 'offer', - description: 'A Google Business offer', - }, - { - name: 'Event', - value: 'event', - description: 'A Google Business event', - }, - ], - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - }, - }, - default: 'whats_new', - description: 'The type of Google Business post to create', - }, - // ---------------------------------- - // Google Business: What's New fields - // ---------------------------------- - { - displayName: 'Action Button', - name: 'googleWhatsNewButton', - type: 'options', - options: [ - { name: 'None', value: 'none' }, - { name: 'Book', value: 'book' }, - { name: 'Order', value: 'order' }, - { name: 'Shop', value: 'shop' }, - { name: 'Learn More', value: 'learn_more' }, - { name: 'Sign Up', value: 'signup' }, - { name: 'Call', value: 'call' }, - ], - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['whats_new'], - }, - }, - default: 'none', - description: 'The call-to-action button for the post', - }, - { - displayName: 'Action Button Link', - name: 'googleWhatsNewLink', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['whats_new'], - }, - }, - default: '', - description: 'URL for the action button', - }, - // ---------------------------------- - // Google Business: Offer fields - // ---------------------------------- - { - displayName: 'Offer Title', - name: 'googleOfferTitle', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['offer'], - }, - }, - default: '', - description: 'Title of the offer', - }, - { - displayName: 'Offer Start Date', - name: 'googleOfferStartDate', - type: 'dateTime', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['offer'], - }, - }, - default: '', - description: 'Start date of the offer', - }, - { - displayName: 'Offer End Date', - name: 'googleOfferEndDate', - type: 'dateTime', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['offer'], - }, - }, - default: '', - description: 'End date of the offer', - }, - { - displayName: 'Coupon Code', - name: 'googleOfferCode', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['offer'], - }, - }, - default: '', - description: 'Coupon code for the offer', - }, - { - displayName: 'Offer Link', - name: 'googleOfferLink', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['offer'], - }, - }, - default: '', - description: 'Link to the offer', - }, - { - displayName: 'Terms & Conditions', - name: 'googleOfferTerms', - type: 'string', - typeOptions: { - rows: 3, - }, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['offer'], - }, - }, - default: '', - description: 'Terms and conditions of the offer', - }, - // ---------------------------------- - // Google Business: Event fields - // ---------------------------------- - { - displayName: 'Event Title', - name: 'googleEventTitle', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['event'], - }, - }, - default: '', - description: 'Title of the event', - }, - { - displayName: 'Event Start Date', - name: 'googleEventStartDate', - type: 'dateTime', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['event'], - }, - }, - default: '', - description: 'Start date of the event', - }, - { - displayName: 'Event End Date', - name: 'googleEventEndDate', - type: 'dateTime', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['event'], - }, - }, - default: '', - description: 'End date of the event', - }, - { - displayName: 'Full Day Event', - name: 'googleEventIsFullDay', - type: 'boolean', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['event'], - }, - }, - default: false, - description: 'Whether the event is a full day event (no specific start/end time)', - }, - { - displayName: 'Action Button', - name: 'googleEventButton', - type: 'options', - options: [ - { name: 'None', value: 'none' }, - { name: 'Book', value: 'book' }, - { name: 'Order', value: 'order' }, - { name: 'Shop', value: 'shop' }, - { name: 'Learn More', value: 'learn_more' }, - { name: 'Sign Up', value: 'signup' }, - { name: 'Call', value: 'call' }, - ], - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['event'], - }, - }, - default: 'none', - description: 'The call-to-action button for the event', - }, - { - displayName: 'Action Button Link', - name: 'googleEventLink', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], - googlePostType: ['event'], - }, - }, - default: '', - description: 'URL for the action button', - }, - { - displayName: 'Facebook Post Type', - name: 'facebookPostType', - type: 'options', - options: [ - { - name: 'Post', - value: 'post', - description: 'A standard Facebook feed post', - }, - { - name: 'Story', - value: 'story', - description: 'A Facebook story', - }, - { - name: 'Reel', - value: 'reel', - description: 'A Facebook reel', - }, - ], - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['facebook_page', 'Facebook_Page', 'FACEBOOK_PAGE'], - }, - }, - default: 'post', - description: 'The type of Facebook post to create', - }, - { - displayName: 'First Comment', - name: 'facebookFirstComment', - type: 'string', - typeOptions: { - rows: 2, - }, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['facebook_page', 'Facebook_Page', 'FACEBOOK_PAGE'], - }, - }, - default: '', - description: 'Text for the first comment on the Facebook post', - }, - { - displayName: 'Link Attachment URL', - name: 'facebookLinkAttachment', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['facebook_page', 'Facebook_Page', 'FACEBOOK_PAGE'], - }, - }, - default: '', - description: 'URL for a link preview attachment. Mutually exclusive with video assets.', - }, - { - displayName: 'Instagram Post Type', - name: 'instagramPostType', - type: 'options', - options: [ - { - name: 'Post', - value: 'post', - description: 'A standard Instagram feed post', - }, - { - name: 'Story', - value: 'story', - description: 'An Instagram story', - }, - { - name: 'Reel', - value: 'reel', - description: 'An Instagram reel', - }, - ], - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['instagram', 'Instagram', 'INSTAGRAM'], - }, - }, - default: 'post', - description: 'The type of Instagram post to create', - }, - { - displayName: 'First Comment', - name: 'instagramFirstComment', - type: 'string', - typeOptions: { - rows: 2, - }, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['instagram', 'Instagram', 'INSTAGRAM'], - }, - }, - default: '', - description: 'Text for the first comment on the Instagram post', - }, - { - displayName: 'Shop Grid Link', - name: 'instagramLink', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['instagram', 'Instagram', 'INSTAGRAM'], - }, - }, - default: '', - description: 'Shop Grid link for the post', - }, - { - displayName: 'Share to Feed', - name: 'instagramShareToFeed', - type: 'boolean', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - channelService: ['instagram', 'Instagram', 'INSTAGRAM'], - }, - }, - default: true, - description: 'Whether the post should also appear on your Instagram feed (relevant for reels and stories)', - }, - { - displayName: 'Attachment Type', - name: 'attachmentType', - type: 'options', - options: [ - { - name: 'No Attachment', - value: 'none', - description: 'Create a text-only post', - }, - { - name: 'Image', - value: 'image', - description: 'Create a post with an image', - }, - { - name: 'Video', - value: 'video', - description: 'Create a post with a video', - }, - ], - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - }, - }, - default: 'none', - description: 'Type of attachment to add to the post', - }, - // ---------------------------------- - // Post: Create - Images - // ---------------------------------- - { - displayName: 'Image URL', - name: 'imageUrl', - type: 'string', - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - attachmentType: ['image'], - }, - }, - default: '', - description: 'The publicly accessible URL of the image (must be HTTP/HTTPS)', - }, - { - displayName: 'Alt Text', - name: 'imageAltText', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - attachmentType: ['image'], - }, - }, - default: '', - description: 'Alternative text describing the image for accessibility', - }, - { - displayName: 'Image Thumbnail URL', - name: 'imageThumbnailUrl', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - attachmentType: ['image'], - }, - }, - default: '', - description: 'Optional URL for a thumbnail version of the image', - }, - // ---------------------------------- - // Post: Create - Videos - // ---------------------------------- - { - displayName: 'Video URL', - name: 'videoUrl', - type: 'string', - required: true, - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - attachmentType: ['video'], - }, - }, - default: '', - description: 'The publicly accessible URL of the video (must be HTTP/HTTPS)', - }, - { - displayName: 'Video Thumbnail URL', - name: 'videoThumbnailUrl', - type: 'string', - displayOptions: { - show: { - resource: ['post'], - operation: ['create'], - attachmentType: ['video'], - }, - }, - default: '', - description: 'Optional URL for a thumbnail version of the video', - }, - ], + properties, }; methods = { - loadOptions: { - async getOrganizations(this: ILoadOptionsFunctions): Promise { - const credentials = await this.getCredentials('bufferApi'); - const apiUrl = (credentials.apiUrl as string) || 'https://api.buffer.com/graphql'; - - const query = ` - query { - account { - organizations { - id - name - } - } - } - `; - - const response = await this.helpers.httpRequestWithAuthentication.call(this, 'bufferApi', { - method: 'POST' as IHttpRequestMethods, - url: apiUrl, - headers: { - 'Content-Type': 'application/json', - }, - body: { query }, - json: true, - }); - - const organizations = response.data?.account?.organizations || []; - - return organizations.map((org: { id: string; name: string }) => ({ - name: org.name, - value: org.id, - })); - }, - - async getChannels(this: ILoadOptionsFunctions): Promise { - const credentials = await this.getCredentials('bufferApi'); - const apiUrl = (credentials.apiUrl as string) || 'https://api.buffer.com/graphql'; - const organizationId = this.getNodeParameter('organizationId') as string; - - if (!organizationId) { - return []; - } - - const query = ` - query GetChannels($input: ChannelsInput!) { - channels(input: $input) { - id - name - service - isLocked - isDisconnected - } - } - `; - - const response = await this.helpers.httpRequestWithAuthentication.call(this, 'bufferApi', { - method: 'POST' as IHttpRequestMethods, - url: apiUrl, - headers: { - 'Content-Type': 'application/json', - }, - body: { - query, - variables: { - input: { - organizationId, - filter: { - isLocked: false, - }, - }, - }, - }, - json: true, - }); - - const channels = response.data?.channels || []; - - // Show all channels, marking disconnected ones so users can see they need reconnecting - // Store value as "id|service" to enable conditional field display - return channels - .map((channel: { id: string; name: string; service: string; isDisconnected: boolean }) => ({ - name: channel.isDisconnected - ? `${channel.name} (${channel.service}) (Disconnected)` - : `${channel.name} (${channel.service})`, - value: channel.isDisconnected - ? `${channel.id}|${channel.service}|disconnected` - : `${channel.id}|${channel.service}`, - })); - }, - }, + loadOptions, }; async execute(this: IExecuteFunctions): Promise { @@ -913,443 +47,17 @@ export class Buffer implements INodeType { const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; - const credentials = await this.getCredentials('bufferApi'); - const apiUrl = (credentials.apiUrl as string) || 'https://api.buffer.com/graphql'; + const apiUrl = await getApiUrl(this); for (let i = 0; i < items.length; i++) { try { if (resource === 'idea') { if (operation === 'create') { - const organizationId = this.getNodeParameter('organizationId', i) as string; - const text = this.getNodeParameter('text', i) as string; - const title = this.getNodeParameter('title', i) as string; - - // Build content input - const content: IDataObject = {}; - - if (text) { - content.text = text; - } - if (title) { - content.title = title; - } - - // Build the input - const input: IDataObject = { - organizationId, - content, - }; - - // GraphQL mutation - const mutation = ` - mutation CreateIdea($input: CreateIdeaInput!) { - createIdea(input: $input) { - ... on Idea { - id - organizationId - groupId - position - createdAt - updatedAt - content { - title - text - } - } - ... on IdeaResponse { - idea { - id - organizationId - groupId - position - createdAt - updatedAt - content { - title - text - } - } - refreshIdeas - } - ... on InvalidInputError { - message - } - ... on UnauthorizedError { - message - } - ... on UnexpectedError { - message - } - ... on LimitReachedError { - message - } - } - } - `; - - const body = { - query: mutation, - variables: { input }, - }; - - const response = await this.helpers.httpRequestWithAuthentication.call(this, 'bufferApi', { - method: 'POST' as IHttpRequestMethods, - url: apiUrl, - headers: { - 'Content-Type': 'application/json', - }, - body, - json: true, - }); - - // Check for GraphQL errors - if (response.errors && response.errors.length > 0) { - throw new NodeApiError(this.getNode(), response.errors[0], { itemIndex: i }); - } - - const result = response.data?.createIdea; - - // Check for mutation-level errors - if (result?.message) { - throw new NodeApiError(this.getNode(), result, { itemIndex: i }); - } - - // Handle IdeaResponse wrapper - const idea = result?.idea || result; - - returnData.push({ - json: idea, - pairedItem: { item: i }, - }); + returnData.push(await createIdea(this, apiUrl, i)); } } else if (resource === 'post') { if (operation === 'create') { - const channelIdComposite = this.getNodeParameter('channelId', i) as string; - // Check if channel is disconnected (format: "id|service|disconnected") - if (channelIdComposite.endsWith('|disconnected')) { - throw new NodeOperationError( - this.getNode(), - 'The selected channel is disconnected. Please reconnect it in Buffer before posting.', - { itemIndex: i }, - ); - } - // Extract channel ID from composite value (format: "id|service") - const channelId = channelIdComposite.split('|')[0]; - const postText = this.getNodeParameter('postText', i) as string; - const shareMode = this.getNodeParameter('shareMode', i) as string; - const attachmentType = this.getNodeParameter('attachmentType', i) as string; - const schedulingType = this.getNodeParameter('schedulingType', i) as string; - - // Build the input - const input: IDataObject = { - channelId, - mode: shareMode, - schedulingType, - assets: [], - }; - - // Add channel-specific post type metadata - const channelService = channelIdComposite.split('|')[1]; - if (channelService && channelService.toLowerCase() === 'instagram') { - const instagramPostType = this.getNodeParameter('instagramPostType', i) as string; - const shouldShareToFeed = this.getNodeParameter('instagramShareToFeed', i) as boolean; - const instagramFirstComment = this.getNodeParameter('instagramFirstComment', i) as string; - const instagramLink = this.getNodeParameter('instagramLink', i) as string; - const instagramMeta: IDataObject = { - type: instagramPostType, - shouldShareToFeed, - }; - if (instagramFirstComment) instagramMeta.firstComment = instagramFirstComment; - if (instagramLink) instagramMeta.link = instagramLink; - input.metadata = { instagram: instagramMeta }; - } else if (channelService && channelService.toLowerCase() === 'facebook_page') { - const facebookPostType = this.getNodeParameter('facebookPostType', i) as string; - const facebookFirstComment = this.getNodeParameter('facebookFirstComment', i) as string; - const facebookLinkAttachment = this.getNodeParameter('facebookLinkAttachment', i) as string; - const facebookMeta: IDataObject = { type: facebookPostType }; - if (facebookFirstComment) facebookMeta.firstComment = facebookFirstComment; - if (facebookLinkAttachment) facebookMeta.linkAttachment = { url: facebookLinkAttachment }; - input.metadata = { facebook: facebookMeta }; - } else if (channelService && ['google', 'googlebusiness', 'google_business'].includes(channelService.toLowerCase())) { - const googlePostType = this.getNodeParameter('googlePostType', i) as string; - const googleMeta: IDataObject = { type: googlePostType }; - - if (googlePostType === 'whats_new') { - const button = this.getNodeParameter('googleWhatsNewButton', i) as string; - const link = this.getNodeParameter('googleWhatsNewLink', i) as string; - const details: IDataObject = {}; - if (button) details.button = button; - if (link) details.link = link; - googleMeta.detailsWhatsNew = details; - } else if (googlePostType === 'offer') { - const title = this.getNodeParameter('googleOfferTitle', i) as string; - const startDate = this.getNodeParameter('googleOfferStartDate', i) as string; - const endDate = this.getNodeParameter('googleOfferEndDate', i) as string; - const code = this.getNodeParameter('googleOfferCode', i) as string; - const link = this.getNodeParameter('googleOfferLink', i) as string; - const terms = this.getNodeParameter('googleOfferTerms', i) as string; - if (title) googleMeta.title = title; - const details: IDataObject = { - title, - startDate: new Date(startDate).toISOString(), - endDate: new Date(endDate).toISOString(), - }; - if (code) details.code = code; - if (link) details.link = link; - if (terms) details.terms = terms; - googleMeta.detailsOffer = details; - } else if (googlePostType === 'event') { - const title = this.getNodeParameter('googleEventTitle', i) as string; - const startDate = this.getNodeParameter('googleEventStartDate', i) as string; - const endDate = this.getNodeParameter('googleEventEndDate', i) as string; - const isFullDay = this.getNodeParameter('googleEventIsFullDay', i) as boolean; - const button = this.getNodeParameter('googleEventButton', i) as string; - const link = this.getNodeParameter('googleEventLink', i) as string; - if (title) googleMeta.title = title; - const details: IDataObject = { - title, - startDate: new Date(startDate).toISOString(), - endDate: new Date(endDate).toISOString(), - isFullDayEvent: isFullDay, - }; - if (button) details.button = button; - if (link) details.link = link; - googleMeta.detailsEvent = details; - } - - input.metadata = { google: googleMeta }; - } - - // Add text if provided - if (postText) { - input.text = postText; - } - - // Handle image attachments - if (attachmentType === 'image') { - const imageUrl = this.getNodeParameter('imageUrl', i) as string; - - // Validate image URL - if (!imageUrl || imageUrl.trim() === '') { - throw new NodeApiError( - this.getNode(), - { message: 'Image URL is required and cannot be empty' }, - { itemIndex: i }, - ); - } - - // Validate URL format - let parsedUrl: URL; - try { - parsedUrl = new URL(imageUrl); - } catch { - throw new NodeApiError( - this.getNode(), - { message: `Invalid image URL format: "${imageUrl}" is not a valid URL` }, - { itemIndex: i }, - ); - } - - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new NodeApiError( - this.getNode(), - { message: `Invalid image URL protocol: URL must use HTTP or HTTPS (got ${parsedUrl.protocol})` }, - { itemIndex: i }, - ); - } - - const imageInput: IDataObject = { url: imageUrl }; - - // Validate and add optional thumbnail URL - const imageThumbnailUrl = this.getNodeParameter('imageThumbnailUrl', i) as string; - if (imageThumbnailUrl && imageThumbnailUrl.trim() !== '') { - // Validate thumbnail URL format - let parsedThumbUrl: URL; - try { - parsedThumbUrl = new URL(imageThumbnailUrl); - } catch { - throw new NodeApiError( - this.getNode(), - { message: `Invalid thumbnail URL format: "${imageThumbnailUrl}" is not a valid URL` }, - { itemIndex: i }, - ); - } - - if (parsedThumbUrl.protocol !== 'http:' && parsedThumbUrl.protocol !== 'https:') { - throw new NodeApiError( - this.getNode(), - { message: `Invalid thumbnail URL protocol: URL must use HTTP or HTTPS (got ${parsedThumbUrl.protocol})` }, - { itemIndex: i }, - ); - } - - imageInput.thumbnailUrl = imageThumbnailUrl; - } - - // Handle metadata (altText) - const imageAltText = this.getNodeParameter('imageAltText', i) as string; - if (imageAltText && imageAltText.trim() !== '') { - imageInput.metadata = { - altText: imageAltText, - }; - } - - input.assets = [{ image: imageInput }]; - } else if (attachmentType === 'video') { - const videoUrl = this.getNodeParameter('videoUrl', i) as string; - - // Validate video URL - if (!videoUrl || videoUrl.trim() === '') { - throw new NodeApiError( - this.getNode(), - { message: 'Video URL is required and cannot be empty' }, - { itemIndex: i }, - ); - } - - // Validate URL format - let parsedUrl: URL; - try { - parsedUrl = new URL(videoUrl); - } catch { - throw new NodeApiError( - this.getNode(), - { message: `Invalid video URL format: "${videoUrl}" is not a valid URL` }, - { itemIndex: i }, - ); - } - - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new NodeApiError( - this.getNode(), - { message: `Invalid video URL protocol: URL must use HTTP or HTTPS (got ${parsedUrl.protocol})` }, - { itemIndex: i }, - ); - } - - const videoInput: IDataObject = { url: videoUrl }; - - // Validate and add optional thumbnail URL - const videoThumbnailUrl = this.getNodeParameter('videoThumbnailUrl', i) as string; - if (videoThumbnailUrl && videoThumbnailUrl.trim() !== '') { - // Validate thumbnail URL format - let parsedThumbUrl: URL; - try { - parsedThumbUrl = new URL(videoThumbnailUrl); - } catch { - throw new NodeApiError( - this.getNode(), - { message: `Invalid thumbnail URL format: "${videoThumbnailUrl}" is not a valid URL` }, - { itemIndex: i }, - ); - } - - if (parsedThumbUrl.protocol !== 'http:' && parsedThumbUrl.protocol !== 'https:') { - throw new NodeApiError( - this.getNode(), - { message: `Invalid thumbnail URL protocol: URL must use HTTP or HTTPS (got ${parsedThumbUrl.protocol})` }, - { itemIndex: i }, - ); - } - - videoInput.thumbnailUrl = videoThumbnailUrl; - } - - input.assets = [{ video: videoInput }]; - } - - // Add dueAt if custom scheduled - if (shareMode === 'customScheduled') { - const dueAt = this.getNodeParameter('dueAt', i) as string; - // Ensure ISO 8601 format with timezone - input.dueAt = new Date(dueAt).toISOString(); - } - - // GraphQL mutation - const mutation = ` - mutation CreatePost($input: CreatePostInput!) { - createPost(input: $input) { - ... on PostActionSuccess { - post { - id - status - text - dueAt - sentAt - createdAt - updatedAt - channelId - channelService - shareMode - isCustomScheduled - externalLink - assets { - id - type - mimeType - source - thumbnail - } - } - } - ... on InvalidInputError { - message - } - ... on UnauthorizedError { - message - } - ... on UnexpectedError { - message - } - ... on NotFoundError { - message - } - ... on LimitReachedError { - message - } - ... on RestProxyError { - message - code - link - } - } - } - `; - - const body = { - query: mutation, - variables: { input }, - }; - - const response = await this.helpers.httpRequestWithAuthentication.call(this, 'bufferApi', { - method: 'POST' as IHttpRequestMethods, - url: apiUrl, - headers: { - 'Content-Type': 'application/json', - }, - body, - json: true, - }); - - // Check for GraphQL errors - if (response.errors && response.errors.length > 0) { - throw new NodeApiError(this.getNode(), response.errors[0], { itemIndex: i }); - } - - const result = response.data?.createPost; - - // Check for mutation-level errors - if (result?.message) { - throw new NodeApiError(this.getNode(), result, { itemIndex: i }); - } - - // Return the post - const post = result?.post; - - returnData.push({ - json: post, - pairedItem: { item: i }, - }); + returnData.push(await createPost(this, apiUrl, i)); } } } catch (error) { diff --git a/nodes/Buffer/__tests__/batch.test.ts b/nodes/Buffer/__tests__/batch.test.ts new file mode 100644 index 0000000..81aec66 --- /dev/null +++ b/nodes/Buffer/__tests__/batch.test.ts @@ -0,0 +1,69 @@ +import { runExecute } from './test-helpers'; + +describe('Multi-item batches', () => { + it('isolates per-item parameters across the batch', async () => { + const { capturedRequests } = await runExecute([ + { postText: 'first post', channelId: 'chan1|twitter' }, + { postText: 'second post', channelId: 'chan2|linkedin' }, + { postText: 'third post', channelId: 'chan3|threads' }, + ]); + + expect(capturedRequests).toHaveLength(3); + expect(capturedRequests[0].variables.input.text).toBe('first post'); + expect(capturedRequests[0].variables.input.channelId).toBe('chan1'); + expect(capturedRequests[1].variables.input.text).toBe('second post'); + expect(capturedRequests[1].variables.input.channelId).toBe('chan2'); + expect(capturedRequests[2].variables.input.text).toBe('third post'); + expect(capturedRequests[2].variables.input.channelId).toBe('chan3'); + }); + + it('assigns the correct pairedItem index to each successful result', async () => { + const { returnData } = await runExecute([ + { postText: 'first post' }, + { postText: 'second post' }, + { postText: 'third post' }, + ]); + + expect(returnData).toHaveLength(3); + returnData.forEach((item, index) => { + expect(item.pairedItem).toEqual({ item: index }); + }); + }); + + it('returns distinct responses per item when different mock responses are provided', async () => { + const { returnData } = await runExecute( + [{ postText: 'first post' }, { postText: 'second post' }], + { + responses: [ + { data: { createPost: { post: { id: 'post-1', status: 'scheduled' } } } }, + { data: { createPost: { post: { id: 'post-2', status: 'sent' } } } }, + ], + }, + ); + + expect(returnData[0].json).toEqual({ id: 'post-1', status: 'scheduled' }); + expect(returnData[1].json).toEqual({ id: 'post-2', status: 'sent' }); + }); + + it('handles a mix of successes and continued failures across a larger batch', async () => { + const { returnData } = await runExecute( + [ + { postText: 'ok post 1' }, + { channelId: 'chan-bad|twitter|profile|disconnected' }, + { postText: 'ok post 2' }, + { channelId: 'chan-bad-2|twitter|profile|disconnected' }, + ], + { continueOnFail: true }, + ); + + expect(returnData).toHaveLength(4); + expect(returnData[0].json.error).toBeUndefined(); + expect(returnData[0].pairedItem).toEqual({ item: 0 }); + expect(returnData[1].json.error).toContain('The selected channel is disconnected'); + expect(returnData[1].pairedItem).toEqual({ item: 1 }); + expect(returnData[2].json.error).toBeUndefined(); + expect(returnData[2].pairedItem).toEqual({ item: 2 }); + expect(returnData[3].json.error).toContain('The selected channel is disconnected'); + expect(returnData[3].pairedItem).toEqual({ item: 3 }); + }); +}); diff --git a/nodes/Buffer/__tests__/bluesky.test.ts b/nodes/Buffer/__tests__/bluesky.test.ts new file mode 100644 index 0000000..945e130 --- /dev/null +++ b/nodes/Buffer/__tests__/bluesky.test.ts @@ -0,0 +1,58 @@ +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Bluesky + // ------------------------------------------------------- + describe('Bluesky', () => { + const blueskyDefaults = { + channelId: 'chan1|bluesky', + postText: 'Hello Bluesky', + }; + + it('should not include a metadata block', async () => { + const input = await executePostCreate(blueskyDefaults); + expect(input).not.toHaveProperty('metadata'); + }); + + it('should succeed with text only', async () => { + const input = await executePostCreate(blueskyDefaults); + expect(input.text).toBe('Hello Bluesky'); + expect(input.assets).toEqual([]); + }); + + it('should succeed with an image attachment', async () => { + const input = await executePostCreate({ + ...blueskyDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.assets).toEqual([{ image: { url: 'https://example.com/photo.jpg' } }]); + }); + + it('should succeed with a video attachment', async () => { + const input = await executePostCreate({ + ...blueskyDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...blueskyDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...blueskyDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/continueOnFail.test.ts b/nodes/Buffer/__tests__/continueOnFail.test.ts new file mode 100644 index 0000000..8047feb --- /dev/null +++ b/nodes/Buffer/__tests__/continueOnFail.test.ts @@ -0,0 +1,36 @@ +import { runExecute } from './test-helpers'; + +describe('continueOnFail behavior', () => { + it('rethrows the error when continueOnFail is false (default)', async () => { + await expect( + runExecute([{ channelId: 'chan123|twitter|profile|disconnected' }]), + ).rejects.toThrow('The selected channel is disconnected'); + }); + + it('captures the error as a json item when continueOnFail is true', async () => { + const { returnData } = await runExecute( + [{ channelId: 'chan123|twitter|profile|disconnected' }], + { continueOnFail: true }, + ); + + expect(returnData).toHaveLength(1); + expect(returnData[0].json.error).toContain('The selected channel is disconnected'); + expect(returnData[0].pairedItem).toEqual({ item: 0 }); + }); + + it('continues processing subsequent items after a caught failure', async () => { + const { returnData } = await runExecute( + [ + { channelId: 'chan123|twitter|profile|disconnected' }, + { channelId: 'chan456|twitter' }, + ], + { continueOnFail: true }, + ); + + expect(returnData).toHaveLength(2); + expect(returnData[0].json.error).toContain('The selected channel is disconnected'); + expect(returnData[0].pairedItem).toEqual({ item: 0 }); + expect(returnData[1].json.error).toBeUndefined(); + expect(returnData[1].pairedItem).toEqual({ item: 1 }); + }); +}); diff --git a/nodes/Buffer/__tests__/core.test.ts b/nodes/Buffer/__tests__/core.test.ts new file mode 100644 index 0000000..b365298 --- /dev/null +++ b/nodes/Buffer/__tests__/core.test.ts @@ -0,0 +1,317 @@ +import type { IDataObject } from 'n8n-workflow'; +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Core input structure + // ------------------------------------------------------- + describe('core input fields', () => { + it('should always include required fields: channelId, mode, schedulingType, assets', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + }); + expect(input).toHaveProperty('channelId', 'chan1'); + expect(input).toHaveProperty('mode', 'shareNow'); + expect(input).toHaveProperty('schedulingType', 'automatic'); + expect(input).toHaveProperty('assets'); + }); + + it('should send assets as an empty array for text-only posts', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'none', + }); + expect(input.assets).toEqual([]); + }); + + it('should include text when provided', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + postText: 'Hello world', + }); + expect(input.text).toBe('Hello world'); + }); + + it('should not include text when empty', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + postText: '', + }); + expect(input).not.toHaveProperty('text'); + }); + }); + + // ------------------------------------------------------- + // Share modes + // ------------------------------------------------------- + describe('share modes', () => { + it.each([ + ['shareNow'], + ['addToQueue'], + ['shareNext'], + ['customScheduled'], + ])('should support share mode: %s', async (mode) => { + const params: Record = { + channelId: 'chan1|twitter', + shareMode: mode, + }; + if (mode === 'customScheduled') { + params.dueAt = '2026-06-01T12:00:00Z'; + } + const input = await executePostCreate(params); + expect(input.mode).toBe(mode); + }); + + it('should include dueAt in ISO format for customScheduled mode', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + shareMode: 'customScheduled', + dueAt: '2026-06-01T12:00:00Z', + }); + expect(input.dueAt).toBe('2026-06-01T12:00:00.000Z'); + }); + + it('should not include dueAt for non-scheduled modes', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + shareMode: 'shareNow', + }); + expect(input).not.toHaveProperty('dueAt'); + }); + }); + + // ------------------------------------------------------- + // Assets - new array format + // ------------------------------------------------------- + describe('assets', () => { + it('should send image asset in new array format with { image: ... }', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + imageThumbnailUrl: '', + imageAltText: '', + }); + expect(input.assets).toEqual([ + { image: { url: 'https://example.com/photo.jpg' } }, + ]); + }); + + it('should include image thumbnailUrl when provided', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + imageThumbnailUrl: 'https://example.com/thumb.jpg', + imageAltText: '', + }); + expect((input.assets as IDataObject[])[0]).toEqual({ + image: { + url: 'https://example.com/photo.jpg', + thumbnailUrl: 'https://example.com/thumb.jpg', + }, + }); + }); + + it('should include image alt text in metadata when provided', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + imageThumbnailUrl: '', + imageAltText: 'A beautiful sunset', + }); + const asset = (input.assets as IDataObject[])[0] as IDataObject; + const image = asset.image as IDataObject; + expect(image.metadata).toEqual({ altText: 'A beautiful sunset' }); + }); + + it('should send video asset in new array format with { video: ... }', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'video', + videoUrl: 'https://example.com/clip.mp4', + videoThumbnailUrl: '', + }); + expect(input.assets).toEqual([ + { video: { url: 'https://example.com/clip.mp4' } }, + ]); + }); + + it('should include video thumbnailUrl when provided', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'video', + videoUrl: 'https://example.com/clip.mp4', + videoThumbnailUrl: 'https://example.com/vthumb.jpg', + }); + expect((input.assets as IDataObject[])[0]).toEqual({ + video: { + url: 'https://example.com/clip.mp4', + thumbnailUrl: 'https://example.com/vthumb.jpg', + }, + }); + }); + }); + + // ------------------------------------------------------- + // Asset URL validation + // ------------------------------------------------------- + describe('asset URL validation', () => { + it('should throw an error when the image URL is empty', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: '', + }), + ).rejects.toThrow('Image URL is required and cannot be empty'); + }); + + it('should throw an error when the image URL is not a valid URL', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: 'not-a-url', + }), + ).rejects.toThrow('Invalid image URL format'); + }); + + it('should throw an error when the image URL protocol is not HTTP or HTTPS', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: 'ftp://example.com/photo.jpg', + }), + ).rejects.toThrow('Invalid image URL protocol'); + }); + + it('should throw an error when the image thumbnail URL is not a valid URL', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + imageThumbnailUrl: 'not-a-url', + }), + ).rejects.toThrow('Invalid thumbnail URL format'); + }); + + it('should throw an error when the image thumbnail URL protocol is not HTTP or HTTPS', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + imageThumbnailUrl: 'ftp://example.com/thumb.jpg', + }), + ).rejects.toThrow('Invalid thumbnail URL protocol'); + }); + + it('should throw an error when the video URL is empty', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'video', + videoUrl: '', + }), + ).rejects.toThrow('Video URL is required and cannot be empty'); + }); + + it('should throw an error when the video URL is not a valid URL', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'video', + videoUrl: 'not-a-url', + }), + ).rejects.toThrow('Invalid video URL format'); + }); + + it('should throw an error when the video URL protocol is not HTTP or HTTPS', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'video', + videoUrl: 'ftp://example.com/video.mp4', + }), + ).rejects.toThrow('Invalid video URL protocol'); + }); + + it('should throw an error when the video thumbnail URL is not a valid URL', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + videoThumbnailUrl: 'not-a-url', + }), + ).rejects.toThrow('Invalid thumbnail URL format'); + }); + + it('should throw an error when the video thumbnail URL protocol is not HTTP or HTTPS', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter', + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + videoThumbnailUrl: 'ftp://example.com/thumb.jpg', + }), + ).rejects.toThrow('Invalid thumbnail URL protocol'); + }); + }); + + // ------------------------------------------------------- + // No metadata for generic channels + // ------------------------------------------------------- + describe('generic channels (Twitter, LinkedIn, etc.)', () => { + it('should not include metadata for channels without specific requirements', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + postText: 'Hello Twitter', + }); + expect(input).not.toHaveProperty('metadata'); + }); + }); + + // ------------------------------------------------------- + // Disconnected channel + // ------------------------------------------------------- + describe('disconnected channel', () => { + it('should throw an error for disconnected channels', async () => { + await expect( + executePostCreate({ + channelId: 'chan1|twitter|disconnected', + }), + ).rejects.toThrow('The selected channel is disconnected'); + }); + }); + + // ------------------------------------------------------- + // Scheduling type + // ------------------------------------------------------- + describe('scheduling type', () => { + it('should send scheduling type as automatic by default', async () => { + const input = await executePostCreate({ + channelId: 'chan1|twitter', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should send notification scheduling type when specified', async () => { + const input = await executePostCreate({ + channelId: 'chan1|instagram', + schedulingType: 'notification', + instagramPostType: 'post', + instagramShareToFeed: true, + instagramFirstComment: '', + instagramLink: '', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.schedulingType).toBe('notification'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/credentials.test.ts b/nodes/Buffer/__tests__/credentials.test.ts new file mode 100644 index 0000000..fded465 --- /dev/null +++ b/nodes/Buffer/__tests__/credentials.test.ts @@ -0,0 +1,38 @@ +import { BufferApi } from '../../../credentials/BufferApi.credentials'; + +describe('BufferApi credentials', () => { + const credential = new BufferApi(); + + it('is named bufferApi', () => { + expect(credential.name).toBe('bufferApi'); + }); + + it('defines a required apiKey property and an apiUrl property with a default', () => { + const apiKey = credential.properties.find((p) => p.name === 'apiKey'); + const apiUrl = credential.properties.find((p) => p.name === 'apiUrl'); + + expect(apiKey?.required).toBe(true); + expect(apiKey?.typeOptions).toEqual({ password: true }); + expect(apiUrl?.default).toBe('https://api.buffer.com/graphql'); + }); + + it('authenticates using a Bearer token header', () => { + expect(credential.authenticate).toEqual({ + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + }, + }, + }); + }); + + it('tests the credential against the account query', () => { + expect(credential.test.request).toEqual({ + baseURL: '={{$credentials.apiUrl}}', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ account { id } }' }), + }); + }); +}); diff --git a/nodes/Buffer/__tests__/errors.test.ts b/nodes/Buffer/__tests__/errors.test.ts new file mode 100644 index 0000000..c3fec3e --- /dev/null +++ b/nodes/Buffer/__tests__/errors.test.ts @@ -0,0 +1,89 @@ +import { runExecute } from './test-helpers'; + +describe('Post create - error response handling', () => { + it('throws when the GraphQL response contains top-level errors', async () => { + await expect( + runExecute([{}], { + responses: [{ errors: [{ message: 'GraphQL syntax error' }] }], + }), + ).rejects.toThrow('GraphQL syntax error'); + }); + + it('throws on an InvalidInputError union response', async () => { + await expect( + runExecute([{}], { + responses: [{ data: { createPost: { message: 'Invalid input provided' } } }], + }), + ).rejects.toThrow('Invalid input provided'); + }); + + it('throws on an UnauthorizedError union response', async () => { + await expect( + runExecute([{}], { + responses: [{ data: { createPost: { message: 'Not authorized' } } }], + }), + ).rejects.toThrow('Not authorized'); + }); + + it('throws on a LimitReachedError union response', async () => { + await expect( + runExecute([{}], { + responses: [{ data: { createPost: { message: 'Plan limit reached' } } }], + }), + ).rejects.toThrow('Plan limit reached'); + }); + + it('throws on a RestProxyError union response', async () => { + await expect( + runExecute([{}], { + responses: [ + { + data: { + createPost: { + message: 'Rest proxy failure', + code: 'CUSTOM_CODE', + link: 'https://example.com/help', + }, + }, + }, + ], + }), + ).rejects.toThrow('Rest proxy failure'); + }); +}); + +describe('Idea create - error response handling', () => { + const ideaParams = { resource: 'idea', operation: 'create', organizationId: 'org1', text: 'hello' }; + + it('throws when the GraphQL response contains top-level errors', async () => { + await expect( + runExecute([ideaParams], { + responses: [{ errors: [{ message: 'GraphQL syntax error' }] }], + }), + ).rejects.toThrow('GraphQL syntax error'); + }); + + it('throws on an InvalidInputError union response', async () => { + await expect( + runExecute([ideaParams], { + responses: [{ data: { createIdea: { message: 'Invalid idea input' } } }], + }), + ).rejects.toThrow('Invalid idea input'); + }); + + it('throws on an UnauthorizedError union response', async () => { + await expect( + runExecute([ideaParams], { + responses: [{ data: { createIdea: { message: 'Not authorized' } } }], + }), + ).rejects.toThrow('Not authorized'); + }); + + it('throws on a LimitReachedError union response', async () => { + await expect( + runExecute([ideaParams], { + responses: [{ data: { createIdea: { message: 'Plan limit reached' } } }], + }), + ).rejects.toThrow('Plan limit reached'); + }); +}); diff --git a/nodes/Buffer/__tests__/facebook.test.ts b/nodes/Buffer/__tests__/facebook.test.ts new file mode 100644 index 0000000..60e145d --- /dev/null +++ b/nodes/Buffer/__tests__/facebook.test.ts @@ -0,0 +1,108 @@ +import type { IDataObject } from 'n8n-workflow'; +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Facebook Page + // ------------------------------------------------------- + describe('Facebook Page', () => { + const facebookDefaults = { + channelId: 'chan1|facebook|page', + facebookPostType: 'post', + facebookFirstComment: '', + facebookLinkAttachment: '', + }; + + it('should send required metadata: type', async () => { + const input = await executePostCreate(facebookDefaults); + expect(input.metadata).toEqual({ + facebook: { type: 'post' }, + }); + }); + + it.each(['post', 'story', 'reel'])('should support Facebook post type: %s', async (type) => { + const input = await executePostCreate({ + ...facebookDefaults, + facebookPostType: type, + }); + const meta = (input.metadata as IDataObject).facebook as IDataObject; + expect(meta.type).toBe(type); + }); + + it('should include firstComment when provided', async () => { + const input = await executePostCreate({ + ...facebookDefaults, + facebookFirstComment: 'First!', + }); + const meta = (input.metadata as IDataObject).facebook as IDataObject; + expect(meta.firstComment).toBe('First!'); + }); + + it('should not include firstComment when empty', async () => { + const input = await executePostCreate(facebookDefaults); + const meta = (input.metadata as IDataObject).facebook as IDataObject; + expect(meta).not.toHaveProperty('firstComment'); + }); + + it('should include linkAttachment as { url } when provided', async () => { + const input = await executePostCreate({ + ...facebookDefaults, + facebookLinkAttachment: 'https://example.com/article', + }); + const meta = (input.metadata as IDataObject).facebook as IDataObject; + expect(meta.linkAttachment).toEqual({ url: 'https://example.com/article' }); + }); + + it('should not include linkAttachment when empty', async () => { + const input = await executePostCreate(facebookDefaults); + const meta = (input.metadata as IDataObject).facebook as IDataObject; + expect(meta).not.toHaveProperty('linkAttachment'); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...facebookDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...facebookDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); + + // ------------------------------------------------------- + // Facebook Group + // ------------------------------------------------------- + describe('Facebook Group', () => { + const facebookGroupDefaults = { + channelId: 'chan1|facebook|group', + }; + + it('should always send metadata type as post (no first comment or link attachment)', async () => { + const input = await executePostCreate(facebookGroupDefaults); + expect(input.metadata).toEqual({ facebook: { type: 'post' } }); + }); + + it('should force schedulingType to notification regardless of the field value', async () => { + const input = await executePostCreate({ + ...facebookGroupDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('notification'); + }); + + it('should keep schedulingType as notification when already set', async () => { + const input = await executePostCreate({ + ...facebookGroupDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('notification'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/google-business.test.ts b/nodes/Buffer/__tests__/google-business.test.ts new file mode 100644 index 0000000..9b49e6e --- /dev/null +++ b/nodes/Buffer/__tests__/google-business.test.ts @@ -0,0 +1,285 @@ +import type { IDataObject } from 'n8n-workflow'; +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Google Business - What's New + // ------------------------------------------------------- + describe('Google Business - What\'s New', () => { + const googleWhatsNewDefaults = { + channelId: 'chan1|googlebusiness', + googlePostType: 'whats_new', + googleWhatsNewButton: 'none', + googleWhatsNewLink: '', + }; + + it('should send correct metadata structure for whats_new', async () => { + const input = await executePostCreate(googleWhatsNewDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + expect(meta.type).toBe('whats_new'); + expect(meta).toHaveProperty('detailsWhatsNew'); + }); + + it.each(['none', 'book', 'order', 'shop', 'learn_more', 'signup', 'call'])( + 'should support action button: %s', + async (button) => { + const input = await executePostCreate({ + ...googleWhatsNewDefaults, + googleWhatsNewButton: button, + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsWhatsNew as IDataObject; + expect(details.button).toBe(button); + }, + ); + + it('should include link in detailsWhatsNew when provided', async () => { + const input = await executePostCreate({ + ...googleWhatsNewDefaults, + googleWhatsNewLink: 'https://example.com/promo', + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsWhatsNew as IDataObject; + expect(details.link).toBe('https://example.com/promo'); + }); + + it('should not include link in detailsWhatsNew when empty', async () => { + const input = await executePostCreate(googleWhatsNewDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsWhatsNew as IDataObject; + expect(details).not.toHaveProperty('link'); + }); + + it('should throw an error when a video attachment is used', async () => { + await expect( + executePostCreate({ + ...googleWhatsNewDefaults, + attachmentType: 'video', + }), + ).rejects.toThrow('Google Business posts do not support video attachments'); + }); + + it('should succeed with no attachment', async () => { + const input = await executePostCreate(googleWhatsNewDefaults); + expect((input.metadata as IDataObject).google).toBeDefined(); + }); + + it('should succeed with an image attachment', async () => { + const input = await executePostCreate({ + ...googleWhatsNewDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect((input.metadata as IDataObject).google).toBeDefined(); + }); + }); + + // ------------------------------------------------------- + // Google Business - Offer + // ------------------------------------------------------- + describe('Google Business - Offer', () => { + const googleOfferDefaults = { + channelId: 'chan1|googlebusiness', + googlePostType: 'offer', + googleOfferTitle: 'Summer Sale', + googleOfferStartDate: '2026-06-01T00:00:00Z', + googleOfferEndDate: '2026-06-30T23:59:59Z', + googleOfferCode: '', + googleOfferLink: '', + googleOfferTerms: '', + }; + + it('should send correct metadata structure for offer', async () => { + const input = await executePostCreate(googleOfferDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + expect(meta.type).toBe('offer'); + expect(meta.title).toBe('Summer Sale'); + expect(meta).toHaveProperty('detailsOffer'); + }); + + it('should include title, startDate, and endDate in detailsOffer', async () => { + const input = await executePostCreate(googleOfferDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsOffer as IDataObject; + expect(details.title).toBe('Summer Sale'); + expect(details.startDate).toBe('2026-06-01T00:00:00.000Z'); + expect(details.endDate).toBe('2026-06-30T00:00:00.000Z'); + }); + + it('should truncate any time component from the start/end date, since offers only support a date', async () => { + const input = await executePostCreate({ + ...googleOfferDefaults, + googleOfferStartDate: '2026-06-01T15:45:00Z', + googleOfferEndDate: '2026-06-30T23:59:59Z', + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsOffer as IDataObject; + expect(details.startDate).toBe('2026-06-01T00:00:00.000Z'); + expect(details.endDate).toBe('2026-06-30T00:00:00.000Z'); + }); + + it('should include optional offer fields when provided', async () => { + const input = await executePostCreate({ + ...googleOfferDefaults, + googleOfferCode: 'SUMMER20', + googleOfferLink: 'https://example.com/sale', + googleOfferTerms: 'While supplies last', + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsOffer as IDataObject; + expect(details.code).toBe('SUMMER20'); + expect(details.link).toBe('https://example.com/sale'); + expect(details.terms).toBe('While supplies last'); + }); + + it('should not include optional offer fields when empty', async () => { + const input = await executePostCreate(googleOfferDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsOffer as IDataObject; + expect(details).not.toHaveProperty('code'); + expect(details).not.toHaveProperty('link'); + expect(details).not.toHaveProperty('terms'); + }); + + it('should throw an error when a video attachment is used', async () => { + await expect( + executePostCreate({ + ...googleOfferDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }), + ).rejects.toThrow('Google Business posts do not support video attachments'); + }); + }); + + // ------------------------------------------------------- + // Google Business - Event + // ------------------------------------------------------- + describe('Google Business - Event', () => { + const googleEventDefaults = { + channelId: 'chan1|googlebusiness', + googlePostType: 'event', + googleEventTitle: 'Grand Opening', + googleEventStartDate: '2026-07-01T10:00:00Z', + googleEventEndDate: '2026-07-01T18:00:00Z', + googleEventHasTime: false, + googleEventStartTime: '', + googleEventEndTime: '', + googleEventButton: 'none', + googleEventLink: '', + }; + + it('should send correct metadata structure for event', async () => { + const input = await executePostCreate(googleEventDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + expect(meta.type).toBe('event'); + expect(meta.title).toBe('Grand Opening'); + expect(meta).toHaveProperty('detailsEvent'); + }); + + it('should default to an all-day event and truncate the date when time is not specified', async () => { + const input = await executePostCreate(googleEventDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsEvent as IDataObject; + expect(details.title).toBe('Grand Opening'); + expect(details.startDate).toBe('2026-07-01T00:00:00.000Z'); + expect(details.endDate).toBe('2026-07-01T00:00:00.000Z'); + expect(details.isFullDayEvent).toBe(true); + }); + + it('should combine the date and time when a specific event time is provided', async () => { + const input = await executePostCreate({ + ...googleEventDefaults, + googleEventHasTime: true, + googleEventStartTime: '10:00', + googleEventEndTime: '18:00', + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsEvent as IDataObject; + expect(details.startDate).toBe('2026-07-01T10:00:00.000Z'); + expect(details.endDate).toBe('2026-07-01T18:00:00.000Z'); + expect(details.isFullDayEvent).toBe(false); + }); + + it('should throw an error when time is enabled but start or end time is missing', async () => { + await expect( + executePostCreate({ + ...googleEventDefaults, + googleEventHasTime: true, + googleEventStartTime: '10:00', + googleEventEndTime: '', + }), + ).rejects.toThrow( + 'Please provide both an "Event Start Time" and "Event End Time", or disable "Specify Event Time" to create an all-day event.', + ); + }); + + it('should throw an error when the provided time is not in 24-hour HH:mm format', async () => { + await expect( + executePostCreate({ + ...googleEventDefaults, + googleEventHasTime: true, + googleEventStartTime: '10am', + googleEventEndTime: '18:00', + }), + ).rejects.toThrow('Event times must be in 24-hour HH:mm format, e.g. 14:30.'); + }); + + it('should include action button and link when provided', async () => { + const input = await executePostCreate({ + ...googleEventDefaults, + googleEventButton: 'book', + googleEventLink: 'https://example.com/rsvp', + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsEvent as IDataObject; + expect(details.button).toBe('book'); + expect(details.link).toBe('https://example.com/rsvp'); + }); + + it('should not include action button link when empty', async () => { + const input = await executePostCreate(googleEventDefaults); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsEvent as IDataObject; + expect(details).not.toHaveProperty('link'); + }); + + it('should throw an error when a video attachment is used', async () => { + await expect( + executePostCreate({ + ...googleEventDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }), + ).rejects.toThrow('Google Business posts do not support video attachments'); + }); + }); + + // ------------------------------------------------------- + // Google Business - Scheduling + // ------------------------------------------------------- + describe('Google Business - Scheduling', () => { + const googleWhatsNewDefaults = { + channelId: 'chan1|googlebusiness', + googlePostType: 'whats_new', + googleWhatsNewButton: 'none', + googleWhatsNewLink: '', + }; + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...googleWhatsNewDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...googleWhatsNewDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/graphql.test.ts b/nodes/Buffer/__tests__/graphql.test.ts new file mode 100644 index 0000000..eb06422 --- /dev/null +++ b/nodes/Buffer/__tests__/graphql.test.ts @@ -0,0 +1,66 @@ +import { getApiUrl, executeGraphQL } from '../transport/graphql'; + +describe('getApiUrl', () => { + it('falls back to the default Buffer API URL when credentials do not specify one', async () => { + const ctx = { getCredentials: async () => ({ apiKey: 'test-key' }) }; + const result = await getApiUrl(ctx as never); + expect(result).toBe('https://api.buffer.com/graphql'); + }); + + it('uses the apiUrl from credentials when provided', async () => { + const ctx = { + getCredentials: async () => ({ apiKey: 'test-key', apiUrl: 'https://custom.example.com/graphql' }), + }; + const result = await getApiUrl(ctx as never); + expect(result).toBe('https://custom.example.com/graphql'); + }); +}); + +describe('executeGraphQL', () => { + it('POSTs the query with variables as authenticated JSON', async () => { + let capturedOpts: Record | undefined; + let capturedCredType: string | undefined; + const ctx = { + helpers: { + httpRequestWithAuthentication: { + call: async (_thisArg: unknown, credType: string, opts: Record) => { + capturedCredType = credType; + capturedOpts = opts; + return { data: {} }; + }, + }, + }, + }; + + await executeGraphQL(ctx as never, 'https://api.buffer.com/graphql', 'query { account { id } }', { + input: { foo: 'bar' }, + }); + + expect(capturedCredType).toBe('bufferApi'); + expect(capturedOpts).toEqual({ + method: 'POST', + url: 'https://api.buffer.com/graphql', + headers: { 'Content-Type': 'application/json' }, + body: { query: 'query { account { id } }', variables: { input: { foo: 'bar' } } }, + json: true, + }); + }); + + it('omits variables from the body when none are provided', async () => { + let capturedOpts: Record | undefined; + const ctx = { + helpers: { + httpRequestWithAuthentication: { + call: async (_thisArg: unknown, _credType: string, opts: Record) => { + capturedOpts = opts; + return { data: {} }; + }, + }, + }, + }; + + await executeGraphQL(ctx as never, 'https://api.buffer.com/graphql', 'query { account { id } }'); + + expect(capturedOpts?.body).toEqual({ query: 'query { account { id } }' }); + }); +}); diff --git a/nodes/Buffer/__tests__/idea.test.ts b/nodes/Buffer/__tests__/idea.test.ts new file mode 100644 index 0000000..f434f74 --- /dev/null +++ b/nodes/Buffer/__tests__/idea.test.ts @@ -0,0 +1,70 @@ +import { runExecute } from './test-helpers'; + +describe('Idea create', () => { + it('builds the content input from text and title', async () => { + const { capturedRequests } = await runExecute([ + { + resource: 'idea', + operation: 'create', + organizationId: 'org-1', + text: 'idea body text', + title: 'idea title', + }, + ]); + + expect(capturedRequests[0].variables.input).toEqual({ + organizationId: 'org-1', + content: { text: 'idea body text', title: 'idea title' }, + }); + }); + + it('omits title from content when not provided', async () => { + const { capturedRequests } = await runExecute([ + { + resource: 'idea', + operation: 'create', + organizationId: 'org-1', + text: 'idea body text', + }, + ]); + + expect(capturedRequests[0].variables.input).toEqual({ + organizationId: 'org-1', + content: { text: 'idea body text' }, + }); + }); + + it('returns the idea directly when the API returns an unwrapped Idea', async () => { + const idea = { + id: 'idea-1', + organizationId: 'org-1', + groupId: 'group-1', + position: 0, + content: { text: 'idea body text', title: 'idea title' }, + }; + + const { returnData } = await runExecute( + [{ resource: 'idea', operation: 'create', organizationId: 'org-1', text: 'idea body text' }], + { responses: [{ data: { createIdea: idea } }] }, + ); + + expect(returnData[0].json).toEqual(idea); + }); + + it('unwraps the idea from an IdeaResponse wrapper', async () => { + const idea = { + id: 'idea-2', + organizationId: 'org-1', + groupId: 'group-1', + position: 1, + content: { text: 'another idea' }, + }; + + const { returnData } = await runExecute( + [{ resource: 'idea', operation: 'create', organizationId: 'org-1', text: 'another idea' }], + { responses: [{ data: { createIdea: { idea, refreshIdeas: true } } }] }, + ); + + expect(returnData[0].json).toEqual(idea); + }); +}); diff --git a/nodes/Buffer/__tests__/instagram.test.ts b/nodes/Buffer/__tests__/instagram.test.ts new file mode 100644 index 0000000..7d5c9aa --- /dev/null +++ b/nodes/Buffer/__tests__/instagram.test.ts @@ -0,0 +1,149 @@ +import type { IDataObject } from 'n8n-workflow'; +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Instagram + // ------------------------------------------------------- + describe('Instagram', () => { + const instagramDefaults = { + channelId: 'chan1|instagram|business', + instagramPostType: 'post', + instagramShareToFeed: true, + instagramFirstComment: '', + instagramLink: '', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }; + + it('should send required metadata: type and shouldShareToFeed', async () => { + const input = await executePostCreate(instagramDefaults); + expect(input.metadata).toEqual({ + instagram: { + type: 'post', + shouldShareToFeed: true, + }, + }); + }); + + it.each(['post', 'story', 'reel'])('should support Instagram post type: %s', async (type) => { + const input = await executePostCreate({ + ...instagramDefaults, + instagramPostType: type, + }); + const meta = (input.metadata as IDataObject).instagram as IDataObject; + expect(meta.type).toBe(type); + }); + + it('should send shouldShareToFeed as false when disabled', async () => { + const input = await executePostCreate({ + ...instagramDefaults, + instagramShareToFeed: false, + }); + const meta = (input.metadata as IDataObject).instagram as IDataObject; + expect(meta.shouldShareToFeed).toBe(false); + }); + + it('should include firstComment when provided', async () => { + const input = await executePostCreate({ + ...instagramDefaults, + instagramFirstComment: 'Check this out!', + }); + const meta = (input.metadata as IDataObject).instagram as IDataObject; + expect(meta.firstComment).toBe('Check this out!'); + }); + + it('should not include firstComment when empty', async () => { + const input = await executePostCreate(instagramDefaults); + const meta = (input.metadata as IDataObject).instagram as IDataObject; + expect(meta).not.toHaveProperty('firstComment'); + }); + + it('should include Shop Grid link when provided', async () => { + const input = await executePostCreate({ + ...instagramDefaults, + instagramLink: 'https://shop.example.com/item', + }); + const meta = (input.metadata as IDataObject).instagram as IDataObject; + expect(meta.link).toBe('https://shop.example.com/item'); + }); + + it('should not include link when empty', async () => { + const input = await executePostCreate(instagramDefaults); + const meta = (input.metadata as IDataObject).instagram as IDataObject; + expect(meta).not.toHaveProperty('link'); + }); + + it('should throw an error when there is no image or video attachment', async () => { + await expect( + executePostCreate({ + ...instagramDefaults, + attachmentType: 'none', + }), + ).rejects.toThrow('Instagram posts require an image or video attachment'); + }); + + it('should succeed with an image attachment', async () => { + const input = await executePostCreate(instagramDefaults); + expect((input.metadata as IDataObject).instagram).toBeDefined(); + }); + + it('should succeed with a video attachment', async () => { + const input = await executePostCreate({ + ...instagramDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }); + expect((input.metadata as IDataObject).instagram).toBeDefined(); + }); + + it('should support notification scheduling and not force it to change', async () => { + const input = await executePostCreate({ + ...instagramDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('notification'); + }); + }); + + // ------------------------------------------------------- + // Instagram Profile + // ------------------------------------------------------- + describe('Instagram Profile', () => { + const instagramProfileDefaults = { + channelId: 'chan1|instagram|profile', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }; + + it('should always send metadata type as post with shouldShareToFeed true (no first comment or link)', async () => { + const input = await executePostCreate(instagramProfileDefaults); + expect(input.metadata).toEqual({ instagram: { type: 'post', shouldShareToFeed: true } }); + }); + + it('should force schedulingType to notification regardless of the field value', async () => { + const input = await executePostCreate({ + ...instagramProfileDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('notification'); + }); + + it('should keep schedulingType as notification when already set', async () => { + const input = await executePostCreate({ + ...instagramProfileDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('notification'); + }); + + it('should throw an error when there is no image or video attachment', async () => { + await expect( + executePostCreate({ + ...instagramProfileDefaults, + attachmentType: 'none', + }), + ).rejects.toThrow('Instagram posts require an image or video attachment'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/linkedin.test.ts b/nodes/Buffer/__tests__/linkedin.test.ts new file mode 100644 index 0000000..6b6e15e --- /dev/null +++ b/nodes/Buffer/__tests__/linkedin.test.ts @@ -0,0 +1,58 @@ +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // LinkedIn + // ------------------------------------------------------- + describe('LinkedIn', () => { + const linkedinDefaults = { + channelId: 'chan1|linkedin', + postText: 'Hello LinkedIn', + }; + + it('should not include a metadata block', async () => { + const input = await executePostCreate(linkedinDefaults); + expect(input).not.toHaveProperty('metadata'); + }); + + it('should succeed with text only', async () => { + const input = await executePostCreate(linkedinDefaults); + expect(input.text).toBe('Hello LinkedIn'); + expect(input.assets).toEqual([]); + }); + + it('should succeed with an image attachment', async () => { + const input = await executePostCreate({ + ...linkedinDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.assets).toEqual([{ image: { url: 'https://example.com/photo.jpg' } }]); + }); + + it('should succeed with a video attachment', async () => { + const input = await executePostCreate({ + ...linkedinDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...linkedinDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...linkedinDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/loadOptions.test.ts b/nodes/Buffer/__tests__/loadOptions.test.ts new file mode 100644 index 0000000..17ada11 --- /dev/null +++ b/nodes/Buffer/__tests__/loadOptions.test.ts @@ -0,0 +1,120 @@ +import { getOrganizations, getChannels, getPinterestBoards } from '../methods/loadOptions'; + +function mockContext(params: Record, response: unknown) { + return { + getNodeParameter: (name: string) => params[name] ?? '', + getCredentials: async () => ({ + apiKey: 'test-key', + apiUrl: 'https://api.buffer.com/graphql', + }), + helpers: { + httpRequestWithAuthentication: { + call: async () => response, + }, + }, + }; +} + +describe('getOrganizations', () => { + it('maps organizations to name/value options', async () => { + const ctx = mockContext( + {}, + { + data: { + account: { + organizations: [ + { id: 'org-1', name: 'Org One' }, + { id: 'org-2', name: 'Org Two' }, + ], + }, + }, + }, + ); + + const result = await getOrganizations.call(ctx as never); + + expect(result).toEqual([ + { name: 'Org One', value: 'org-1' }, + { name: 'Org Two', value: 'org-2' }, + ]); + }); + + it('returns an empty array when no organizations are present', async () => { + const ctx = mockContext({}, { data: { account: { organizations: [] } } }); + const result = await getOrganizations.call(ctx as never); + expect(result).toEqual([]); + }); +}); + +describe('getChannels', () => { + it('returns an empty array when no organizationId is provided', async () => { + const ctx = mockContext({ organizationId: '' }, {}); + const result = await getChannels.call(ctx as never); + expect(result).toEqual([]); + }); + + it('maps connected channels to composite id|service|type values', async () => { + const ctx = mockContext( + { organizationId: 'org-1' }, + { + data: { + channels: [ + { id: 'chan-1', name: 'My Twitter', service: 'twitter', type: 'profile', isDisconnected: false }, + ], + }, + }, + ); + + const result = await getChannels.call(ctx as never); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('My Twitter (twitter - profile)'); + expect(result[0].value).toBe('chan-1|twitter|profile'); + }); + + it('marks disconnected channels in both name and value', async () => { + const ctx = mockContext( + { organizationId: 'org-1' }, + { + data: { + channels: [ + { id: 'chan-2', name: 'Old Facebook', service: 'facebook', type: 'page', isDisconnected: true }, + ], + }, + }, + ); + + const result = await getChannels.call(ctx as never); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Old Facebook (facebook - page) (Disconnected)'); + expect(result[0].value).toBe('chan-2|facebook|page|disconnected'); + }); +}); + +describe('getPinterestBoards', () => { + it('returns an empty array when no channelId is provided', async () => { + const ctx = mockContext({ channelId: '' }, {}); + const result = await getPinterestBoards.call(ctx as never); + expect(result).toEqual([]); + }); + + it('maps Pinterest boards to name/value options', async () => { + const ctx = mockContext( + { channelId: 'chan-1|pinterest|profile' }, + { + data: { + channel: { + metadata: { + boards: [{ serviceId: 'board-1', name: 'My Board' }], + }, + }, + }, + }, + ); + + const result = await getPinterestBoards.call(ctx as never); + + expect(result).toEqual([{ name: 'My Board', value: 'board-1' }]); + }); +}); diff --git a/nodes/Buffer/__tests__/mastodon.test.ts b/nodes/Buffer/__tests__/mastodon.test.ts new file mode 100644 index 0000000..ef817bc --- /dev/null +++ b/nodes/Buffer/__tests__/mastodon.test.ts @@ -0,0 +1,70 @@ +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Mastodon + // ------------------------------------------------------- + describe('Mastodon', () => { + const mastodonDefaults = { + channelId: 'chan1|mastodon', + }; + + it('should throw an error when there is no text, image, or video', async () => { + await expect(executePostCreate(mastodonDefaults)).rejects.toThrow( + 'Mastodon posts require text, an image, or a video', + ); + }); + + it('should succeed with text only', async () => { + const input = await executePostCreate({ + ...mastodonDefaults, + postText: 'Hello Mastodon', + }); + expect(input.text).toBe('Hello Mastodon'); + }); + + it('should not include a metadata block', async () => { + const input = await executePostCreate({ + ...mastodonDefaults, + postText: 'Hello Mastodon', + }); + expect(input).not.toHaveProperty('metadata'); + }); + + it('should succeed with an image attachment only', async () => { + const input = await executePostCreate({ + ...mastodonDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.assets).toEqual([{ image: { url: 'https://example.com/photo.jpg' } }]); + }); + + it('should succeed with a video attachment only', async () => { + const input = await executePostCreate({ + ...mastodonDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...mastodonDefaults, + postText: 'Hello Mastodon', + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...mastodonDefaults, + postText: 'Hello Mastodon', + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/pinterest.test.ts b/nodes/Buffer/__tests__/pinterest.test.ts new file mode 100644 index 0000000..f3425fe --- /dev/null +++ b/nodes/Buffer/__tests__/pinterest.test.ts @@ -0,0 +1,94 @@ +import type { IDataObject } from 'n8n-workflow'; +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Pinterest + // ------------------------------------------------------- + describe('Pinterest', () => { + const pinterestDefaults = { + channelId: 'chan1|pinterest', + pinterestBoardId: 'board-123', + postText: 'Check out this pin', + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }; + + it('should throw an error when there is no board selected', async () => { + await expect( + executePostCreate({ + ...pinterestDefaults, + pinterestBoardId: '', + }), + ).rejects.toThrow('Pinterest posts require a Board'); + }); + + it('should throw an error when there is no image or video attachment', async () => { + await expect( + executePostCreate({ + ...pinterestDefaults, + attachmentType: 'none', + }), + ).rejects.toThrow('Pinterest posts require an image or video attachment'); + }); + + it('should throw an error when there is no text', async () => { + await expect( + executePostCreate({ + ...pinterestDefaults, + postText: '', + }), + ).rejects.toThrow('Pinterest posts require text content'); + }); + + it('should send required metadata: boardServiceId', async () => { + const input = await executePostCreate(pinterestDefaults); + expect(input.metadata).toEqual({ + pinterest: { boardServiceId: 'board-123' }, + }); + }); + + it('should include title and url in metadata when provided', async () => { + const input = await executePostCreate({ + ...pinterestDefaults, + pinterestTitle: 'My Pin', + pinterestUrl: 'https://example.com/product', + }); + const meta = (input.metadata as IDataObject).pinterest as IDataObject; + expect(meta.title).toBe('My Pin'); + expect(meta.url).toBe('https://example.com/product'); + }); + + it('should not include title or url in metadata when empty', async () => { + const input = await executePostCreate(pinterestDefaults); + const meta = (input.metadata as IDataObject).pinterest as IDataObject; + expect(meta).not.toHaveProperty('title'); + expect(meta).not.toHaveProperty('url'); + }); + + it('should succeed with a video attachment', async () => { + const input = await executePostCreate({ + ...pinterestDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...pinterestDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...pinterestDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/startpage.test.ts b/nodes/Buffer/__tests__/startpage.test.ts new file mode 100644 index 0000000..1d27389 --- /dev/null +++ b/nodes/Buffer/__tests__/startpage.test.ts @@ -0,0 +1,67 @@ +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Start Page + // ------------------------------------------------------- + describe('Start Page', () => { + const startPageDefaults = { + channelId: 'chan1|startpage', + postText: 'Check out my new page', + }; + + it('should throw an error when there is no text', async () => { + await expect( + executePostCreate({ + ...startPageDefaults, + postText: '', + }), + ).rejects.toThrow('Start Page posts require text content'); + }); + + it('should throw an error when a video attachment is used', async () => { + await expect( + executePostCreate({ + ...startPageDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }), + ).rejects.toThrow('Start Page posts do not support video attachments'); + }); + + it('should succeed with text and no attachment', async () => { + const input = await executePostCreate(startPageDefaults); + expect(input.text).toBe('Check out my new page'); + }); + + it('should not include a metadata block', async () => { + const input = await executePostCreate(startPageDefaults); + expect(input).not.toHaveProperty('metadata'); + }); + + it('should succeed with text and an image attachment', async () => { + const input = await executePostCreate({ + ...startPageDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.assets).toEqual([{ image: { url: 'https://example.com/photo.jpg' } }]); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...startPageDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...startPageDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/test-helpers.ts b/nodes/Buffer/__tests__/test-helpers.ts new file mode 100644 index 0000000..bf8c1c4 --- /dev/null +++ b/nodes/Buffer/__tests__/test-helpers.ts @@ -0,0 +1,133 @@ +import { Buffer } from '../Buffer.node'; +import type { IDataObject, INodeExecutionData } from 'n8n-workflow'; + +/** + * Test helper: creates a mock n8n execution context and runs the Buffer node's + * execute method for a single post-create item. Returns the GraphQL variables + * that were sent to the API so tests can assert on the `input` shape. + */ +export async function executePostCreate(params: Record) { + const node = new Buffer(); + let capturedBody: { query: string; variables: { input: IDataObject } } | undefined; + + const defaults: Record = { + resource: 'post', + operation: 'create', + channelId: 'chan123|twitter', + postText: '', + shareMode: 'shareNow', + attachmentType: 'none', + schedulingType: 'automatic', + ...params, + }; + + const mockContext = { + getInputData: () => [{ json: {} }], + getNodeParameter: (name: string) => { + if (!(name in defaults)) { + return ''; + } + return defaults[name]; + }, + getCredentials: async () => ({ + apiKey: 'test-key', + apiUrl: 'https://api.buffer.com/graphql', + }), + getNode: () => ({ name: 'Buffer', type: 'buffer', typeVersion: 1, position: [0, 0] }), + helpers: { + httpRequestWithAuthentication: { + call: async (_thisArg: unknown, _credType: string, opts: { body: typeof capturedBody }) => { + capturedBody = opts.body; + return { + data: { + createPost: { + post: { id: 'post-1', status: 'scheduled' }, + }, + }, + }; + }, + }, + }, + continueOnFail: () => false, + }; + + await node.execute.call(mockContext as never); + + if (!capturedBody) throw new Error('No API call was made'); + return capturedBody.variables.input; +} + +export interface CapturedRequest { + query: string; + variables: { input: IDataObject }; +} + +export interface RunExecuteOptions { + /** + * Mock GraphQL responses to return, one per sequential API call across all + * items. If there are more calls than responses, the last response is reused + * for any remaining calls. + */ + responses?: unknown[]; + /** Overrides the mock context's continueOnFail() return value. Defaults to false. */ + continueOnFail?: boolean; +} + +/** + * Lower-level test helper: like executePostCreate, but supports multiple input + * items (each with its own parameter overrides), custom/injectable mock GraphQL + * responses (including error shapes), and an overridable continueOnFail(). Use + * this when testing error-handling, continueOnFail behavior, or multi-item + * batches; use executePostCreate for simple single-item success-path assertions. + */ +export async function runExecute( + itemsParams: Record[], + options: RunExecuteOptions = {}, +): Promise<{ capturedRequests: CapturedRequest[]; returnData: INodeExecutionData[] }> { + const node = new Buffer(); + const capturedRequests: CapturedRequest[] = []; + const responses = options.responses ?? [ + { data: { createPost: { post: { id: 'post-1', status: 'scheduled' } } } }, + ]; + + const itemsDefaults: Record[] = itemsParams.map((params) => ({ + resource: 'post', + operation: 'create', + channelId: 'chan123|twitter', + postText: '', + shareMode: 'shareNow', + attachmentType: 'none', + schedulingType: 'automatic', + ...params, + })); + + const mockContext = { + getInputData: () => itemsDefaults.map(() => ({ json: {} })), + getNodeParameter: (name: string, itemIndex: number) => { + const defaults = itemsDefaults[itemIndex]; + if (!defaults || !(name in defaults)) { + return ''; + } + return defaults[name]; + }, + getCredentials: async () => ({ + apiKey: 'test-key', + apiUrl: 'https://api.buffer.com/graphql', + }), + getNode: () => ({ name: 'Buffer', type: 'buffer', typeVersion: 1, position: [0, 0] }), + helpers: { + httpRequestWithAuthentication: { + call: async (_thisArg: unknown, _credType: string, opts: { body: CapturedRequest }) => { + capturedRequests.push(opts.body); + const responseIndex = Math.min(capturedRequests.length - 1, responses.length - 1); + return responses[responseIndex]; + }, + }, + }, + continueOnFail: () => options.continueOnFail ?? false, + }; + + const returnData = (await node.execute.call(mockContext as never)) as unknown as INodeExecutionData[][]; + + return { capturedRequests, returnData: returnData[0] }; +} diff --git a/nodes/Buffer/__tests__/threads.test.ts b/nodes/Buffer/__tests__/threads.test.ts new file mode 100644 index 0000000..b728490 --- /dev/null +++ b/nodes/Buffer/__tests__/threads.test.ts @@ -0,0 +1,58 @@ +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Threads + // ------------------------------------------------------- + describe('Threads', () => { + const threadsDefaults = { + channelId: 'chan1|threads', + postText: 'Hello Threads', + }; + + it('should not include a metadata block', async () => { + const input = await executePostCreate(threadsDefaults); + expect(input).not.toHaveProperty('metadata'); + }); + + it('should succeed with text only', async () => { + const input = await executePostCreate(threadsDefaults); + expect(input.text).toBe('Hello Threads'); + expect(input.assets).toEqual([]); + }); + + it('should succeed with an image attachment', async () => { + const input = await executePostCreate({ + ...threadsDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.assets).toEqual([{ image: { url: 'https://example.com/photo.jpg' } }]); + }); + + it('should succeed with a video attachment', async () => { + const input = await executePostCreate({ + ...threadsDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...threadsDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...threadsDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/tiktok.test.ts b/nodes/Buffer/__tests__/tiktok.test.ts new file mode 100644 index 0000000..6604492 --- /dev/null +++ b/nodes/Buffer/__tests__/tiktok.test.ts @@ -0,0 +1,47 @@ +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // TikTok + // ------------------------------------------------------- + describe('TikTok', () => { + const tiktokDefaults = { + channelId: 'chan1|tiktok', + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }; + + it('should throw an error when there is no image or video attachment', async () => { + await expect( + executePostCreate({ + ...tiktokDefaults, + attachmentType: 'none', + }), + ).rejects.toThrow('TikTok posts require an image or video attachment'); + }); + + it('should succeed with a video attachment and no TikTok metadata block', async () => { + const input = await executePostCreate(tiktokDefaults); + expect(input.metadata).toBeUndefined(); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should succeed with an image attachment and no TikTok metadata block', async () => { + const input = await executePostCreate({ + ...tiktokDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.metadata).toBeUndefined(); + expect(input.assets).toEqual([{ image: { url: 'https://example.com/photo.jpg' } }]); + }); + + it('should support notification scheduling and not force it to change', async () => { + const input = await executePostCreate({ + ...tiktokDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('notification'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/twitter.test.ts b/nodes/Buffer/__tests__/twitter.test.ts new file mode 100644 index 0000000..e570799 --- /dev/null +++ b/nodes/Buffer/__tests__/twitter.test.ts @@ -0,0 +1,58 @@ +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // Twitter / X + // ------------------------------------------------------- + describe('Twitter', () => { + const twitterDefaults = { + channelId: 'chan1|twitter', + postText: 'Hello Twitter', + }; + + it('should not include a metadata block', async () => { + const input = await executePostCreate(twitterDefaults); + expect(input).not.toHaveProperty('metadata'); + }); + + it('should succeed with text only', async () => { + const input = await executePostCreate(twitterDefaults); + expect(input.text).toBe('Hello Twitter'); + expect(input.assets).toEqual([]); + }); + + it('should succeed with an image attachment', async () => { + const input = await executePostCreate({ + ...twitterDefaults, + attachmentType: 'image', + imageUrl: 'https://example.com/photo.jpg', + }); + expect(input.assets).toEqual([{ image: { url: 'https://example.com/photo.jpg' } }]); + }); + + it('should succeed with a video attachment', async () => { + const input = await executePostCreate({ + ...twitterDefaults, + attachmentType: 'video', + videoUrl: 'https://example.com/video.mp4', + }); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should force schedulingType to automatic regardless of the field value', async () => { + const input = await executePostCreate({ + ...twitterDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('automatic'); + }); + + it('should keep schedulingType as automatic when already set', async () => { + const input = await executePostCreate({ + ...twitterDefaults, + schedulingType: 'automatic', + }); + expect(input.schedulingType).toBe('automatic'); + }); + }); +}); diff --git a/nodes/Buffer/__tests__/youtube.test.ts b/nodes/Buffer/__tests__/youtube.test.ts new file mode 100644 index 0000000..8e7079e --- /dev/null +++ b/nodes/Buffer/__tests__/youtube.test.ts @@ -0,0 +1,106 @@ +import type { IDataObject } from 'n8n-workflow'; +import { executePostCreate } from './test-helpers'; + +describe('Buffer Node - Create Post', () => { + // ------------------------------------------------------- + // YouTube + // ------------------------------------------------------- + describe('YouTube', () => { + const youtubeDefaults = { + channelId: 'chan1|youtube', + youtubeTitle: 'My Video', + youtubeCategoryId: '22', + youtubePrivacy: 'public', + youtubeLicense: 'youtube', + youtubeMadeForKids: false, + youtubeEmbeddable: true, + youtubeNotifySubscribers: true, + videoUrl: 'https://example.com/video.mp4', + }; + + it('should send required metadata: title and categoryId', async () => { + const input = await executePostCreate(youtubeDefaults); + expect(input.metadata).toEqual({ + youtube: { + title: 'My Video', + categoryId: '22', + privacy: 'public', + license: 'youtube', + madeForKids: false, + embeddable: true, + notifySubscribers: true, + }, + }); + }); + + it.each(['public', 'private', 'unlisted'])('should support privacy status: %s', async (privacy) => { + const input = await executePostCreate({ + ...youtubeDefaults, + youtubePrivacy: privacy, + }); + const meta = (input.metadata as IDataObject).youtube as IDataObject; + expect(meta.privacy).toBe(privacy); + }); + + it.each(['youtube', 'creativeCommon'])('should support license: %s', async (license) => { + const input = await executePostCreate({ + ...youtubeDefaults, + youtubeLicense: license, + }); + const meta = (input.metadata as IDataObject).youtube as IDataObject; + expect(meta.license).toBe(license); + }); + + it('should send madeForKids as true when enabled', async () => { + const input = await executePostCreate({ + ...youtubeDefaults, + youtubeMadeForKids: true, + }); + const meta = (input.metadata as IDataObject).youtube as IDataObject; + expect(meta.madeForKids).toBe(true); + }); + + it('should send embeddable as false when disabled', async () => { + const input = await executePostCreate({ + ...youtubeDefaults, + youtubeEmbeddable: false, + }); + const meta = (input.metadata as IDataObject).youtube as IDataObject; + expect(meta.embeddable).toBe(false); + }); + + it('should send notifySubscribers as false when disabled', async () => { + const input = await executePostCreate({ + ...youtubeDefaults, + youtubeNotifySubscribers: false, + }); + const meta = (input.metadata as IDataObject).youtube as IDataObject; + expect(meta.notifySubscribers).toBe(false); + }); + + it('should throw an error when the title is missing', async () => { + await expect( + executePostCreate({ + ...youtubeDefaults, + youtubeTitle: '', + }), + ).rejects.toThrow('YouTube posts require a title'); + }); + + it('should force attachmentType to video regardless of the field value', async () => { + const input = await executePostCreate({ + ...youtubeDefaults, + attachmentType: 'none', + }); + expect(input.assets).toEqual([{ video: { url: 'https://example.com/video.mp4' } }]); + }); + + it('should support notification scheduling and not force it to change', async () => { + const input = await executePostCreate({ + ...youtubeDefaults, + schedulingType: 'notification', + }); + expect(input.schedulingType).toBe('notification'); + }); + }); +}); diff --git a/nodes/Buffer/actions/idea/create.ts b/nodes/Buffer/actions/idea/create.ts new file mode 100644 index 0000000..15184a3 --- /dev/null +++ b/nodes/Buffer/actions/idea/create.ts @@ -0,0 +1,98 @@ +import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import { executeGraphQL } from '../../transport/graphql'; + +export async function createIdea( + ctx: IExecuteFunctions, + apiUrl: string, + itemIndex: number, +): Promise { + const organizationId = ctx.getNodeParameter('organizationId', itemIndex) as string; + const text = ctx.getNodeParameter('text', itemIndex) as string; + const title = ctx.getNodeParameter('title', itemIndex) as string; + + // Build content input + const content: IDataObject = {}; + + if (text) { + content.text = text; + } + if (title) { + content.title = title; + } + + // Build the input + const input: IDataObject = { + organizationId, + content, + }; + + // GraphQL mutation + const mutation = ` + mutation CreateIdea($input: CreateIdeaInput!) { + createIdea(input: $input) { + ... on Idea { + id + organizationId + groupId + position + createdAt + updatedAt + content { + title + text + } + } + ... on IdeaResponse { + idea { + id + organizationId + groupId + position + createdAt + updatedAt + content { + title + text + } + } + refreshIdeas + } + ... on InvalidInputError { + message + } + ... on UnauthorizedError { + message + } + ... on UnexpectedError { + message + } + ... on LimitReachedError { + message + } + } + } + `; + + const response = await executeGraphQL(ctx, apiUrl, mutation, { input }); + + // Check for GraphQL errors + if (response.errors && response.errors.length > 0) { + throw new NodeApiError(ctx.getNode(), response.errors[0], { itemIndex }); + } + + const result = response.data?.createIdea; + + // Check for mutation-level errors + if (result?.message) { + throw new NodeApiError(ctx.getNode(), result, { itemIndex }); + } + + // Handle IdeaResponse wrapper + const idea = result?.idea || result; + + return { + json: idea, + pairedItem: { item: itemIndex }, + }; +} diff --git a/nodes/Buffer/actions/post/create.ts b/nodes/Buffer/actions/post/create.ts new file mode 100644 index 0000000..2006329 --- /dev/null +++ b/nodes/Buffer/actions/post/create.ts @@ -0,0 +1,221 @@ +import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import { executeGraphQL } from '../../transport/graphql'; +import { validateUrl } from '../../helpers/urlValidation'; +import { resolveSchedulingType } from './scheduling'; +import { validateInstagram, buildInstagramMetadata } from './networks/instagram'; +import { buildFacebookMetadata } from './networks/facebook'; +import { buildYoutubeMetadata } from './networks/youtube'; +import { validateGoogleBusiness, buildGoogleBusinessMetadata } from './networks/google-business'; +import { validatePinterest, buildPinterestMetadata } from './networks/pinterest'; +import { validateTikTok } from './networks/tiktok'; +import { validateMastodon } from './networks/mastodon'; +import { validateStartPage } from './networks/startpage'; + +export async function createPost( + ctx: IExecuteFunctions, + apiUrl: string, + itemIndex: number, +): Promise { + const i = itemIndex; + const channelIdComposite = ctx.getNodeParameter('channelId', i) as string; + // Check if channel is disconnected (format: "id|service|type|disconnected") + if (channelIdComposite.endsWith('|disconnected')) { + throw new NodeOperationError( + ctx.getNode(), + 'The selected channel is disconnected. Please reconnect it in Buffer before posting.', + { itemIndex: i }, + ); + } + // Extract channel ID from composite value (format: "id|service|type") + const channelId = channelIdComposite.split('|')[0]; + const postText = ctx.getNodeParameter('postText', i) as string; + const shareMode = ctx.getNodeParameter('shareMode', i) as string; + let attachmentType = ctx.getNodeParameter('attachmentType', i) as string; + const requestedSchedulingType = ctx.getNodeParameter('schedulingType', i) as string; + + // Add channel-specific post type metadata + const channelService = channelIdComposite.split('|')[1]; + const channelType = channelIdComposite.split('|')[2]; + const isFacebookGroup = channelService?.toLowerCase() === 'facebook' && channelType?.toLowerCase() === 'group'; + const isInstagramProfile = channelService?.toLowerCase() === 'instagram' && channelType?.toLowerCase() === 'profile'; + + const schedulingType = resolveSchedulingType(requestedSchedulingType, channelService, isFacebookGroup, isInstagramProfile); + + // YouTube posts only support video attachments; force it regardless of the field's value + if (channelService?.toLowerCase() === 'youtube') { + attachmentType = 'video'; + } + + // Instagram and TikTok posts require an image or video attachment + if (channelService?.toLowerCase() === 'instagram') { + validateInstagram(ctx, i, attachmentType); + } + if (channelService?.toLowerCase() === 'tiktok') { + validateTikTok(ctx, i, attachmentType); + } + + // Google Business posts do not support video attachments + if (channelService && ['google', 'googlebusiness', 'google_business'].includes(channelService.toLowerCase())) { + validateGoogleBusiness(ctx, i, attachmentType); + } + + // Start Page posts require text and do not support video attachments + if (channelService?.toLowerCase() === 'startpage') { + validateStartPage(ctx, i, attachmentType, postText); + } + + // Mastodon posts require at least text, an image, or a video + if (channelService?.toLowerCase() === 'mastodon') { + validateMastodon(ctx, i, attachmentType, postText); + } + + // Pinterest posts require a Board, an image/video attachment, and text + if (channelService?.toLowerCase() === 'pinterest') { + validatePinterest(ctx, i, attachmentType, postText); + } + + // Build the input + const input: IDataObject = { + channelId, + mode: shareMode, + schedulingType, + assets: [], + }; + + if (channelService && channelService.toLowerCase() === 'instagram') { + input.metadata = { instagram: buildInstagramMetadata(ctx, i, isInstagramProfile) }; + } else if (channelService && channelService.toLowerCase() === 'facebook') { + input.metadata = { facebook: buildFacebookMetadata(ctx, i, isFacebookGroup) }; + } else if (channelService && channelService.toLowerCase() === 'youtube') { + input.metadata = { youtube: buildYoutubeMetadata(ctx, i) }; + } else if (channelService && ['google', 'googlebusiness', 'google_business'].includes(channelService.toLowerCase())) { + input.metadata = { google: buildGoogleBusinessMetadata(ctx, i) }; + } else if (channelService?.toLowerCase() === 'pinterest') { + input.metadata = { pinterest: buildPinterestMetadata(ctx, i) }; + } + + // Add text if provided + if (postText) { + input.text = postText; + } + + // Handle image attachments + if (attachmentType === 'image') { + const imageUrl = ctx.getNodeParameter('imageUrl', i) as string; + validateUrl(ctx, imageUrl, i, { required: true, requiredLabel: 'Image URL', formatLabel: 'image' }); + + const imageInput: IDataObject = { url: imageUrl }; + + // Validate and add optional thumbnail URL + const imageThumbnailUrl = ctx.getNodeParameter('imageThumbnailUrl', i) as string; + if (imageThumbnailUrl && imageThumbnailUrl.trim() !== '') { + validateUrl(ctx, imageThumbnailUrl, i, { required: false, formatLabel: 'thumbnail' }); + imageInput.thumbnailUrl = imageThumbnailUrl; + } + + // Handle metadata (altText) + const imageAltText = ctx.getNodeParameter('imageAltText', i) as string; + if (imageAltText && imageAltText.trim() !== '') { + imageInput.metadata = { + altText: imageAltText, + }; + } + + input.assets = [{ image: imageInput }]; + } else if (attachmentType === 'video') { + const videoUrl = ctx.getNodeParameter('videoUrl', i) as string; + validateUrl(ctx, videoUrl, i, { required: true, requiredLabel: 'Video URL', formatLabel: 'video' }); + + const videoInput: IDataObject = { url: videoUrl }; + + // Validate and add optional thumbnail URL + const videoThumbnailUrl = ctx.getNodeParameter('videoThumbnailUrl', i) as string; + if (videoThumbnailUrl && videoThumbnailUrl.trim() !== '') { + validateUrl(ctx, videoThumbnailUrl, i, { required: false, formatLabel: 'thumbnail' }); + videoInput.thumbnailUrl = videoThumbnailUrl; + } + + input.assets = [{ video: videoInput }]; + } + + // Add dueAt if custom scheduled + if (shareMode === 'customScheduled') { + const dueAt = ctx.getNodeParameter('dueAt', i) as string; + // Ensure ISO 8601 format with timezone + input.dueAt = new Date(dueAt).toISOString(); + } + + // GraphQL mutation + const mutation = ` + mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + ... on PostActionSuccess { + post { + id + status + text + dueAt + sentAt + createdAt + updatedAt + channelId + channelService + shareMode + isCustomScheduled + externalLink + assets { + id + type + mimeType + source + thumbnail + } + } + } + ... on InvalidInputError { + message + } + ... on UnauthorizedError { + message + } + ... on UnexpectedError { + message + } + ... on NotFoundError { + message + } + ... on LimitReachedError { + message + } + ... on RestProxyError { + message + code + link + } + } + } + `; + + const response = await executeGraphQL(ctx, apiUrl, mutation, { input }); + + // Check for GraphQL errors + if (response.errors && response.errors.length > 0) { + throw new NodeApiError(ctx.getNode(), response.errors[0], { itemIndex: i }); + } + + const result = response.data?.createPost; + + // Check for mutation-level errors + if (result?.message) { + throw new NodeApiError(ctx.getNode(), result, { itemIndex: i }); + } + + // Return the post + const post = result?.post; + + return { + json: post, + pairedItem: { item: i }, + }; +} diff --git a/nodes/Buffer/actions/post/networks/facebook.ts b/nodes/Buffer/actions/post/networks/facebook.ts new file mode 100644 index 0000000..073f3ea --- /dev/null +++ b/nodes/Buffer/actions/post/networks/facebook.ts @@ -0,0 +1,21 @@ +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; + +export function buildFacebookMetadata( + ctx: IExecuteFunctions, + itemIndex: number, + isFacebookGroup: boolean, +): IDataObject { + if (isFacebookGroup) { + // Facebook Groups only support the "post" type; no first comment or link attachment + return { type: 'post' }; + } + + const facebookPostType = ctx.getNodeParameter('facebookPostType', itemIndex) as string; + const facebookFirstComment = ctx.getNodeParameter('facebookFirstComment', itemIndex) as string; + const facebookLinkAttachment = ctx.getNodeParameter('facebookLinkAttachment', itemIndex) as string; + + const meta: IDataObject = { type: facebookPostType }; + if (facebookFirstComment) meta.firstComment = facebookFirstComment; + if (facebookLinkAttachment) meta.linkAttachment = { url: facebookLinkAttachment }; + return meta; +} diff --git a/nodes/Buffer/actions/post/networks/google-business.ts b/nodes/Buffer/actions/post/networks/google-business.ts new file mode 100644 index 0000000..5b76e3d --- /dev/null +++ b/nodes/Buffer/actions/post/networks/google-business.ts @@ -0,0 +1,92 @@ +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { TIME_FORMAT_REGEX, toDateOnlyIso, combineDateAndTime } from '../../../helpers/dateTime'; + +// Google Business posts do not support video attachments +export function validateGoogleBusiness(ctx: IExecuteFunctions, itemIndex: number, attachmentType: string): void { + if (attachmentType === 'video') { + throw new NodeOperationError( + ctx.getNode(), + 'Google Business posts do not support video attachments. Please use an image or no attachment.', + { itemIndex }, + ); + } +} + +export function buildGoogleBusinessMetadata(ctx: IExecuteFunctions, itemIndex: number): IDataObject { + const googlePostType = ctx.getNodeParameter('googlePostType', itemIndex) as string; + const googleMeta: IDataObject = { type: googlePostType }; + + if (googlePostType === 'whats_new') { + const button = ctx.getNodeParameter('googleWhatsNewButton', itemIndex) as string; + const link = ctx.getNodeParameter('googleWhatsNewLink', itemIndex) as string; + const details: IDataObject = {}; + if (button) details.button = button; + if (link) details.link = link; + googleMeta.detailsWhatsNew = details; + } else if (googlePostType === 'offer') { + const title = ctx.getNodeParameter('googleOfferTitle', itemIndex) as string; + const startDate = ctx.getNodeParameter('googleOfferStartDate', itemIndex) as string; + const endDate = ctx.getNodeParameter('googleOfferEndDate', itemIndex) as string; + const code = ctx.getNodeParameter('googleOfferCode', itemIndex) as string; + const link = ctx.getNodeParameter('googleOfferLink', itemIndex) as string; + const terms = ctx.getNodeParameter('googleOfferTerms', itemIndex) as string; + if (title) googleMeta.title = title; + const details: IDataObject = { + title, + // Google Business offers only support a calendar date, not a time of day + startDate: toDateOnlyIso(startDate), + endDate: toDateOnlyIso(endDate), + }; + if (code) details.code = code; + if (link) details.link = link; + if (terms) details.terms = terms; + googleMeta.detailsOffer = details; + } else if (googlePostType === 'event') { + const title = ctx.getNodeParameter('googleEventTitle', itemIndex) as string; + const startDate = ctx.getNodeParameter('googleEventStartDate', itemIndex) as string; + const endDate = ctx.getNodeParameter('googleEventEndDate', itemIndex) as string; + const hasTime = ctx.getNodeParameter('googleEventHasTime', itemIndex) as boolean; + const button = ctx.getNodeParameter('googleEventButton', itemIndex) as string; + const link = ctx.getNodeParameter('googleEventLink', itemIndex) as string; + + let eventStartDate: string; + let eventEndDate: string; + if (hasTime) { + const startTime = ctx.getNodeParameter('googleEventStartTime', itemIndex) as string; + const endTime = ctx.getNodeParameter('googleEventEndTime', itemIndex) as string; + if (!startTime || !endTime) { + throw new NodeOperationError( + ctx.getNode(), + 'Please provide both an "Event Start Time" and "Event End Time", or disable "Specify Event Time" to create an all-day event.', + { itemIndex }, + ); + } + if (!TIME_FORMAT_REGEX.test(startTime) || !TIME_FORMAT_REGEX.test(endTime)) { + throw new NodeOperationError( + ctx.getNode(), + 'Event times must be in 24-hour HH:mm format, e.g. 14:30.', + { itemIndex }, + ); + } + eventStartDate = combineDateAndTime(startDate, startTime); + eventEndDate = combineDateAndTime(endDate, endTime); + } else { + eventStartDate = toDateOnlyIso(startDate); + eventEndDate = toDateOnlyIso(endDate); + } + + if (title) googleMeta.title = title; + const details: IDataObject = { + title, + startDate: eventStartDate, + endDate: eventEndDate, + isFullDayEvent: !hasTime, + }; + if (button) details.button = button; + if (link) details.link = link; + googleMeta.detailsEvent = details; + } + + return googleMeta; +} diff --git a/nodes/Buffer/actions/post/networks/instagram.ts b/nodes/Buffer/actions/post/networks/instagram.ts new file mode 100644 index 0000000..33cbded --- /dev/null +++ b/nodes/Buffer/actions/post/networks/instagram.ts @@ -0,0 +1,39 @@ +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +// Instagram posts require an image or video attachment +export function validateInstagram(ctx: IExecuteFunctions, itemIndex: number, attachmentType: string): void { + if (attachmentType === 'none') { + throw new NodeOperationError( + ctx.getNode(), + 'Instagram posts require an image or video attachment. Please set the "Attachment Type" field.', + { itemIndex }, + ); + } +} + +export function buildInstagramMetadata( + ctx: IExecuteFunctions, + itemIndex: number, + isInstagramProfile: boolean, +): IDataObject { + if (isInstagramProfile) { + // Instagram Profiles only support the "post" type, no first comment or link. + // shouldShareToFeed has no real effect for a personal profile, but the API schema + // requires the field to be present (non-nullable), so default it to true. + return { type: 'post', shouldShareToFeed: true }; + } + + const instagramPostType = ctx.getNodeParameter('instagramPostType', itemIndex) as string; + const shouldShareToFeed = ctx.getNodeParameter('instagramShareToFeed', itemIndex) as boolean; + const instagramFirstComment = ctx.getNodeParameter('instagramFirstComment', itemIndex) as string; + const instagramLink = ctx.getNodeParameter('instagramLink', itemIndex) as string; + + const meta: IDataObject = { + type: instagramPostType, + shouldShareToFeed, + }; + if (instagramFirstComment) meta.firstComment = instagramFirstComment; + if (instagramLink) meta.link = instagramLink; + return meta; +} diff --git a/nodes/Buffer/actions/post/networks/mastodon.ts b/nodes/Buffer/actions/post/networks/mastodon.ts new file mode 100644 index 0000000..08499db --- /dev/null +++ b/nodes/Buffer/actions/post/networks/mastodon.ts @@ -0,0 +1,18 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +// Mastodon posts require at least text, an image, or a video +export function validateMastodon( + ctx: IExecuteFunctions, + itemIndex: number, + attachmentType: string, + postText: string, +): void { + if (attachmentType === 'none' && (!postText || postText.trim() === '')) { + throw new NodeOperationError( + ctx.getNode(), + 'Mastodon posts require text, an image, or a video.', + { itemIndex }, + ); + } +} diff --git a/nodes/Buffer/actions/post/networks/pinterest.ts b/nodes/Buffer/actions/post/networks/pinterest.ts new file mode 100644 index 0000000..688b522 --- /dev/null +++ b/nodes/Buffer/actions/post/networks/pinterest.ts @@ -0,0 +1,45 @@ +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +// Pinterest posts require an image/video attachment and text (the Board requirement is +// enforced separately in buildPinterestMetadata, since it needs the loaded board value). +export function validatePinterest( + ctx: IExecuteFunctions, + itemIndex: number, + attachmentType: string, + postText: string, +): void { + if (attachmentType === 'none') { + throw new NodeOperationError( + ctx.getNode(), + 'Pinterest posts require an image or video attachment.', + { itemIndex }, + ); + } + if (!postText || postText.trim() === '') { + throw new NodeOperationError( + ctx.getNode(), + 'Pinterest posts require text content.', + { itemIndex }, + ); + } +} + +export function buildPinterestMetadata(ctx: IExecuteFunctions, itemIndex: number): IDataObject { + const pinterestBoardId = ctx.getNodeParameter('pinterestBoardId', itemIndex) as string; + if (!pinterestBoardId) { + throw new NodeOperationError( + ctx.getNode(), + 'Pinterest posts require a Board. Please set the "Pinterest Board" field.', + { itemIndex }, + ); + } + + const pinterestTitle = ctx.getNodeParameter('pinterestTitle', itemIndex) as string; + const pinterestUrl = ctx.getNodeParameter('pinterestUrl', itemIndex) as string; + + const meta: IDataObject = { boardServiceId: pinterestBoardId }; + if (pinterestTitle) meta.title = pinterestTitle; + if (pinterestUrl) meta.url = pinterestUrl; + return meta; +} diff --git a/nodes/Buffer/actions/post/networks/startpage.ts b/nodes/Buffer/actions/post/networks/startpage.ts new file mode 100644 index 0000000..1ea5f07 --- /dev/null +++ b/nodes/Buffer/actions/post/networks/startpage.ts @@ -0,0 +1,25 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +// Start Page posts require text and do not support video attachments +export function validateStartPage( + ctx: IExecuteFunctions, + itemIndex: number, + attachmentType: string, + postText: string, +): void { + if (attachmentType === 'video') { + throw new NodeOperationError( + ctx.getNode(), + 'Start Page posts do not support video attachments.', + { itemIndex }, + ); + } + if (!postText || postText.trim() === '') { + throw new NodeOperationError( + ctx.getNode(), + 'Start Page posts require text content.', + { itemIndex }, + ); + } +} diff --git a/nodes/Buffer/actions/post/networks/tiktok.ts b/nodes/Buffer/actions/post/networks/tiktok.ts new file mode 100644 index 0000000..52d6160 --- /dev/null +++ b/nodes/Buffer/actions/post/networks/tiktok.ts @@ -0,0 +1,13 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +// TikTok posts require an image or video attachment +export function validateTikTok(ctx: IExecuteFunctions, itemIndex: number, attachmentType: string): void { + if (attachmentType === 'none') { + throw new NodeOperationError( + ctx.getNode(), + 'TikTok posts require an image or video attachment. Please set the "Attachment Type" field.', + { itemIndex }, + ); + } +} diff --git a/nodes/Buffer/actions/post/networks/youtube.ts b/nodes/Buffer/actions/post/networks/youtube.ts new file mode 100644 index 0000000..768ca04 --- /dev/null +++ b/nodes/Buffer/actions/post/networks/youtube.ts @@ -0,0 +1,30 @@ +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +export function buildYoutubeMetadata(ctx: IExecuteFunctions, itemIndex: number): IDataObject { + const youtubeTitle = ctx.getNodeParameter('youtubeTitle', itemIndex) as string; + if (!youtubeTitle) { + throw new NodeOperationError( + ctx.getNode(), + 'YouTube posts require a title. Please set the "YouTube Title" field.', + { itemIndex }, + ); + } + + const youtubeCategoryId = ctx.getNodeParameter('youtubeCategoryId', itemIndex) as string; + const youtubePrivacy = ctx.getNodeParameter('youtubePrivacy', itemIndex) as string; + const youtubeLicense = ctx.getNodeParameter('youtubeLicense', itemIndex) as string; + const youtubeMadeForKids = ctx.getNodeParameter('youtubeMadeForKids', itemIndex) as boolean; + const youtubeEmbeddable = ctx.getNodeParameter('youtubeEmbeddable', itemIndex) as boolean; + const youtubeNotifySubscribers = ctx.getNodeParameter('youtubeNotifySubscribers', itemIndex) as boolean; + + return { + title: youtubeTitle, + categoryId: youtubeCategoryId, + privacy: youtubePrivacy, + license: youtubeLicense, + madeForKids: youtubeMadeForKids, + embeddable: youtubeEmbeddable, + notifySubscribers: youtubeNotifySubscribers, + }; +} diff --git a/nodes/Buffer/actions/post/scheduling.ts b/nodes/Buffer/actions/post/scheduling.ts new file mode 100644 index 0000000..cefd378 --- /dev/null +++ b/nodes/Buffer/actions/post/scheduling.ts @@ -0,0 +1,20 @@ +// Facebook Groups and Instagram Profiles only support notification scheduling; Facebook Pages +// and every other network except Instagram, TikTok, and YouTube only support automatic +// scheduling. This resolves the effective schedulingType regardless of what the (possibly +// stale, or simply not applicable) form field value was. +export function resolveSchedulingType( + requestedSchedulingType: string, + channelService: string | undefined, + isFacebookGroup: boolean, + isInstagramProfile: boolean, +): string { + if (isFacebookGroup || isInstagramProfile) { + return 'notification'; + } + + if (channelService && !['instagram', 'tiktok', 'youtube'].includes(channelService.toLowerCase())) { + return 'automatic'; + } + + return requestedSchedulingType; +} diff --git a/nodes/Buffer/descriptions/facebook.description.ts b/nodes/Buffer/descriptions/facebook.description.ts new file mode 100644 index 0000000..1366dde --- /dev/null +++ b/nodes/Buffer/descriptions/facebook.description.ts @@ -0,0 +1,75 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const facebookProperties: INodeProperties[] = [ + { + displayName: 'Facebook Post Type', + name: 'facebookPostType', + type: 'options', + options: [ + { + name: 'Post', + value: 'post', + description: 'A standard Facebook feed post', + }, + { + name: 'Story', + value: 'story', + description: 'A Facebook story', + }, + { + name: 'Reel', + value: 'reel', + description: 'A Facebook reel', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['facebook', 'Facebook', 'FACEBOOK'], + }, + hide: { + channelType: ['group', 'Group', 'GROUP'], + }, + }, + default: 'post', + description: 'The type of Facebook post to create. Not applicable to Facebook Groups.', + }, + { + displayName: 'First Comment', + name: 'facebookFirstComment', + type: 'string', + typeOptions: { + rows: 2, + }, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['facebook', 'Facebook', 'FACEBOOK'], + }, + hide: { + channelType: ['group', 'Group', 'GROUP'], + }, + }, + default: '', + description: 'Text for the first comment on the Facebook post. Not applicable to Facebook Groups.', + }, + { + displayName: 'Link Attachment URL', + name: 'facebookLinkAttachment', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['facebook', 'Facebook', 'FACEBOOK'], + }, + hide: { + channelType: ['group', 'Group', 'GROUP'], + }, + }, + default: '', + description: 'URL for a link preview attachment. Mutually exclusive with video assets. Not applicable to Facebook Groups.', + }, +]; diff --git a/nodes/Buffer/descriptions/google-business.description.ts b/nodes/Buffer/descriptions/google-business.description.ts new file mode 100644 index 0000000..cfcbce9 --- /dev/null +++ b/nodes/Buffer/descriptions/google-business.description.ts @@ -0,0 +1,309 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const googleBusinessProperties: INodeProperties[] = [ + { + displayName: 'Google Business Post Type', + name: 'googlePostType', + type: 'options', + options: [ + { + name: "What's New", + value: 'whats_new', + description: 'A standard Google Business post', + }, + { + name: 'Offer', + value: 'offer', + description: 'A Google Business offer', + }, + { + name: 'Event', + value: 'event', + description: 'A Google Business event', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + }, + }, + default: 'whats_new', + description: 'The type of Google Business post to create', + }, + // ---------------------------------- + // Google Business: What's New fields + // ---------------------------------- + { + displayName: 'Action Button', + name: 'googleWhatsNewButton', + type: 'options', + options: [ + { name: 'Book', value: 'book' }, + { name: 'Call', value: 'call' }, + { name: 'Learn More', value: 'learn_more' }, + { name: 'None', value: 'none' }, + { name: 'Order', value: 'order' }, + { name: 'Shop', value: 'shop' }, + { name: 'Sign Up', value: 'signup' }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['whats_new'], + }, + }, + default: 'none', + description: 'The call-to-action button for the post', + }, + { + displayName: 'Action Button Link', + name: 'googleWhatsNewLink', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['whats_new'], + }, + }, + default: '', + description: 'URL for the action button', + }, + // ---------------------------------- + // Google Business: Offer fields + // ---------------------------------- + { + displayName: 'Offer Title', + name: 'googleOfferTitle', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['offer'], + }, + }, + default: '', + description: 'Title of the offer', + }, + { + displayName: 'Offer Start Date', + name: 'googleOfferStartDate', + type: 'dateTime', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['offer'], + }, + }, + default: '', + description: 'Start date of the offer. Only the date is used; Google Business offers do not support a specific time.', + }, + { + displayName: 'Offer End Date', + name: 'googleOfferEndDate', + type: 'dateTime', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['offer'], + }, + }, + default: '', + description: 'End date of the offer. Only the date is used; Google Business offers do not support a specific time.', + }, + { + displayName: 'Coupon Code', + name: 'googleOfferCode', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['offer'], + }, + }, + default: '', + description: 'Coupon code for the offer', + }, + { + displayName: 'Offer Link', + name: 'googleOfferLink', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['offer'], + }, + }, + default: '', + description: 'Link to the offer', + }, + { + displayName: 'Terms & Conditions', + name: 'googleOfferTerms', + type: 'string', + typeOptions: { + rows: 3, + }, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['offer'], + }, + }, + default: '', + description: 'Terms and conditions of the offer', + }, + // ---------------------------------- + // Google Business: Event fields + // ---------------------------------- + { + displayName: 'Event Title', + name: 'googleEventTitle', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + }, + }, + default: '', + description: 'Title of the event', + }, + { + displayName: 'Event Start Date', + name: 'googleEventStartDate', + type: 'dateTime', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + }, + }, + default: '', + description: 'Start date of the event. Only the date is used here; enable "Specify Event Time" below to add a start time.', + }, + { + displayName: 'Event End Date', + name: 'googleEventEndDate', + type: 'dateTime', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + }, + }, + default: '', + description: 'End date of the event. Only the date is used here; enable "Specify Event Time" below to add an end time.', + }, + { + displayName: 'Specify Event Time', + name: 'googleEventHasTime', + type: 'boolean', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + }, + }, + default: false, + description: 'Whether to set a specific start/end time for the event. If disabled, the event is posted as an all-day event.', + }, + { + displayName: 'Event Start Time', + name: 'googleEventStartTime', + type: 'string', + placeholder: 'e.g. 14:30', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + googleEventHasTime: [true], + }, + }, + default: '', + description: 'Start time of the event, in 24-hour HH:mm format', + }, + { + displayName: 'Event End Time', + name: 'googleEventEndTime', + type: 'string', + placeholder: 'e.g. 18:00', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + googleEventHasTime: [true], + }, + }, + default: '', + description: 'End time of the event, in 24-hour HH:mm format', + }, + { + displayName: 'Action Button', + name: 'googleEventButton', + type: 'options', + options: [ + { name: 'Book', value: 'book' }, + { name: 'Call', value: 'call' }, + { name: 'Learn More', value: 'learn_more' }, + { name: 'None', value: 'none' }, + { name: 'Order', value: 'order' }, + { name: 'Shop', value: 'shop' }, + { name: 'Sign Up', value: 'signup' }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + }, + }, + default: 'none', + description: 'The call-to-action button for the event', + }, + { + displayName: 'Action Button Link', + name: 'googleEventLink', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + googlePostType: ['event'], + }, + }, + default: '', + description: 'URL for the action button', + }, +]; diff --git a/nodes/Buffer/descriptions/idea.description.ts b/nodes/Buffer/descriptions/idea.description.ts new file mode 100644 index 0000000..92710fe --- /dev/null +++ b/nodes/Buffer/descriptions/idea.description.ts @@ -0,0 +1,76 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const ideaProperties: INodeProperties[] = [ + // ---------------------------------- + // Operations + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['idea'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new idea', + action: 'Create an idea', + }, + ], + default: 'create', + }, + // ---------------------------------- + // Idea: Create + // ---------------------------------- + { + displayName: 'Organization Name or ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + required: true, + displayOptions: { + show: { + resource: ['idea'], + operation: ['create'], + }, + }, + default: '', + description: 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + rows: 4, + }, + displayOptions: { + show: { + resource: ['idea'], + operation: ['create'], + }, + }, + default: '', + description: 'The main body text or description of the idea', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + displayOptions: { + show: { + resource: ['idea'], + operation: ['create'], + }, + }, + default: '', + description: 'Title or headline of the idea (optional)', + }, +]; diff --git a/nodes/Buffer/descriptions/index.ts b/nodes/Buffer/descriptions/index.ts new file mode 100644 index 0000000..47f8809 --- /dev/null +++ b/nodes/Buffer/descriptions/index.ts @@ -0,0 +1,22 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { ideaProperties } from './idea.description'; +import { postCoreProperties, postAttachmentProperties, postAssetProperties } from './post.description'; +import { googleBusinessProperties } from './google-business.description'; +import { pinterestProperties } from './pinterest.description'; +import { facebookProperties } from './facebook.description'; +import { instagramProperties } from './instagram.description'; +import { youtubeProperties } from './youtube.description'; + +// Concatenated in the same order the fields originally appeared in Buffer.node.ts's `properties` +// array, so the field order in the n8n UI is unchanged by the split into per-resource/network files. +export const properties: INodeProperties[] = [ + ...ideaProperties, + ...postCoreProperties, + ...googleBusinessProperties, + ...pinterestProperties, + ...facebookProperties, + ...instagramProperties, + ...youtubeProperties, + ...postAttachmentProperties, + ...postAssetProperties, +]; diff --git a/nodes/Buffer/descriptions/instagram.description.ts b/nodes/Buffer/descriptions/instagram.description.ts new file mode 100644 index 0000000..9ed3c1d --- /dev/null +++ b/nodes/Buffer/descriptions/instagram.description.ts @@ -0,0 +1,92 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const instagramProperties: INodeProperties[] = [ + { + displayName: 'Instagram Post Type', + name: 'instagramPostType', + type: 'options', + options: [ + { + name: 'Post', + value: 'post', + description: 'A standard Instagram feed post', + }, + { + name: 'Story', + value: 'story', + description: 'An Instagram story', + }, + { + name: 'Reel', + value: 'reel', + description: 'An Instagram reel', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['instagram', 'Instagram', 'INSTAGRAM'], + }, + hide: { + channelType: ['profile', 'Profile', 'PROFILE'], + }, + }, + default: 'post', + description: 'The type of Instagram post to create. Not applicable to Instagram Profiles.', + }, + { + displayName: 'First Comment', + name: 'instagramFirstComment', + type: 'string', + typeOptions: { + rows: 2, + }, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['instagram', 'Instagram', 'INSTAGRAM'], + }, + hide: { + channelType: ['profile', 'Profile', 'PROFILE'], + }, + }, + default: '', + description: 'Text for the first comment on the Instagram post. Not applicable to Instagram Profiles.', + }, + { + displayName: 'Shop Grid Link', + name: 'instagramLink', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['instagram', 'Instagram', 'INSTAGRAM'], + }, + hide: { + channelType: ['profile', 'Profile', 'PROFILE'], + }, + }, + default: '', + description: 'Shop Grid link for the post. Not applicable to Instagram Profiles.', + }, + { + displayName: 'Share to Feed', + name: 'instagramShareToFeed', + type: 'boolean', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['instagram', 'Instagram', 'INSTAGRAM'], + }, + hide: { + channelType: ['profile', 'Profile', 'PROFILE'], + }, + }, + default: true, + description: 'Whether the post should also appear on your Instagram feed (relevant for reels and stories). Not applicable to Instagram Profiles.', + }, +]; diff --git a/nodes/Buffer/descriptions/pinterest.description.ts b/nodes/Buffer/descriptions/pinterest.description.ts new file mode 100644 index 0000000..75e8d00 --- /dev/null +++ b/nodes/Buffer/descriptions/pinterest.description.ts @@ -0,0 +1,50 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const pinterestProperties: INodeProperties[] = [ + { + displayName: 'Pinterest Board Name or ID', + name: 'pinterestBoardId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getPinterestBoards', + loadOptionsDependsOn: ['channelId'], + }, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['pinterest', 'Pinterest', 'PINTEREST'], + }, + }, + default: '', + description: 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Pinterest Title', + name: 'pinterestTitle', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['pinterest', 'Pinterest', 'PINTEREST'], + }, + }, + default: '', + description: 'The title of the Pin', + }, + { + displayName: 'Pinterest Destination URL', + name: 'pinterestUrl', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['pinterest', 'Pinterest', 'PINTEREST'], + }, + }, + default: '', + description: 'The destination URL for the Pin', + }, +]; diff --git a/nodes/Buffer/descriptions/post.description.ts b/nodes/Buffer/descriptions/post.description.ts new file mode 100644 index 0000000..f2d92f0 --- /dev/null +++ b/nodes/Buffer/descriptions/post.description.ts @@ -0,0 +1,411 @@ +import type { INodeProperties } from 'n8n-workflow'; + +// Resource, Post's Operation field, core post fields (channel/text/scheduling), and both +// schedulingType field variants (see the comment on the field below for why there are two). +export const postCoreProperties: INodeProperties[] = [ + // ---------------------------------- + // Resources + // ---------------------------------- + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Idea', + value: 'idea', + }, + { + name: 'Post', + value: 'post', + }, + ], + default: 'idea', + }, + // ---------------------------------- + // Operations + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['post'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new post', + action: 'Create a post', + }, + ], + default: 'create', + }, + // ---------------------------------- + // Post: Create + // ---------------------------------- + { + displayName: 'Organization Name or ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + required: true, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + }, + }, + default: '', + description: 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Channel Name or ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + loadOptionsDependsOn: ['organizationId'], + }, + required: true, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + }, + }, + default: '', + description: 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Channel Service', + name: 'channelService', + type: 'hidden', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + }, + }, + default: '={{ $parameter["channelId"].split("|")[1] }}', + description: 'The service type of the selected channel (auto-populated)', + }, + { + displayName: 'Channel Type', + name: 'channelType', + type: 'hidden', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + }, + }, + default: '={{ $parameter["channelId"].split("|")[2] }}', + description: 'The type of the selected channel, e.g. page or group (auto-populated)', + }, + { + displayName: 'Text', + name: 'postText', + type: 'string', + typeOptions: { + rows: 4, + }, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + }, + }, + default: '', + description: 'The text content of the post (optional for image posts)', + }, + { + displayName: 'Share Mode', + name: 'shareMode', + type: 'options', + options: [ + { + name: 'Share Now', + value: 'shareNow', + description: 'Publish the post immediately', + }, + { + name: 'Add to Queue', + value: 'addToQueue', + description: 'Add the post to the publishing queue', + }, + { + name: 'Share Next', + value: 'shareNext', + description: 'Add the post to the front of the queue', + }, + { + name: 'Custom Schedule', + value: 'customScheduled', + description: 'Schedule the post for a specific time', + }, + ], + required: true, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + }, + }, + default: 'shareNow', + description: 'When to publish the post', + }, + { + displayName: 'Scheduled Time', + name: 'dueAt', + type: 'dateTime', + required: true, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + shareMode: ['customScheduled'], + }, + }, + default: '', + description: 'The date and time to publish the post. Example: 2026-03-26T10:28:47.545Z.', + }, + { + // Per-option displayOptions (hiding just "Automatic") are not reliably enforced by n8n's + // UI when the comparator is a hidden, expression-derived field like channelType. Instead, + // this field is hidden entirely for Facebook Pages/Groups and Instagram Profiles (Facebook + // Pages only support automatic scheduling, so they fall back to this field's default) and + // replaced with the notification-only field below for Groups/Profiles, using field-level + // displayOptions which do work correctly. + displayName: 'Scheduling Mode', + name: 'schedulingType', + type: 'options', + options: [ + { + name: 'Automatic', + value: 'automatic', + description: 'Post will be published automatically at the scheduled time', + }, + { + name: 'Notification', + value: 'notification', + description: 'You will receive a notification to manually publish the post', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['instagram', 'tiktok', 'youtube', 'Instagram', 'TikTok', 'YouTube', 'INSTAGRAM', 'TIKTOK', 'YOUTUBE'], + }, + hide: { + channelType: ['profile', 'Profile', 'PROFILE'], + }, + }, + default: 'automatic', + description: 'How the post should be scheduled', + }, + { + displayName: 'Scheduling Mode', + name: 'schedulingType', + type: 'options', + options: [ + { + name: 'Notification', + value: 'notification', + description: 'You will receive a notification to manually publish the post', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['facebook', 'Facebook', 'FACEBOOK', 'instagram', 'Instagram', 'INSTAGRAM'], + channelType: ['group', 'Group', 'GROUP', 'profile', 'Profile', 'PROFILE'], + }, + }, + default: 'notification', + description: 'Facebook Groups and Instagram Profiles only support notification scheduling', + }, +]; + +// The three attachmentType field variants: generic (hidden for YouTube/Google Business), +// YouTube-only (forces 'video'), and Google Business-only (no video option). +export const postAttachmentProperties: INodeProperties[] = [ + { + // YouTube posts are always videos, and Google Business does not support video, so + // this field is hidden for those channels and replaced with the network-specific + // fields below (see the Scheduling Mode fields above for why this uses field-level + // displayOptions rather than per-option displayOptions). + displayName: 'Attachment Type', + name: 'attachmentType', + type: 'options', + options: [ + { + name: 'No Attachment', + value: 'none', + description: 'Create a text-only post', + }, + { + name: 'Image', + value: 'image', + description: 'Create a post with an image', + }, + { + name: 'Video', + value: 'video', + description: 'Create a post with a video', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + }, + hide: { + channelService: [ + 'youtube', 'YouTube', 'YOUTUBE', + 'google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS', + ], + }, + }, + default: 'none', + description: 'Type of attachment to add to the post', + }, + { + displayName: 'Attachment Type', + name: 'attachmentType', + type: 'options', + options: [ + { + name: 'Video', + value: 'video', + description: 'Create a post with a video', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: 'video', + description: 'YouTube posts only support video attachments', + }, + { + displayName: 'Attachment Type', + name: 'attachmentType', + type: 'options', + options: [ + { + name: 'No Attachment', + value: 'none', + description: 'Create a text-only post', + }, + { + name: 'Image', + value: 'image', + description: 'Create a post with an image', + }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + }, + }, + default: 'none', + description: 'Google Business posts do not support video attachments', + }, +]; + +// Image and video asset fields (URL, alt text, thumbnail). +export const postAssetProperties: INodeProperties[] = [ + // ---------------------------------- + // Post: Create - Images + // ---------------------------------- + { + displayName: 'Image URL', + name: 'imageUrl', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + attachmentType: ['image'], + }, + }, + default: '', + description: 'The publicly accessible URL of the image (must be HTTP/HTTPS)', + }, + { + displayName: 'Alt Text', + name: 'imageAltText', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + attachmentType: ['image'], + }, + }, + default: '', + description: 'Alternative text describing the image for accessibility', + }, + { + displayName: 'Image Thumbnail URL', + name: 'imageThumbnailUrl', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + attachmentType: ['image'], + }, + }, + default: '', + description: 'Optional URL for a thumbnail version of the image', + }, + // ---------------------------------- + // Post: Create - Videos + // ---------------------------------- + { + displayName: 'Video URL', + name: 'videoUrl', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + attachmentType: ['video'], + }, + }, + default: '', + description: 'The publicly accessible URL of the video (must be HTTP/HTTPS)', + }, + { + displayName: 'Video Thumbnail URL', + name: 'videoThumbnailUrl', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + attachmentType: ['video'], + }, + }, + default: '', + description: 'Optional URL for a thumbnail version of the video', + }, +]; diff --git a/nodes/Buffer/descriptions/youtube.description.ts b/nodes/Buffer/descriptions/youtube.description.ts new file mode 100644 index 0000000..42088a1 --- /dev/null +++ b/nodes/Buffer/descriptions/youtube.description.ts @@ -0,0 +1,128 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const youtubeProperties: INodeProperties[] = [ + { + displayName: 'YouTube Title', + name: 'youtubeTitle', + type: 'string', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: '', + description: 'The title of the YouTube video', + }, + { + displayName: 'YouTube Category', + name: 'youtubeCategoryId', + type: 'options', + options: [ + { name: 'Film & Animation', value: '1' }, + { name: 'Autos & Vehicles', value: '2' }, + { name: 'Music', value: '10' }, + { name: 'Pets & Animals', value: '15' }, + { name: 'Sports', value: '17' }, + { name: 'Travel & Events', value: '19' }, + { name: 'Gaming', value: '20' }, + { name: 'People & Blogs', value: '22' }, + { name: 'Comedy', value: '23' }, + { name: 'Entertainment', value: '24' }, + { name: 'News & Politics', value: '25' }, + { name: 'Howto & Style', value: '26' }, + { name: 'Education', value: '27' }, + { name: 'Science & Technology', value: '28' }, + { name: 'Nonprofits & Activism', value: '29' }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: '22', + description: 'The YouTube category ID for the video', + }, + { + displayName: 'Privacy Status', + name: 'youtubePrivacy', + type: 'options', + options: [ + { name: 'Public', value: 'public' }, + { name: 'Private', value: 'private' }, + { name: 'Unlisted', value: 'unlisted' }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: 'public', + description: 'The privacy status of the YouTube video', + }, + { + displayName: 'License', + name: 'youtubeLicense', + type: 'options', + options: [ + { name: 'Standard YouTube License', value: 'youtube' }, + { name: 'Creative Commons - Attribution', value: 'creativeCommon' }, + ], + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: 'youtube', + description: 'The license under which the video is shared', + }, + { + displayName: 'Made for Kids', + name: 'youtubeMadeForKids', + type: 'boolean', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: false, + description: 'Whether the video is made for kids', + }, + { + displayName: 'Allow Embedding', + name: 'youtubeEmbeddable', + type: 'boolean', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: true, + description: 'Whether the video can be embedded on other websites', + }, + { + displayName: 'Notify Subscribers', + name: 'youtubeNotifySubscribers', + type: 'boolean', + displayOptions: { + show: { + resource: ['post'], + operation: ['create'], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], + }, + }, + default: true, + description: 'Whether to notify subscribers that the video has been published', + }, +]; diff --git a/nodes/Buffer/helpers/dateTime.ts b/nodes/Buffer/helpers/dateTime.ts new file mode 100644 index 0000000..c7a0526 --- /dev/null +++ b/nodes/Buffer/helpers/dateTime.ts @@ -0,0 +1,14 @@ +export const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/; + +// Google Business Offers only support a calendar date (no time-of-day component); this +// truncates whatever value the dateTime picker returned down to midnight UTC on that date. +export function toDateOnlyIso(dateStr: string): string { + return `${new Date(dateStr).toISOString().slice(0, 10)}T00:00:00.000Z`; +} + +// Combines a calendar date with a separately-provided "HH:mm" time into a single ISO timestamp, +// used for Google Business Events when the user opts in to specifying a start/end time. +export function combineDateAndTime(dateStr: string, timeStr: string): string { + const datePart = new Date(dateStr).toISOString().slice(0, 10); + return new Date(`${datePart}T${timeStr}:00.000Z`).toISOString(); +} diff --git a/nodes/Buffer/helpers/urlValidation.ts b/nodes/Buffer/helpers/urlValidation.ts new file mode 100644 index 0000000..e5ce694 --- /dev/null +++ b/nodes/Buffer/helpers/urlValidation.ts @@ -0,0 +1,51 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +interface ValidateUrlOptions { + /** Whether an empty value should throw. When false, an empty value is silently accepted (skips validation). */ + required: boolean; + /** Label used in the "is required and cannot be empty" message, e.g. "Image URL". Required when `required` is true. */ + requiredLabel?: string; + /** Label used in the "Invalid