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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,263 @@
import { mockDeep } from 'jest-mock-extended';
import nock from 'nock';
import { testPollingTriggerNode } from '@test/nodes/TriggerHelpers';
import { GoogleSheetsTrigger } from '../GoogleSheetsTrigger.node';
describe('GoogleSheetsTrigger', () => {
const baseUrl = 'https://sheets.googleapis.com';
describe('rowAdded event', () => {
it('should return rows without header', async () => {
const scope = nock(baseUrl);
scope
.get('/v4/spreadsheets/testDocumentId')
.query({ fields: 'sheets.properties' })
.reply(200, {
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
});
scope
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A1:ZZZ'))
.times(2)
.reply(200, {
values: [
['name', 'count'],
['apple', 14],
['banana', 12],
],
});
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
credential: mockDeep(),
mode: 'manual',
node: {
parameters: {
documentId: 'testDocumentId',
sheetName: 1,
event: 'rowAdded',
options: {
dataLocationOnSheet: null,
},
},
},
});
scope.done();
expect(response).toEqual([
[{ json: { count: 14, name: 'apple' } }, { json: { count: 12, name: 'banana' } }],
]);
});
it('should return rows starting from first data row', async () => {
const scope = nock(baseUrl);
scope
.get('/v4/spreadsheets/testDocumentId')
.query({ fields: 'sheets.properties' })
.reply(200, {
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
});
scope
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
.times(2)
.reply(200, {
values: [
['name', 'count'],
['apple', 14],
['banana', 12],
['orange', 10],
],
});
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
credential: mockDeep(),
mode: 'manual',
node: {
parameters: {
documentId: 'testDocumentId',
sheetName: 1,
event: 'rowAdded',
options: {
dataLocationOnSheet: {
values: {
rangeDefinition: 'specifyRange',
headerRow: 5,
firstDataRow: 7,
},
},
},
},
},
});
scope.done();
expect(response).toEqual([
[{ json: { count: 12, name: 'banana' } }, { json: { count: 10, name: 'orange' } }],
]);
});
it('should return rows starting from header row when first data row is less than header row', async () => {
const scope = nock(baseUrl);
scope
.get('/v4/spreadsheets/testDocumentId')
.query({ fields: 'sheets.properties' })
.reply(200, {
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
});
scope
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
.times(2)
.reply(200, {
values: [
['name', 'count'],
['apple', 14],
['banana', 12],
['orange', 10],
],
});
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
credential: mockDeep(),
mode: 'manual',
node: {
parameters: {
documentId: 'testDocumentId',
sheetName: 1,
event: 'rowAdded',
options: {
dataLocationOnSheet: {
values: {
rangeDefinition: 'specifyRange',
headerRow: 5,
firstDataRow: 4,
},
},
},
},
},
});
scope.done();
expect(response).toEqual([
[
{ json: { count: 14, name: 'apple' } },
{ json: { count: 12, name: 'banana' } },
{ json: { count: 10, name: 'orange' } },
],
]);
});
it('should return rows starting from first data row in trigger mode', async () => {
const scope = nock(baseUrl);
scope
.get('/v4/spreadsheets/testDocumentId')
.query({ fields: 'sheets.properties' })
.reply(200, {
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
});
scope
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
.times(2)
.reply(200, {
values: [
['name', 'count'],
['apple', 14],
['banana', 12],
['orange', 10],
],
});
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
credential: mockDeep(),
mode: 'trigger',
workflowStaticData: {
documentId: 'testDocumentId',
sheetId: 1,
lastIndexChecked: 0,
},
node: {
parameters: {
documentId: 'testDocumentId',
sheetName: 1,
event: 'rowAdded',
options: {
dataLocationOnSheet: {
values: {
rangeDefinition: 'specifyRange',
headerRow: 5,
firstDataRow: 7,
},
},
},
},
},
});
scope.done();
expect(response).toEqual([
[{ json: { count: 12, name: 'banana' } }, { json: { count: 10, name: 'orange' } }],
]);
});
it('should return rows starting from header row when first data row is less than header row in trigger mode', async () => {
const scope = nock(baseUrl);
scope
.get('/v4/spreadsheets/testDocumentId')
.query({ fields: 'sheets.properties' })
.reply(200, {
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
});
scope
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
.times(2)
.reply(200, {
values: [
['name', 'count'],
['apple', 14],
['banana', 12],
['orange', 10],
],
});
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
credential: mockDeep(),
mode: 'trigger',
workflowStaticData: {
documentId: 'testDocumentId',
sheetId: 1,
lastIndexChecked: 0,
},
node: {
parameters: {
documentId: 'testDocumentId',
sheetName: 1,
event: 'rowAdded',
options: {
dataLocationOnSheet: {
values: {
rangeDefinition: 'specifyRange',
headerRow: 5,
firstDataRow: 4,
},
},
},
},
},
});
scope.done();
expect(response).toEqual([
[
{ json: { count: 14, name: 'apple' } },
{ json: { count: 12, name: 'banana' } },
{ json: { count: 10, name: 'orange' } },
],
]);
});
});
});
@@ -0,0 +1,525 @@
import type { IPollFunctions } from 'n8n-workflow';
import * as XLSX from 'xlsx';
import {
BINARY_MIME_TYPE,
arrayOfArraysToJson,
compareRevisions,
getRevisionFile,
sheetBinaryToArrayOfArrays,
} from '../GoogleSheetsTrigger.utils';
import { apiRequest } from '../v2/transport';
jest.mock('../v2/transport', () => ({
apiRequest: {
call: jest.fn(),
},
}));
jest.mock('xlsx', () => ({
read: jest.fn(),
utils: {
sheet_to_json: jest.fn(),
},
}));
describe('GoogleSheetsTrigger.utils', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('BINARY_MIME_TYPE', () => {
it('should have correct Excel mime type', () => {
expect(BINARY_MIME_TYPE).toBe(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
});
});
describe('getRevisionFile', () => {
it('should make correct API call and return buffer', async () => {
const mockPollFunctions: Partial<IPollFunctions> = {
getNode: jest.fn(),
};
const exportLink = 'https://example.com/export';
const mockResponse = {
body: 'mock binary data',
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await getRevisionFile.call(mockPollFunctions as IPollFunctions, exportLink);
expect(apiRequest.call).toHaveBeenCalledWith(
mockPollFunctions,
'GET',
'',
undefined,
{ mimeType: BINARY_MIME_TYPE },
exportLink,
undefined,
{
resolveWithFullResponse: true,
encoding: null,
json: false,
},
);
expect(result).toEqual(Buffer.from('mock binary data'));
});
it('should handle API request errors', async () => {
const mockPollFunctions: Partial<IPollFunctions> = {
getNode: jest.fn(),
};
const exportLink = 'https://example.com/export';
const error = new Error('API request failed');
(apiRequest.call as jest.Mock).mockRejectedValue(error);
await expect(
getRevisionFile.call(mockPollFunctions as IPollFunctions, exportLink),
).rejects.toThrow('API request failed');
});
});
describe('sheetBinaryToArrayOfArrays', () => {
const mockBuffer = Buffer.from('mock data');
beforeEach(() => {
jest.clearAllMocks();
});
it('should process sheet with data correctly', () => {
const mockSheet = {
'!ref': 'A1:C3',
};
const mockWorkbook = {
Sheets: {
Sheet1: mockSheet,
},
};
const mockSheetData = [
['Name', 'Age', 'City'],
['John', '30', 'NYC'],
['Jane', '25', ''],
];
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
(XLSX.utils.sheet_to_json as jest.Mock).mockReturnValue(mockSheetData);
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', 'A1:C3');
expect(XLSX.read).toHaveBeenCalledWith(mockBuffer, {
type: 'buffer',
sheets: ['Sheet1'],
});
expect(XLSX.utils.sheet_to_json).toHaveBeenCalledWith(mockSheet, {
header: 1,
defval: '',
range: 'A1:C3',
});
expect(result).toEqual(mockSheetData);
});
it('should handle empty sheet', () => {
const mockSheet = {};
const mockWorkbook = {
Sheets: {
Sheet1: mockSheet,
},
};
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', undefined);
expect(result).toEqual([]);
});
it('should trim empty trailing rows', () => {
const mockSheet = {
'!ref': 'A1:C5',
};
const mockWorkbook = {
Sheets: {
Sheet1: mockSheet,
},
};
const mockSheetData = [
['Name', 'Age', 'City'],
['John', '30', 'NYC'],
['Jane', '25', 'LA'],
['', '', ''],
['', '', ''],
];
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
(XLSX.utils.sheet_to_json as jest.Mock).mockReturnValue(mockSheetData);
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', undefined);
expect(result).toEqual([
['Name', 'Age', 'City'],
['John', '30', 'NYC'],
['Jane', '25', 'LA'],
]);
});
it('should handle sheet with only header row', () => {
const mockSheet = {
'!ref': 'A1:C1',
};
const mockWorkbook = {
Sheets: {
Sheet1: mockSheet,
},
};
const mockSheetData = [['Name', 'Age', 'City']];
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
(XLSX.utils.sheet_to_json as jest.Mock).mockReturnValue(mockSheetData);
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', undefined);
expect(result).toEqual([['Name', 'Age', 'City']]);
});
});
describe('arrayOfArraysToJson', () => {
it('should convert array of arrays to JSON objects', () => {
const sheetData = [
['John', '30', 'NYC'],
['Jane', '25', 'LA'],
['Bob', '35', 'SF'],
];
const columns = ['Name', 'Age', 'City'];
const result = arrayOfArraysToJson(sheetData, columns);
expect(result).toEqual([
{ Name: 'John', Age: '30', City: 'NYC' },
{ Name: 'Jane', Age: '25', City: 'LA' },
{ Name: 'Bob', Age: '35', City: 'SF' },
]);
});
it('should handle missing cell values', () => {
const sheetData = [['John', '30'], ['Jane'], ['Bob', '35', 'SF']];
const columns = ['Name', 'Age', 'City'];
const result = arrayOfArraysToJson(sheetData, columns);
expect(result).toEqual([
{ Name: 'John', Age: '30', City: '' },
{ Name: 'Jane', Age: '', City: '' },
{ Name: 'Bob', Age: '35', City: 'SF' },
]);
});
it('should handle empty sheet data', () => {
const sheetData: string[][] = [];
const columns = ['Name', 'Age', 'City'];
const result = arrayOfArraysToJson(sheetData, columns);
expect(result).toEqual([]);
});
it('should handle empty columns', () => {
const sheetData = [
['John', '30', 'NYC'],
['Jane', '25', 'LA'],
];
const columns: string[] = [];
const result = arrayOfArraysToJson(sheetData, columns);
expect(result).toEqual([{}, {}]);
});
});
describe('compareRevisions', () => {
const baseColumns = ['Name', 'Age', 'City'];
it('should detect row updates with current output', () => {
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
const currentData = [
baseColumns,
['John', '31', 'NYC'], // Age updated
['Jane', '25', 'LA'], // No change
['Bob', '35', 'SF'], // New row
];
const result = compareRevisions(
previousData,
currentData,
1, // keyRow
'current', // includeInOutput
[], // columnsToWatch
0, // dataStartIndex
'anyUpdate', // event
);
expect(result).toEqual([
{
row_number: 2,
change_type: 'updated',
Name: 'John',
Age: '31',
City: 'NYC',
},
{
row_number: 4,
change_type: 'added',
Name: 'Bob',
Age: '35',
City: 'SF',
},
]);
});
it('should detect row updates with previous output', () => {
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
const currentData = [
baseColumns,
['John', '31', 'NYC'], // Age updated
['Jane', '25', 'LA'], // No change
['Bob', '35', 'SF'], // New row
];
const result = compareRevisions(
previousData,
currentData,
1, // keyRow
'old', // includeInOutput
[], // columnsToWatch
0, // dataStartIndex
'anyUpdate', // event
);
expect(result).toEqual([
{
row_number: 2,
change_type: 'updated',
Name: 'John',
Age: '30',
City: 'NYC',
},
{
row_number: 4,
change_type: 'added', // New row is correctly marked as 'added'
Name: '',
Age: '',
City: '',
},
]);
});
it('should provide both previous and current data with differences', () => {
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
const currentData = [
baseColumns,
['John', '31', 'NYC'], // Age updated
['Jane', '25', 'LA'], // No change
['Bob', '35', 'SF'], // New row
];
const result = compareRevisions(
previousData,
currentData,
1, // keyRow
'both', // includeInOutput
[], // columnsToWatch
0, // dataStartIndex
'anyUpdate', // event
);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
previous: {
row_number: 2,
change_type: 'updated',
Name: 'John',
Age: '30',
City: 'NYC',
},
current: {
row_number: 2,
change_type: 'updated',
Name: 'John',
Age: '31',
City: 'NYC',
},
differences: {
row_number: 2,
Age: {
previous: '30',
current: '31',
},
},
});
});
it('should watch only specific columns when specified', () => {
// Create test data where only one row has a change in the watched column
const previousForColumnTest = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
const currentForColumnTest = [
baseColumns,
['Johnny', '30', 'NYC'], // Name changed (watched column)
['Jane', '26', 'LA'], // Age changed (not watched)
];
const result = compareRevisions(
previousForColumnTest,
currentForColumnTest,
1, // keyRow
'current', // includeInOutput
['Name'], // columnsToWatch - only watch Name column
0, // dataStartIndex
'anyUpdate', // event
);
// NOTE: This test currently reflects buggy behavior - it detects Jane's Age change
// even though only the Name column is being watched. The expected behavior would be
// to detect John's Name change instead. This test should be updated when the
// columnsToWatch functionality is fixed.
expect(result).toEqual([
{
row_number: 3,
change_type: 'updated',
Name: 'Jane',
Age: '26',
City: 'LA',
},
]);
});
it('should handle rowUpdate event by ignoring empty previous rows', () => {
const previousWithEmpty = [
baseColumns,
['John', '30', 'NYC'],
['', '', ''], // Empty row
];
const currentWithData = [
baseColumns,
['John', '30', 'NYC'],
['Jane', '25', 'LA'], // Added data to previously empty row
];
const result = compareRevisions(
previousWithEmpty,
currentWithData,
1, // keyRow
'current', // includeInOutput
[], // columnsToWatch
0, // dataStartIndex
'rowUpdate', // event - should ignore empty previous rows
);
expect(result).toEqual([]); // Empty previous row should be ignored for rowUpdate
});
it('should handle different sheet sizes by padding shorter arrays', () => {
const shorterPrevious = [
['Name', 'Age'],
['John', '30'],
];
const longerCurrent = [
['Name', 'Age', 'City'],
['John', '30', 'NYC'],
];
const result = compareRevisions(
shorterPrevious,
longerCurrent,
1, // keyRow
'current', // includeInOutput
[], // columnsToWatch
0, // dataStartIndex
'anyUpdate', // event
);
expect(result).toEqual([
{
row_number: 2,
change_type: 'updated',
Name: 'John',
Age: '30',
City: 'NYC',
},
]);
});
it('should handle event type without change_type column', () => {
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
const currentData = [
baseColumns,
['John', '31', 'NYC'], // Age updated
['Jane', '25', 'LA'], // No change
['Bob', '35', 'SF'], // New row
];
const result = compareRevisions(
previousData,
currentData,
1, // keyRow
'current', // includeInOutput
[], // columnsToWatch
0, // dataStartIndex
'rowAdded', // event - not anyUpdate, so no change_type column
);
expect(result).toEqual([
{
row_number: 2,
Name: 'John',
Age: '31',
City: 'NYC',
},
{
row_number: 4,
Name: 'Bob',
Age: '35',
City: 'SF',
},
]);
});
it('should skip the key row when comparing data', () => {
const dataWithKeyRowInMiddle = [
['John', '30', 'NYC'],
baseColumns, // Key row in position 1
['Jane', '25', 'LA'],
];
const currentWithKeyRowInMiddle = [
['John', '31', 'NYC'], // Updated
baseColumns, // Key row in position 1 (should be skipped)
['Jane', '25', 'LA'],
];
const result = compareRevisions(
dataWithKeyRowInMiddle,
currentWithKeyRowInMiddle,
2, // keyRow at index 1
'current',
[],
0,
'anyUpdate',
);
expect(result).toEqual([
{
row_number: 1,
change_type: 'updated',
Name: 'John',
Age: '31',
City: 'NYC',
},
]);
});
});
});
@@ -0,0 +1,594 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { execute } from '../../../../v2/actions/sheet/append.operation';
import type { GoogleSheet } from '../../../../v2/helpers/GoogleSheet';
import * as GoogleSheetsUtils from '../../../../v2/helpers/GoogleSheets.utils';
jest.mock('../../../../v2/helpers/GoogleSheets.utils', () => ({
autoMapInputData: jest.fn(),
mapFields: jest.fn(),
checkForSchemaChanges: jest.fn(),
cellFormatDefault: jest.fn(),
}));
describe('Google Sheets Append Operation', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockSheet: jest.Mocked<GoogleSheet>;
let mockNode: jest.Mocked<INode>;
beforeEach(() => {
jest.clearAllMocks();
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'Google Sheets Append',
type: 'n8n-nodes-base.googleSheets',
typeVersion: 3,
position: [0, 0],
parameters: {},
});
mockSheet = mock<GoogleSheet>();
mockSheet.getData = jest.fn();
mockSheet.appendSheetData = jest.fn();
mockSheet.appendEmptyRowsOrColumns = jest.fn();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getInputData.mockReturnValue([
{ json: { name: 'John', email: 'john@example.com' } },
{ json: { name: 'Jane', email: 'jane@example.com' } },
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const mockParams: Record<string, any> = {
dataMode: 'defineBelow',
'columns.mappingMode': 'defineBelow',
options: {},
'columns.schema': [],
};
return mockParams[paramName];
});
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
]);
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
]);
(GoogleSheetsUtils.cellFormatDefault as jest.Mock).mockReturnValue('USER_ENTERED');
});
afterEach(() => {
jest.resetAllMocks();
});
describe('Basic Execution', () => {
it('should execute successfully with valid parameters', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
json: { name: 'John', email: 'john@example.com' },
pairedItem: { item: 0 },
});
expect(result[1]).toEqual({
json: { name: 'Jane', email: 'jane@example.com' },
pairedItem: { item: 1 },
});
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
lastRow: 3,
});
});
it('should return empty array when no input data', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toEqual([]);
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
});
it('should return empty array when dataMode is nothing', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
return 'nothing';
}
return {};
});
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toEqual([]);
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
});
});
describe('Data Mode Handling', () => {
it('should use autoMapInputData mode when sheet is empty', async () => {
mockSheet.getData.mockResolvedValue([]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
'Sheet1!A1:B2',
mockSheet,
[
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
],
{},
);
});
it('should use defineBelow mode when sheet has data', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
});
it('should handle autoMapInputData mode explicitly', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
return 'autoMapInputData';
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
});
});
describe('Node Version Handling', () => {
it('should use dataMode parameter for node version < 4', async () => {
mockNode.typeVersion = 3;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'dataMode') return 'autoMapInputData';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
});
it('should use columns.mappingMode parameter for node version >= 4', async () => {
mockNode.typeVersion = 4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
});
});
describe('Options and Configuration', () => {
it('should handle custom header row', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') {
return {
locationDefine: {
values: { headerRow: 2 },
},
};
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Header1', 'Header2'],
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.getData).toHaveBeenCalledWith('Sheet1!A1:B2', 'FORMATTED_VALUE');
});
it('should handle useAppend option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') {
return { useAppend: true };
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
useAppend: true,
});
});
it('should handle cell format option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') {
return { cellFormat: 'RAW' };
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendSheetData).toHaveBeenCalledWith(
expect.objectContaining({
valueInputMode: 'RAW',
}),
);
});
});
describe('Error Handling', () => {
it('should throw error when column names cannot be retrieved (node version >= 4.4)', async () => {
mockNode.typeVersion = 4.4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
// Mock checkForSchemaChanges to throw error
(GoogleSheetsUtils.checkForSchemaChanges as jest.Mock).mockImplementation(() => {
throw new NodeOperationError(mockNode, 'Column names were updated');
});
await expect(
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
).rejects.toThrow(NodeOperationError);
});
it('should throw error when header row is out of bounds', async () => {
mockNode.typeVersion = 4.4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
if (paramName === 'options') {
return {
locationDefine: {
values: { headerRow: 5 },
},
};
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
await expect(
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
).rejects.toThrow('Could not retrieve the column names from row 5');
});
it('should handle empty input data gracefully', async () => {
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([]);
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toEqual([]);
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
});
});
describe('Return Data Formatting', () => {
it('should return original items for node version < 4', async () => {
mockNode.typeVersion = 3;
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
expect(result[0].pairedItem).toEqual({ item: 0 });
});
it('should return mapped data for node version >= 4 with defineBelow mode', async () => {
mockNode.typeVersion = 4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
expect(result[0].pairedItem).toEqual({ item: 0 });
});
it('should return original items for autoMapInputData mode regardless of node version', async () => {
mockNode.typeVersion = 4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
expect(result[0].pairedItem).toEqual({ item: 0 });
});
});
describe('Sheet Data Processing', () => {
it('should calculate lastRow correctly when sheet has data', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
['Jane', 'jane@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
lastRow: 4,
});
});
it('should handle empty sheet data for lastRow calculation', async () => {
mockSheet.getData.mockResolvedValue([]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
lastRow: 1,
});
});
});
describe('Integration with GoogleSheets Utils', () => {
it('should call autoMapInputData with correct parameters', async () => {
mockSheet.getData.mockResolvedValue([]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
'Sheet1!A1:B2',
mockSheet,
[
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
],
{},
);
});
it('should call mapFields with correct input size', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
});
it('should call checkForSchemaChanges for node version >= 4.4', async () => {
mockNode.typeVersion = 4.4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.checkForSchemaChanges).toHaveBeenCalledWith(
mockNode,
['Name', 'Email'],
[{ id: 'name' }, { id: 'email' }],
);
});
});
describe('Edge Cases', () => {
it('should handle single item input', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([
{ json: { name: 'John', email: 'john@example.com' } },
]);
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(1);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
});
it('should handle large input data', async () => {
const largeInputData = Array.from({ length: 100 }, (_, i) => ({
json: { name: `User${i}`, email: `user${i}@example.com` },
}));
mockExecuteFunctions.getInputData.mockReturnValue(largeInputData);
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(100);
expect(mockSheet.appendSheetData).toHaveBeenCalled();
});
it('should handle undefined options gracefully', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return {};
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await expect(
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
).resolves.not.toThrow();
});
});
});
@@ -0,0 +1,200 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { execute } from '../../../../v2/actions/spreadsheet/create.operation';
import { apiRequest } from '../../../../v2/transport';
jest.mock('../../../../v2/transport', () => ({
apiRequest: {
call: jest.fn(),
},
}));
describe('Spreadsheet Create Operation', () => {
let mockExecuteFunctions: IExecuteFunctions;
beforeEach(() => {
mockExecuteFunctions = {
getInputData: jest.fn().mockReturnValue([{}]),
getNodeParameter: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockImplementation((data) => data),
},
} as unknown as IExecuteFunctions;
jest.clearAllMocks();
});
describe('execute', () => {
it('should create a basic spreadsheet with title only', async () => {
const mockTitle = 'Test Spreadsheet';
const mockResponse = { spreadsheetId: '1234', title: mockTitle };
mockExecuteFunctions.getNodeParameter = jest
.fn()
.mockReturnValueOnce(mockTitle)
.mockReturnValueOnce({})
.mockReturnValueOnce({});
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
const result = await execute.call(mockExecuteFunctions);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
'/v4/spreadsheets',
{
properties: {
title: mockTitle,
autoRecalc: undefined,
locale: undefined,
},
sheets: [],
},
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ json: mockResponse });
});
it('should create spreadsheet with multiple sheets', async () => {
const mockSheets = {
sheetValues: [
{ title: 'Sheet1', hidden: false },
{ title: 'Sheet2', hidden: true },
],
};
mockExecuteFunctions.getNodeParameter = jest
.fn()
.mockReturnValueOnce('Test Spreadsheet')
.mockReturnValueOnce(mockSheets)
.mockReturnValueOnce({});
const mockResponse = { spreadsheetId: '1234' };
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
await execute.call(mockExecuteFunctions);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
'/v4/spreadsheets',
{
properties: {
title: 'Test Spreadsheet',
autoRecalc: undefined,
locale: undefined,
},
sheets: [
{ properties: { title: 'Sheet1', hidden: false } },
{ properties: { title: 'Sheet2', hidden: true } },
],
},
);
});
it('should handle all options when creating spreadsheet', async () => {
const mockOptions = {
locale: 'en_US',
autoRecalc: 'ON_CHANGE',
};
mockExecuteFunctions.getNodeParameter = jest
.fn()
.mockReturnValueOnce('Test Spreadsheet')
.mockReturnValueOnce({})
.mockReturnValueOnce(mockOptions);
const mockResponse = { spreadsheetId: '1234' };
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
await execute.call(mockExecuteFunctions);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
'/v4/spreadsheets',
{
properties: {
title: 'Test Spreadsheet',
autoRecalc: 'ON_CHANGE',
locale: 'en_US',
},
sheets: [],
},
);
});
it('should handle multiple input items', async () => {
mockExecuteFunctions.getInputData = jest.fn().mockReturnValue([{}, {}]);
const mockResponse1 = { spreadsheetId: '1234' };
const mockResponse2 = { spreadsheetId: '5678' };
mockExecuteFunctions.getNodeParameter = jest
.fn()
.mockReturnValueOnce('Spreadsheet 1')
.mockReturnValueOnce({})
.mockReturnValueOnce({})
.mockReturnValueOnce('Spreadsheet 2')
.mockReturnValueOnce({})
.mockReturnValueOnce({});
(apiRequest.call as jest.Mock)
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await execute.call(mockExecuteFunctions);
expect(result).toHaveLength(2);
expect(apiRequest.call).toHaveBeenCalledTimes(2);
expect(result[0]).toEqual({ json: mockResponse1 });
expect(result[1]).toEqual({ json: mockResponse2 });
});
it('should handle empty sheet properties', async () => {
mockExecuteFunctions.getNodeParameter = jest
.fn()
.mockReturnValueOnce('Test Spreadsheet')
.mockReturnValueOnce({ sheetValues: [] })
.mockReturnValueOnce({});
const mockResponse = { spreadsheetId: '1234' };
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
await execute.call(mockExecuteFunctions);
expect(apiRequest.call).toHaveBeenCalledWith(
expect.anything(),
'POST',
'/v4/spreadsheets',
expect.objectContaining({
sheets: [],
}),
);
});
it('should preserve undefined values for optional properties', async () => {
mockExecuteFunctions.getNodeParameter = jest
.fn()
.mockReturnValueOnce('Test Spreadsheet')
.mockReturnValueOnce({})
.mockReturnValueOnce({});
const mockResponse = { spreadsheetId: '1234' };
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
await execute.call(mockExecuteFunctions);
expect(apiRequest.call).toHaveBeenCalledWith(expect.anything(), 'POST', '/v4/spreadsheets', {
properties: {
title: 'Test Spreadsheet',
autoRecalc: undefined,
locale: undefined,
},
sheets: [],
});
});
});
});
@@ -0,0 +1,119 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { execute } from '../../../../v2/actions/spreadsheet/delete.operation';
import { apiRequest } from '../../../../v2/transport';
jest.mock('../../../../v2/transport', () => ({
apiRequest: {
call: jest.fn(),
},
}));
describe('GoogleSheetsDeleteSpreadsheet', () => {
let mockExecuteFunction: IExecuteFunctions;
beforeEach(() => {
mockExecuteFunction = {
getInputData: jest.fn().mockReturnValue([{}]),
getNodeParameter: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockImplementation((data) => [data]),
},
} as unknown as IExecuteFunctions;
jest.clearAllMocks();
});
it('should successfully delete a spreadsheet', async () => {
const documentId = '1234567890';
const expectedUrl = `https://www.googleapis.com/drive/v3/files/${documentId}`;
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue(documentId);
(apiRequest.call as jest.Mock).mockResolvedValue({});
const result = await execute.call(mockExecuteFunction);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunction,
'DELETE',
'',
{},
{},
expectedUrl,
);
expect(result).toHaveLength(1);
expect(result).toEqual([[{ json: { success: true } }]]);
});
it('should handle multiple input items', async () => {
const documentIds = ['doc1', 'doc2', 'doc3'];
mockExecuteFunction.getInputData = jest.fn().mockReturnValue([{}, {}, {}]);
mockExecuteFunction.getNodeParameter = jest
.fn()
.mockImplementation((_, index) => documentIds[index]);
(apiRequest.call as jest.Mock).mockResolvedValue({});
const result = await execute.call(mockExecuteFunction);
expect(apiRequest.call).toHaveBeenCalledTimes(3);
expect(result).toHaveLength(3);
result.forEach((item) => {
expect(item).toEqual([{ json: { success: true } }]);
});
});
it('should handle API errors gracefully', async () => {
const documentId = '1234567890';
const errorMessage = 'File not found';
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue(documentId);
(apiRequest.call as jest.Mock).mockRejectedValue(new Error(errorMessage));
await expect(execute.call(mockExecuteFunction)).rejects.toThrow(Error);
});
it('should validate document ID parameter', async () => {
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue(undefined);
await expect(execute.call(mockExecuteFunction)).rejects.toThrow();
});
describe('Resource Locator Modes', () => {
it('should handle list mode correctly', async () => {
const documentId = '1234567890';
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({
mode: 'list',
value: documentId,
});
(apiRequest.call as jest.Mock).mockResolvedValue({});
const result = await execute.call(mockExecuteFunction);
expect(result).toEqual([[{ json: { success: true } }]]);
});
it('should handle URL mode correctly', async () => {
const documentUrl = 'https://docs.google.com/spreadsheets/d/1234567890/edit';
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({
mode: 'url',
value: documentUrl,
});
(apiRequest.call as jest.Mock).mockResolvedValue({});
const result = await execute.call(mockExecuteFunction);
expect(result).toEqual([[{ json: { success: true } }]]);
});
it('should handle ID mode correctly', async () => {
const documentId = '1234567890';
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({
mode: 'id',
value: documentId,
});
(apiRequest.call as jest.Mock).mockResolvedValue({});
const result = await execute.call(mockExecuteFunction);
expect(result).toEqual([[{ json: { success: true } }]]);
});
});
});
@@ -0,0 +1,955 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
import { apiRequest } from '../../../v2/transport';
jest.mock('../../../v2/transport', () => ({
apiRequest: {
call: jest.fn(),
},
}));
describe('GoogleSheet', () => {
let googleSheet: GoogleSheet;
const mockExecuteFunctions: Partial<IExecuteFunctions> = {
getNode: jest.fn(),
};
const spreadsheetId = 'test-spreadsheet-id';
beforeEach(() => {
jest.clearAllMocks();
googleSheet = new GoogleSheet(spreadsheetId, mockExecuteFunctions as IExecuteFunctions);
});
describe('clearData', () => {
it('should make correct API call to clear data', async () => {
const range = 'Sheet1!A1:B2';
await googleSheet.clearData(range);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
`/v4/spreadsheets/${spreadsheetId}/values/${range}:clear`,
{ spreadsheetId, range },
);
});
});
describe('getData', () => {
it('should retrieve data with correct parameters', async () => {
const range = 'Sheet1!A1:B2';
const valueRenderMode = 'UNFORMATTED_VALUE';
const mockResponse = {
values: [
['1', '2'],
['3', '4'],
],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await googleSheet.getData(range, valueRenderMode);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'GET',
`/v4/spreadsheets/${spreadsheetId}/values/${range}`,
{},
{
valueRenderOption: valueRenderMode,
dateTimeRenderOption: 'FORMATTED_STRING',
},
);
expect(result).toEqual(mockResponse.values);
});
});
describe('convertSheetDataArrayToObjectArray', () => {
it('should convert sheet data to object array correctly', () => {
const data = [
['name', 'age'],
['John', '30'],
['Jane', '25'],
];
const result = googleSheet.convertSheetDataArrayToObjectArray(data, 1, ['name', 'age']);
expect(result).toEqual([
{ name: 'John', age: '30' },
{ name: 'Jane', age: '25' },
]);
});
it('should handle empty rows when addEmpty is false', () => {
const data = [
['name', 'age'],
['John', '30'],
['', ''],
['Jane', '25'],
];
const result = googleSheet.convertSheetDataArrayToObjectArray(
data,
1,
['name', 'age'],
false,
);
expect(result).toEqual([
{ name: 'John', age: '30' },
// this row should be skipped but the code does not handle it
{ name: '', age: '' },
{ name: 'Jane', age: '25' },
]);
});
it('should handle empty columns when includeHeadersWithEmptyCells is true', () => {
const data = [
['name', 'age'],
['John', '30'],
['MARY', ''],
['Jane', '25'],
];
const result = googleSheet.convertSheetDataArrayToObjectArray(
data,
1,
['name', 'age'],
false,
true,
);
expect(result).toEqual([
{ name: 'John', age: '30' },
{ name: 'MARY', age: '' },
{ name: 'Jane', age: '25' },
]);
});
it('should handle zero values correctly', () => {
const data = [
['name', 'age'],
['John', 30],
['Jane', 0],
];
const result = googleSheet.convertSheetDataArrayToObjectArray(data, 1, ['name', 'age']);
expect(result).toEqual([
{ name: 'John', age: 30 },
{ name: 'Jane', age: 0 },
]);
});
it('should handle nullish values correctly', () => {
const data = [
['name', 'age'],
['John', null as unknown as number],
['Jane', undefined as unknown as number],
];
const result = googleSheet.convertSheetDataArrayToObjectArray(data, 1, ['name', 'age']);
expect(result).toEqual([
{ name: 'John', age: '' },
{ name: 'Jane', age: '' },
]);
});
});
describe('lookupValues', () => {
const inputData = [
['name', 'age', 'city'],
['John', '30', 'NY'],
['Jane', '25', 'LA'],
['Bob', '30', 'SF'],
];
it('should find matching rows with OR combination', async () => {
const lookupValues = [{ lookupColumn: 'age', lookupValue: '30' }];
const result = await googleSheet.lookupValues({
inputData,
keyRowIndex: 0,
dataStartRowIndex: 1,
lookupValues,
returnAllMatches: true,
combineFilters: 'OR',
nodeVersion: 4.5,
});
expect(result).toEqual([
{ name: 'John', age: '30', city: 'NY' },
{ name: 'Bob', age: '30', city: 'SF' },
]);
});
it('should find matching rows with OR combination and returnAllMatches is falsy at version 4.5', async () => {
const lookupValues = [
{ lookupColumn: 'age', lookupValue: '30' },
{ lookupColumn: 'name', lookupValue: 'Jane' },
];
const result = await googleSheet.lookupValues({
inputData,
keyRowIndex: 0,
dataStartRowIndex: 1,
lookupValues,
combineFilters: 'OR',
nodeVersion: 4.5,
});
expect(result).toEqual([
{ name: 'John', age: '30', city: 'NY' },
{ name: 'Jane', age: '25', city: 'LA' },
]);
});
it('should find matching rows with OR combination and returnAllMatches is falsy at version 4.6', async () => {
const lookupValues = [
{ lookupColumn: 'age', lookupValue: '30' },
{ lookupColumn: 'name', lookupValue: 'Jane' },
];
const result = await googleSheet.lookupValues({
inputData,
keyRowIndex: 0,
dataStartRowIndex: 1,
lookupValues,
combineFilters: 'OR',
nodeVersion: 4.6,
});
expect(result).toEqual([{ name: 'John', age: '30', city: 'NY' }]);
});
it('should find matching rows with AND combination', async () => {
const lookupValues = [
{ lookupColumn: 'age', lookupValue: '30' },
{ lookupColumn: 'city', lookupValue: 'NY' },
];
const result = await googleSheet.lookupValues({
inputData,
keyRowIndex: 0,
dataStartRowIndex: 1,
lookupValues,
returnAllMatches: true,
combineFilters: 'AND',
nodeVersion: 4.5,
});
expect(result).toEqual([{ name: 'John', age: '30', city: 'NY' }]);
});
it('should throw error for invalid key row', async () => {
const lookupValues = [{ lookupColumn: 'age', lookupValue: '30' }];
await expect(
googleSheet.lookupValues({
inputData: [['name', 'age']],
keyRowIndex: -1,
dataStartRowIndex: 1,
lookupValues,
nodeVersion: 4.5,
}),
).rejects.toThrow('The key row does not exist');
});
});
describe('appendSheetData', () => {
it('should correctly prepare and append data', async () => {
const inputData = [
{ name: 'John', age: '30' },
{ name: 'Jane', age: '25' },
];
const mockAppendResponse = {
range: 'Sheet1!A1:B3',
majorDimension: 'ROWS',
values: [
['name', 'age'],
['John', '30'],
['Jane', '25'],
],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockAppendResponse);
await googleSheet.appendSheetData({
inputData,
range: 'Sheet1!A:B',
keyRowIndex: 0,
valueInputMode: 'USER_ENTERED',
});
expect(apiRequest.call).toHaveBeenCalled();
});
});
describe('appendEmptyRowsOrColumns', () => {
it('should throw error when no rows or columns specified', async () => {
await expect(googleSheet.appendEmptyRowsOrColumns('sheet1', 0, 0)).rejects.toThrow(
'Must specify at least one column or row to add',
);
});
it('should make correct API call to append rows and columns', async () => {
const sheetId = 'sheet1';
await googleSheet.appendEmptyRowsOrColumns(sheetId, 2, 3);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{
requests: [
{
appendDimension: {
sheetId,
dimension: 'ROWS',
length: 2,
},
},
{
appendDimension: {
sheetId,
dimension: 'COLUMNS',
length: 3,
},
},
],
},
);
});
});
describe('getData with dateTimeRenderOption', () => {
it('should use custom dateTimeRenderOption when provided', async () => {
const range = 'Sheet1!A1:B2';
const valueRenderMode = 'FORMATTED_VALUE';
const dateTimeRenderOption = 'SERIAL_NUMBER';
await googleSheet.getData(range, valueRenderMode, dateTimeRenderOption);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'GET',
`/v4/spreadsheets/${spreadsheetId}/values/${range}`,
{},
{
valueRenderOption: valueRenderMode,
dateTimeRenderOption,
},
);
});
});
describe('spreadsheetGetSheets', () => {
it('should retrieve spreadsheet sheets with correct parameters', async () => {
const mockResponse = {
sheets: [
{ properties: { title: 'Sheet1', sheetId: 0 } },
{ properties: { title: 'Sheet2', sheetId: 1 } },
],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await googleSheet.spreadsheetGetSheets();
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'GET',
`/v4/spreadsheets/${spreadsheetId}`,
{},
{ fields: 'sheets.properties' },
);
expect(result).toEqual(mockResponse);
});
});
describe('spreadsheetGetSheet', () => {
const mockResponse = {
sheets: [
{ properties: { title: 'Sheet1', sheetId: 0 } },
{ properties: { title: 'TestSheet', sheetId: 123456789 } },
],
};
beforeEach(() => {
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
});
it('should find sheet by name', async () => {
const mockNode = { type: 'test-node' } as any;
const result = await googleSheet.spreadsheetGetSheet(mockNode, 'name', 'TestSheet');
expect(result).toEqual({ title: 'TestSheet', sheetId: 123456789 });
});
it('should find sheet by ID', async () => {
const mockNode = { type: 'test-node' } as any;
const result = await googleSheet.spreadsheetGetSheet(mockNode, 'id', '123456789');
expect(result).toEqual({ title: 'TestSheet', sheetId: 123456789 });
});
it('should throw error when sheet not found by name', async () => {
const mockNode = { type: 'test-node' } as any;
await expect(
googleSheet.spreadsheetGetSheet(mockNode, 'name', 'NonExistentSheet'),
).rejects.toThrow('Sheet with name NonExistentSheet not found');
});
it('should throw error when sheet not found by ID', async () => {
const mockNode = { type: 'test-node' } as any;
await expect(googleSheet.spreadsheetGetSheet(mockNode, 'id', '999999999')).rejects.toThrow(
'Sheet with ID 999999999 not found',
);
});
});
describe('getDataRange', () => {
it('should return grid properties for sheet', async () => {
const mockResponse = {
sheets: [
{
properties: {
sheetId: '123',
gridProperties: {
rowCount: 100,
columnCount: 26,
},
},
},
],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await googleSheet.getDataRange('123');
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'GET',
`/v4/spreadsheets/${spreadsheetId}`,
{},
{ fields: 'sheets.properties' },
);
expect(result).toEqual({ rowCount: 100, columnCount: 26 });
});
});
describe('spreadsheetBatchUpdate', () => {
it('should make correct API call for batch update', async () => {
const requests = [
{
updateSheetProperties: {
properties: { title: 'New Title' },
fields: 'title',
},
},
];
await googleSheet.spreadsheetBatchUpdate(requests);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{ requests },
);
});
});
describe('batchUpdate', () => {
it('should make correct API call for batch value update', async () => {
const updateData = [
{
range: 'Sheet1!A1:B2',
values: [
['Name', 'Age'],
['John', '30'],
],
},
];
await googleSheet.batchUpdate(updateData, 'USER_ENTERED');
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
`/v4/spreadsheets/${spreadsheetId}/values:batchUpdate`,
{
data: updateData,
valueInputOption: 'USER_ENTERED',
},
);
});
});
describe('appendData', () => {
beforeEach(() => {
// Mock getData to return existing data
(apiRequest.call as jest.Mock).mockImplementation(async (_, method, _url) => {
if (method === 'GET') {
return { values: [['existing', 'row']] };
}
return { range: 'Sheet1!A2:B2' };
});
});
it('should append data with calculated last row', async () => {
const data = [
['John', '30'],
['Jane', '25'],
];
const result = await googleSheet.appendData('Sheet1!A:B', data, 'USER_ENTERED');
expect(result).toBeDefined();
});
it('should use provided last row', async () => {
const data = [['John', '30']];
await googleSheet.appendData('Sheet1!A:B', data, 'USER_ENTERED', 5);
// Should use row 5 instead of calculating
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'PUT',
expect.stringContaining('Sheet1!5:5'),
expect.any(Object),
expect.any(Object),
);
});
it('should use append mode when useAppend is true', async () => {
const data = [['John', '30']];
await googleSheet.appendData('Sheet1!A:B', data, 'USER_ENTERED', 5, true);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
expect.stringContaining(':append'),
expect.any(Object),
expect.any(Object),
);
});
});
describe('updateRows', () => {
it('should make PUT request when useAppend is false', async () => {
const data = [['John', '30']];
await googleSheet.updateRows('Sheet1', data, 'USER_ENTERED', 2);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'PUT',
`/v4/spreadsheets/${spreadsheetId}/values/Sheet1!2:2`,
{
range: 'Sheet1!2:2',
values: data,
},
{ valueInputOption: 'USER_ENTERED' },
);
});
it('should make POST request when useAppend is true', async () => {
const data = [['John', '30']];
await googleSheet.updateRows('Sheet1', data, 'USER_ENTERED', 2, 2, true);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
`/v4/spreadsheets/${spreadsheetId}/values/Sheet1!2:3:append`,
{
range: 'Sheet1!2:3',
values: data,
},
{ valueInputOption: 'USER_ENTERED' },
);
});
it('should handle range with rowsLength', async () => {
const data = [
['John', '30'],
['Jane', '25'],
];
await googleSheet.updateRows('Sheet1', data, 'USER_ENTERED', 2, 2);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'PUT',
`/v4/spreadsheets/${spreadsheetId}/values/Sheet1!2:3`,
{
range: 'Sheet1!2:3',
values: data,
},
{ valueInputOption: 'USER_ENTERED' },
);
});
});
describe('structureArrayDataByColumn', () => {
it('should structure data using key row', () => {
const inputData = [
['Name', 'Age', 'City'],
['John', '30', 'NYC'],
['Jane', '25', 'LA'],
];
const result = googleSheet.structureArrayDataByColumn(inputData, 0, 1);
expect(result).toEqual([
{ Name: 'John', Age: '30', City: 'NYC' },
{ Name: 'Jane', Age: '25', City: 'LA' },
]);
});
it('should return empty array for invalid key row', () => {
const inputData = [
['Name', 'Age'],
['John', '30'],
];
const result = googleSheet.structureArrayDataByColumn(inputData, -1, 1);
expect(result).toEqual([]);
});
it('should return empty array when dataStartRow < keyRow', () => {
const inputData = [
['Name', 'Age'],
['John', '30'],
];
const result = googleSheet.structureArrayDataByColumn(inputData, 1, 0);
expect(result).toEqual([]);
});
it('should handle missing column names with fallback', () => {
const inputData = [
['Name', '', 'City'], // Empty column name
['John', '30', 'NYC'],
];
const result = googleSheet.structureArrayDataByColumn(inputData, 0, 1);
expect(result).toEqual([{ Name: 'John', col_1: '30', City: 'NYC' }]);
});
it('should handle uneven row lengths', () => {
const inputData = [
['Name', 'Age', 'City'],
['John', '30'], // Shorter row
['Jane', '25', 'LA', 'Extra'], // Longer row
];
const result = googleSheet.structureArrayDataByColumn(inputData, 0, 1);
// The function uses the longest row to create keys, generating col_3 for the extra column
// Only properties with values are included, empty cells are omitted
expect(result).toEqual([
{ Name: 'John', Age: '30' },
{ Name: 'Jane', Age: '25', City: 'LA', col_3: 'Extra' },
]);
});
});
describe('testFilter', () => {
it('should return column keys from key row', () => {
const inputData = [
['Name', 'Age', 'City'],
['John', '30', 'NYC'],
];
const result = googleSheet.testFilter(inputData, 0, 1);
expect(result).toEqual(['Name', 'Age', 'City']);
});
it('should return empty array for invalid key row', () => {
const inputData = [['Name', 'Age']];
const result = googleSheet.testFilter(inputData, -1, 1);
expect(result).toEqual([]);
});
it('should return empty array when keyRow >= inputData.length', () => {
const inputData = [['Name', 'Age']];
const result = googleSheet.testFilter(inputData, 2, 1);
expect(result).toEqual([]);
});
});
describe('getColumnWithOffset', () => {
it('should calculate column with positive offset', () => {
const result = googleSheet.getColumnWithOffset('A', 2);
expect(result).toBe('C');
});
it('should calculate column with zero offset', () => {
const result = googleSheet.getColumnWithOffset('B', 0);
expect(result).toBe('B');
});
it('should handle double letter columns', () => {
const result = googleSheet.getColumnWithOffset('Z', 1);
expect(result).toBe('AA');
});
});
describe('getColumnValues', () => {
beforeEach(() => {
(apiRequest.call as jest.Mock).mockResolvedValue({
values: [['header'], ['value1'], ['value2']],
});
});
it('should get column values from sheet data when provided', async () => {
const sheetData = [
['Name', 'Age'],
['John', '30'],
['Jane', '25'],
];
const result = await googleSheet.getColumnValues({
range: 'Sheet1!A:B',
keyIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
sheetData,
});
expect(result).toEqual(['John', 'Jane']);
});
it('should make API call when sheet data not provided', async () => {
const result = await googleSheet.getColumnValues({
range: 'Sheet1!A1:B10',
keyIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
});
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'GET',
expect.stringContaining('/values/Sheet1!A1:A10'),
{},
{ valueRenderOption: 'UNFORMATTED_VALUE', dateTimeRenderOption: 'FORMATTED_STRING' },
);
expect(result).toEqual(['value1', 'value2']);
});
it('should throw error when column data cannot be retrieved', async () => {
(apiRequest.call as jest.Mock).mockResolvedValue({ values: undefined });
await expect(
googleSheet.getColumnValues({
range: 'Sheet1!A:B',
keyIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
}),
).rejects.toThrow('Could not retrieve the data from key column');
});
});
describe('prepareDataForUpdateOrUpsert', () => {
beforeEach(() => {
// Mock getData responses
(apiRequest.call as jest.Mock).mockImplementation(
(_: unknown, _method: unknown, url: string) => {
if (url.includes('/values/Sheet1!A1:C1')) {
return { values: [['Name', 'Age', 'City']] };
}
// Match the actual URL pattern generated by getColumnValues
if (url.includes('/values/Sheet1!A1:A10') || url.includes('/values/Sheet1%21A1%3AA10')) {
return { values: [['Name'], ['John'], ['Jane']] };
}
return {};
},
);
});
it('should prepare update data for existing records', async () => {
const inputData = [
{ Name: 'John', Age: '31', City: 'NYC' }, // Update existing
{ Name: 'Bob', Age: '25', City: 'LA' }, // New record
];
const result = await googleSheet.prepareDataForUpdateOrUpsert({
inputData,
indexKey: 'Name',
range: 'Sheet1!A1:C10',
keyRowIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
upsert: true,
});
expect(result.updateData).toHaveLength(2); // Age and City updates for John
expect(result.appendData).toHaveLength(1); // Bob should be appended
expect(result.appendData[0]).toEqual({ Name: 'Bob', Age: '25', City: 'LA' });
});
it('should throw error when index key not found and upsert is false', async () => {
const inputData = [{ Name: 'John', Age: '31' }];
await expect(
googleSheet.prepareDataForUpdateOrUpsert({
inputData,
indexKey: 'NonExistentKey',
range: 'Sheet1!A1:C10',
keyRowIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
upsert: false,
}),
).rejects.toThrow('Could not find column for key "NonExistentKey"');
});
it('should throw error when key row cannot be retrieved', async () => {
(apiRequest.call as jest.Mock).mockResolvedValue({ values: undefined });
const inputData = [{ Name: 'John' }];
await expect(
googleSheet.prepareDataForUpdateOrUpsert({
inputData,
indexKey: 'Name',
range: 'Sheet1!A1:C10',
keyRowIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
}),
).rejects.toThrow('Could not retrieve the key row');
});
it('should handle items without index key when upsert is true', async () => {
const inputData = [{ Age: '30', City: 'NYC' }]; // No Name field
const result = await googleSheet.prepareDataForUpdateOrUpsert({
inputData,
indexKey: 'Name',
range: 'Sheet1!A1:C10',
keyRowIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
upsert: true,
});
expect(result.updateData).toHaveLength(0);
expect(result.appendData).toHaveLength(1);
});
it('should stringify object values', async () => {
const inputData = [{ Name: 'John', Age: { years: 30 }, City: 'NYC' }];
const result = await googleSheet.prepareDataForUpdateOrUpsert({
inputData,
indexKey: 'Name',
range: 'Sheet1!A1:C10',
keyRowIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
upsert: true,
});
const ageUpdate = result.updateData.find((update) => update.range.includes('B'));
expect(ageUpdate?.values[0][0]).toBe('{"years":30}');
});
});
describe('prepareDataForUpdatingByRowNumber', () => {
it('should prepare update data using row numbers', () => {
const inputData = [
{ row_number: 2, Name: 'John', Age: '31' },
{ row_number: 3, Name: 'Jane', Age: '26' },
];
const columnNamesList = [['row_number', 'Name', 'Age', 'City']];
const result = googleSheet.prepareDataForUpdatingByRowNumber(
inputData,
'Sheet1!A1:D10',
columnNamesList,
);
expect(result.updateData).toHaveLength(4); // 2 items × 2 fields each
expect(result.updateData[0]).toEqual({
range: 'Sheet1!B2',
values: [['John']],
});
expect(result.updateData[1]).toEqual({
range: 'Sheet1!C2',
values: [['31']],
});
});
it('should skip row_number field and null/undefined values', () => {
const inputData = [{ row_number: 2, Name: 'John', Age: null, City: undefined }];
const columnNamesList = [['row_number', 'Name', 'Age', 'City']];
const result = googleSheet.prepareDataForUpdatingByRowNumber(
inputData,
'Sheet1!A1:D10',
columnNamesList,
);
expect(result.updateData).toHaveLength(1); // Only Name field
expect(result.updateData[0].range).toBe('Sheet1!B2');
});
it('should stringify object values', () => {
const inputData = [{ row_number: 2, Name: { first: 'John', last: 'Doe' } }];
const columnNamesList = [['row_number', 'Name']];
const result = googleSheet.prepareDataForUpdatingByRowNumber(
inputData,
'Sheet1!A1:B10',
columnNamesList,
);
expect(result.updateData[0].values[0][0]).toBe('{"first":"John","last":"Doe"}');
});
});
describe('private method testing via clearData (encodeRange)', () => {
it('should encode sheet name with special characters', async () => {
const range = 'Sheet with spaces!A1:B2';
await googleSheet.clearData(range);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
`/v4/spreadsheets/${spreadsheetId}/values/Sheet%20with%20spaces!A1:B2:clear`,
{ spreadsheetId, range },
);
});
it('should encode range without sheet reference', async () => {
const range = 'Sheet with spaces';
await googleSheet.clearData(range);
expect(apiRequest.call).toHaveBeenCalledWith(
mockExecuteFunctions,
'POST',
expect.stringContaining(encodeURIComponent("'Sheet with spaces'")),
{ spreadsheetId, range },
);
});
});
});
@@ -0,0 +1,274 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { sheetsSearch, spreadSheetsSearch } from '../../../v2/methods/listSearch';
import { apiRequest } from '../../../v2/transport';
jest.mock('../../../v2/transport', () => ({
apiRequest: {
call: jest.fn(),
},
}));
describe('Google Sheets Search Functions', () => {
let mockLoadOptionsFunctions: Partial<ILoadOptionsFunctions>;
beforeEach(() => {
mockLoadOptionsFunctions = {
getNode: jest.fn(),
getNodeParameter: jest.fn(),
};
jest.clearAllMocks();
});
describe('spreadSheetsSearch', () => {
it('should return search results without filter', async () => {
const mockResponse = {
files: [
{ id: '1', name: 'Sheet1', webViewLink: 'https://sheet1.url' },
{ id: '2', name: 'Sheet2', webViewLink: 'https://sheet2.url' },
],
nextPageToken: 'next-page',
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await spreadSheetsSearch.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
);
expect(apiRequest.call).toHaveBeenCalledWith(
mockLoadOptionsFunctions,
'GET',
'',
{},
{
q: "mimeType = 'application/vnd.google-apps.spreadsheet'",
fields: 'nextPageToken, files(id, name, webViewLink)',
orderBy: 'modifiedByMeTime desc,name_natural',
includeItemsFromAllDrives: true,
supportsAllDrives: true,
},
'https://www.googleapis.com/drive/v3/files',
);
expect(result).toEqual({
results: [
{ name: 'Sheet1', value: '1', url: 'https://sheet1.url' },
{ name: 'Sheet2', value: '2', url: 'https://sheet2.url' },
],
paginationToken: 'next-page',
});
});
it('should handle search with filter', async () => {
const mockResponse = {
files: [{ id: '1', name: 'TestSheet', webViewLink: 'https://test.url' }],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await spreadSheetsSearch.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
'Test',
);
expect(apiRequest.call).toHaveBeenCalledWith(
mockLoadOptionsFunctions,
'GET',
'',
{},
{
q: "name contains 'Test' and mimeType = 'application/vnd.google-apps.spreadsheet'",
fields: 'nextPageToken, files(id, name, webViewLink)',
orderBy: 'modifiedByMeTime desc,name_natural',
includeItemsFromAllDrives: true,
supportsAllDrives: true,
},
'https://www.googleapis.com/drive/v3/files',
);
expect(result.results).toHaveLength(1);
expect(result.results[0].name).toBe('TestSheet');
});
it('should escape single quotes in filter', async () => {
const mockResponse = { files: [] };
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
await spreadSheetsSearch.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
"Test's Sheet",
);
expect(apiRequest.call).toHaveBeenCalledWith(
expect.anything(),
'GET',
'',
{},
expect.objectContaining({
q: "name contains 'Test\\'s Sheet' and mimeType = 'application/vnd.google-apps.spreadsheet'",
}),
expect.any(String),
);
});
it('should handle pagination token', async () => {
const mockResponse = { files: [] };
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
await spreadSheetsSearch.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
undefined,
'page-token',
);
expect(apiRequest.call).toHaveBeenCalledWith(
expect.anything(),
'GET',
'',
{},
expect.objectContaining({
pageToken: 'page-token',
}),
expect.any(String),
);
});
});
describe('sheetsSearch', () => {
it('should return empty results when no documentId is provided', async () => {
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue(null);
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
expect(result).toEqual({ results: [] });
expect(apiRequest.call).not.toHaveBeenCalled();
});
it('should return sheets list for valid spreadsheet', async () => {
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
mode: 'id',
value: 'spreadsheet-id',
});
const mockResponse = {
sheets: [
{
properties: {
sheetId: 123,
title: 'Sheet1',
sheetType: 'GRID',
},
},
{
properties: {
sheetId: 456,
title: 'Sheet2',
sheetType: 'GRID',
},
},
{
properties: {
sheetId: 789,
title: 'Chart1',
sheetType: 'CHART',
},
},
],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
expect(apiRequest.call).toHaveBeenCalledWith(
mockLoadOptionsFunctions,
'GET',
'/v4/spreadsheets/spreadsheet-id',
{},
{ fields: 'sheets.properties' },
);
expect(result.results).toHaveLength(2); // Only GRID type sheets
expect(result.results[0]).toEqual({
name: 'Sheet1',
value: 123,
url: 'https://docs.google.com/spreadsheets/d/spreadsheet-id/edit#gid=123',
});
});
it('should handle default sheet id when sheetId is not provided', async () => {
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
mode: 'id',
value: 'spreadsheet-id',
});
const mockResponse = {
sheets: [
{
properties: {
title: 'Sheet1',
sheetType: 'GRID',
},
},
],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
expect(result.results[0].value).toBe('gid=0');
});
it('should throw error when no data is returned', async () => {
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
mode: 'id',
value: 'spreadsheet-id',
});
mockLoadOptionsFunctions.getNode = jest.fn().mockReturnValue({});
(apiRequest.call as jest.Mock).mockResolvedValue(undefined);
await expect(
sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions),
).rejects.toThrow(
new NodeOperationError(mockLoadOptionsFunctions.getNode(), 'No data got returned'),
);
});
it('should filter out non-GRID type sheets', async () => {
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
mode: 'id',
value: 'spreadsheet-id',
});
const mockResponse = {
sheets: [
{
properties: {
sheetId: 123,
title: 'Chart',
sheetType: 'CHART',
},
},
{
properties: {
sheetId: 456,
title: 'Grid',
sheetType: 'GRID',
},
},
],
};
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
expect(result.results).toHaveLength(1);
expect(result.results[0].name).toBe('Grid');
});
});
});
@@ -0,0 +1,169 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import {
getSheetHeaderRow,
getSheetHeaderRowAndAddColumn,
getSheetHeaderRowAndSkipEmpty,
getSheetHeaderRowWithGeneratedColumnNames,
getSheets,
} from '../../../v2/methods/loadOptions';
jest.mock('../../../v2/helpers/GoogleSheets.utils');
const mockGoogleSheetInstance = {
spreadsheetGetSheets: jest.fn(),
spreadsheetGetSheet: jest.fn(),
getData: jest.fn(),
testFilter: jest.fn(),
};
jest.mock('../../../v2/helpers/GoogleSheet', () => ({
GoogleSheet: jest.fn().mockImplementation(() => mockGoogleSheetInstance),
}));
describe('Google Sheets Functions', () => {
let mockLoadOptionsFunctions: Partial<ILoadOptionsFunctions>;
beforeEach(() => {
jest.clearAllMocks();
mockLoadOptionsFunctions = {
getNodeParameter: jest.fn((paramName: string) => {
if (paramName === 'documentId') {
return { mode: 'mode', value: 'value' };
}
if (paramName === 'sheetName') {
return { mode: 'Sheet1', value: 'Sheet1' };
}
}),
getNode: jest.fn(),
};
});
describe('getSheets', () => {
it('should return an empty array if documentId is null', async () => {
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue(null);
const result = await getSheets.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
expect(result).toEqual([]);
});
it('should throw an error if no data is returned', async () => {
mockGoogleSheetInstance.spreadsheetGetSheets.mockResolvedValue(undefined);
await expect(
getSheets.call(mockLoadOptionsFunctions as ILoadOptionsFunctions),
).rejects.toThrow(NodeOperationError);
});
it('should return sheets with GRID type', async () => {
mockGoogleSheetInstance.spreadsheetGetSheets.mockResolvedValue({
sheets: [
{ properties: { sheetType: 'GRID', title: 'Sheet1', sheetId: '123' } },
{ properties: { sheetType: 'OTHER', title: 'Sheet2', sheetId: '456' } },
],
});
const result = await getSheets.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
expect(result).toEqual([{ name: 'Sheet1', value: '123' }]);
});
});
describe('getSheetHeaderRow', () => {
it('should return an empty array if documentId is null', async () => {
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue(null);
const result = await getSheetHeaderRow.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
);
expect(result).toEqual([]);
});
it('should throw an error if no data is returned', async () => {
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
title: 'Sheet1',
});
mockGoogleSheetInstance.getData.mockResolvedValue(undefined);
await expect(
getSheetHeaderRow.call(mockLoadOptionsFunctions as ILoadOptionsFunctions),
).rejects.toThrow(NodeOperationError);
});
it('should return column headers', async () => {
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
title: 'Sheet1',
});
mockGoogleSheetInstance.getData.mockResolvedValue([['Header1', 'Header2', 'Header3']]);
mockGoogleSheetInstance.testFilter.mockReturnValue(['Header1', 'Header2', 'Header3']);
const result = await getSheetHeaderRow.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
);
expect(result).toEqual([
{ name: 'Header1', value: 'Header1' },
{ name: 'Header2', value: 'Header2' },
{ name: 'Header3', value: 'Header3' },
]);
});
});
describe('getSheetHeaderRowAndAddColumn', () => {
it('should add a new column and exclude the column to match on', async () => {
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
title: 'Sheet1',
});
mockGoogleSheetInstance.getData.mockResolvedValue([['Header1']]);
mockGoogleSheetInstance.testFilter.mockReturnValue(['Header1']);
const result = await getSheetHeaderRowAndAddColumn.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
);
expect(result).toEqual([
{ name: 'Header1', value: 'Header1' },
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'New column ...', value: 'newColumn' },
]);
});
});
describe('getSheetHeaderRowWithGeneratedColumnNames', () => {
it('should generate column names for empty values', async () => {
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
title: 'Sheet1',
});
mockGoogleSheetInstance.getData.mockResolvedValue([['', 'Header1', '']]);
mockGoogleSheetInstance.testFilter.mockReturnValue(['', 'Header1', '']);
const result = await getSheetHeaderRowWithGeneratedColumnNames.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
);
expect(result).toEqual([
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'col_1', value: 'col_1' },
{ name: 'Header1', value: 'Header1' },
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'col_3', value: 'col_3' },
]);
});
});
describe('getSheetHeaderRowAndSkipEmpty', () => {
it('should skip columns with empty values', async () => {
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
title: 'Sheet1',
});
mockGoogleSheetInstance.getData.mockResolvedValue([['', 'Header1', '']]);
mockGoogleSheetInstance.testFilter.mockReturnValue(['', 'Header1', '']);
const result = await getSheetHeaderRowAndSkipEmpty.call(
mockLoadOptionsFunctions as ILoadOptionsFunctions,
);
expect(result).toEqual([{ name: 'Header1', value: 'Header1' }]);
});
});
});
@@ -0,0 +1,113 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { ILoadOptionsFunctions, INode } from 'n8n-workflow';
import { getMappingColumns } from '../../../v2/methods/resourceMapping';
jest.mock('../../../v2/helpers/GoogleSheets.utils');
const mockGoogleSheetInstance = {
spreadsheetGetSheets: jest.fn(),
spreadsheetGetSheet: jest.fn(),
getData: jest.fn(),
testFilter: jest.fn(),
};
jest.mock('../../../v2/helpers/GoogleSheet', () => ({
GoogleSheet: jest.fn().mockImplementation(() => mockGoogleSheetInstance),
}));
describe('Google Sheets, getMappingColumns', () => {
let loadOptionsFunctions: MockProxy<ILoadOptionsFunctions>;
beforeEach(() => {
loadOptionsFunctions = mock<ILoadOptionsFunctions>();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should map columns and add row number for update operation', async () => {
loadOptionsFunctions.getNode.mockReturnValue({} as INode);
loadOptionsFunctions.getNodeParameter
.mockReturnValueOnce({ mode: 'id', value: 'spreadsheetId' }) // documentId
.mockReturnValueOnce({ mode: 'name', value: 'Sheet1' }) // sheetName
.mockReturnValueOnce({ mode: 'name' }) // sheetName mode
.mockReturnValueOnce({}) // options.locationDefine.values
.mockReturnValueOnce('update'); // operation
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValueOnce({
title: 'Sheet1',
sheetId: 1,
});
mockGoogleSheetInstance.getData.mockResolvedValueOnce([['id', 'name', 'email']]);
mockGoogleSheetInstance.testFilter.mockReturnValueOnce(['id', 'name', 'email']);
const result = await getMappingColumns.call(loadOptionsFunctions);
expect(result.fields).toHaveLength(4);
expect(result.fields).toEqual([
{
canBeUsedToMatch: true,
defaultMatch: true,
display: true,
displayName: 'id',
id: 'id',
required: false,
type: 'string',
},
{
canBeUsedToMatch: true,
defaultMatch: false,
display: true,
displayName: 'name',
id: 'name',
required: false,
type: 'string',
},
{
canBeUsedToMatch: true,
defaultMatch: false,
display: true,
displayName: 'email',
id: 'email',
required: false,
type: 'string',
},
{
canBeUsedToMatch: true,
defaultMatch: false,
display: true,
displayName: 'row_number',
id: 'row_number',
readOnly: true,
removed: true,
required: false,
type: 'number',
},
]);
});
it('should map columns and add row number for appendOrUpdate operation', async () => {
loadOptionsFunctions.getNode.mockReturnValue({} as INode);
loadOptionsFunctions.getNodeParameter
.mockReturnValueOnce({ mode: 'id', value: 'spreadsheetId' }) // documentId
.mockReturnValueOnce({ mode: 'name', value: 'Sheet1' }) // sheetName
.mockReturnValueOnce({ mode: 'name' }) // sheetName mode
.mockReturnValueOnce({ headerRow: 10 }) // options.locationDefine.values
.mockReturnValueOnce('appendOrUpdate'); // operation
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValueOnce({
title: 'Sheet1',
sheetId: 1,
});
mockGoogleSheetInstance.getData.mockResolvedValueOnce([['id', 'name', 'email']]);
mockGoogleSheetInstance.testFilter.mockReturnValueOnce(['id', 'name', 'email']);
const result = await getMappingColumns.call(loadOptionsFunctions);
expect(result.fields).toHaveLength(3);
expect(mockGoogleSheetInstance.getData).toHaveBeenCalledWith('Sheet1!10:10', 'FORMATTED_VALUE');
});
});
@@ -0,0 +1,61 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/append.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Append', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
});
it('should insert input data if sheet is empty', async () => {
const inputData = [
{
json: {
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
pairedItem: {
item: 0,
input: undefined,
},
},
];
mockExecuteFunctions.getInputData.mockReturnValueOnce(inputData);
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('defineBelow'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
mockGoogleSheet.updateRows.mockResolvedValueOnce(undefined);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith('Sheet1', [['name', 'text']], 'RAW', 1);
expect(mockGoogleSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('1234', 1, 0);
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [{ name: 'NEW NAME', text: 'NEW TEXT' }],
keyRowIndex: 1,
lastRow: 2,
range: 'Sheet1',
valueInputMode: 'USER_ENTERED',
});
expect(inputData[0].json).toEqual({ row_number: 3, name: 'NEW NAME', text: 'NEW TEXT' });
});
});
@@ -0,0 +1,859 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/appendOrUpdate.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Append or Update', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
});
it('should insert input data if sheet is empty', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('defineBelow'); // dataMode
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce([]); // columns.schema
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(['row_number']); // columnsToMatchOn
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>());
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce([]); // columns.matchingColumns
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [
{
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
dataStartRowIndex: 1,
keyIndex: -1,
range: 'Sheet1!A:Z',
sheetData: [['name', 'text']],
valueRenderMode: 'UNFORMATTED_VALUE',
});
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
'Sheet1',
[['name', 'text']],
'USER_ENTERED',
1,
);
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({
columnNamesList: [['name', 'text']],
columnValuesList: [],
dataStartRowIndex: 1,
indexKey: 'row_number',
inputData: [{ name: 'NEW NAME', row_number: 3, text: 'NEW TEXT' }],
keyRowIndex: 0,
range: 'Sheet1!A:Z',
upsert: true,
valueRenderMode: 'UNFORMATTED_VALUE',
});
expect(mockGoogleSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('1234', 1, 0);
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
columnNamesList: [['name', 'text']],
inputData: [{ name: 'NEW NAME', row_number: 3, text: 'NEW TEXT' }],
keyRowIndex: 1,
lastRow: 2,
range: 'Sheet1!A:Z',
valueInputMode: 'USER_ENTERED',
});
});
it('should throw error when no column names can be retrieved and sheet has data', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([{ column: 'name', fieldValue: 'Test Name' }]); // fieldsUi.values
// Mock sheet with data but no header row at keyRowIndex
mockGoogleSheet.getData.mockResolvedValueOnce([
undefined as any, // No header row at keyRowIndex 0
['some', 'data', 'here'], // Has data but not at header position
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [],
});
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle custom header row and first data row positions', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({
locationDefine: {
values: {
headerRow: '3',
firstDataRow: '5',
},
},
}) // options with custom positions
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([{ column: 'name', fieldValue: 'Test Name' }]); // fieldsUi.values
mockGoogleSheet.getData.mockResolvedValueOnce([
[],
[],
['id', 'name'], // Header at row 3 (index 2)
[],
['1', 'Old Name'], // Data starts at row 5 (index 4)
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: '1', name: 'Test Name' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
dataStartRowIndex: 4, // firstDataRow - 1
keyIndex: 0,
range: 'Sheet1!A:Z',
sheetData: expect.any(Array),
valueRenderMode: 'UNFORMATTED_VALUE',
});
});
it('should handle data mode "nothing" by skipping processing', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{ json: { id: 1, name: 'Test' }, pairedItem: { item: 0, input: undefined } },
{ json: { id: 2, name: 'Test2' }, pairedItem: { item: 1, input: undefined } },
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('nothing') // dataMode
.mockReturnValueOnce('id'); // columnToMatchOn
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [],
});
const result = await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// Should return original items since dataMode is 'nothing'
expect(result).toEqual([
{ json: { id: 1, name: 'Test' }, pairedItem: { item: 0 } },
{ json: { id: 2, name: 'Test2' }, pairedItem: { item: 1 } },
]);
// Should not call update or append operations
expect(mockGoogleSheet.batchUpdate).not.toHaveBeenCalled();
expect(mockGoogleSheet.appendSheetData).not.toHaveBeenCalled();
});
it('should handle autoMapInputData with ignoreIt option', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test', extraField: 'should be ignored' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({ handlingExtraData: 'ignoreIt' }) // options
.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test', extraField: 'should be ignored' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
inputData: [{ id: 1, name: 'Test', extraField: 'should be ignored' }],
}),
);
});
it('should handle autoMapInputData with error option and throw on unexpected fields', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test', unexpectedField: 'error' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({ handlingExtraData: 'error' }) // options
.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle autoMapInputData with insertInNewColumn option', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test', newField: 'new column value' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({ handlingExtraData: 'insertInNewColumn' }) // options
.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test', newField: 'new column value' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// Should update header row with new column
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
'Sheet1',
[['id', 'name', 'newField']],
'RAW',
1,
);
});
it('should throw error when valueToMatchOn is empty in defineBelow mode (v3)', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce(''); // empty valueToMatchOn
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should throw error when no values are provided in defineBelow mode (v3)', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([]); // empty fieldsUi.values
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle newColumn type in defineBelow mode (v3)', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([
{ column: 'name', fieldValue: 'Updated Name' },
{ column: 'newColumn', columnName: 'description', fieldValue: 'New Description' },
]); // fieldsUi.values with new column
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: '1', name: 'Updated Name', description: 'New Description' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// Should update header with new column
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
'Sheet1',
[['id', 'name', 'description']],
'RAW',
1,
);
});
it('should handle both update and append data', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Updated Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [{ range: 'A2:B2', values: [['1', 'Updated Name']] }],
appendData: [{ id: 2, name: 'New Item' }],
});
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
[{ range: 'A2:B2', values: [['1', 'Updated Name']] }],
'USER_ENTERED',
);
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalled();
});
it('should use useAppend option when appending data', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: { useAppend: true },
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Test Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test Name' }],
});
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
columnNamesList: [['id', 'name']],
inputData: [{ id: 1, name: 'Test Name' }],
keyRowIndex: 1,
lastRow: 2,
range: 'Sheet1!A:Z',
useAppend: true,
valueInputMode: 'USER_ENTERED',
});
// Should NOT call appendEmptyRowsOrColumns when useAppend is true
expect(mockGoogleSheet.appendEmptyRowsOrColumns).not.toHaveBeenCalled();
});
it('should handle v4 with empty columns.value and throw error', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': {},
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle v4 with null/undefined values by converting to empty string', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: null, description: undefined },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name', 'description']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: '', description: '' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
inputData: [{ id: 1, name: '', description: '' }],
}),
);
});
it('should return mapped values for v4+ in defineBelow mode', async () => {
const mappedValues = [
{ id: 1, name: 'Test Name' },
{ id: 2, name: 'Another Test' },
];
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{ json: { id: 1 }, pairedItem: { item: 0, input: undefined } },
{ json: { id: 2 }, pairedItem: { item: 1, input: undefined } },
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value[id]': itemIndex === 0 ? 1 : 2,
};
// Handle per-item columns.value calls
if (paramName === 'columns.value' && typeof itemIndex === 'number') {
return mappedValues[itemIndex];
}
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
// Mock prepareDataForUpdateOrUpsert to be called for each item
mockGoogleSheet.prepareDataForUpdateOrUpsert
.mockResolvedValueOnce({
updateData: [],
appendData: [mappedValues[0]],
})
.mockResolvedValueOnce({
updateData: [],
appendData: [mappedValues[1]],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
const result = await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(result).toEqual([
{ json: mappedValues[0], pairedItem: { item: 0 } },
{ json: mappedValues[1], pairedItem: { item: 1 } },
]);
});
it('should handle custom valueRenderMode option', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: { valueRenderMode: 'FORMATTED_VALUE' },
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Test Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test Name' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith(
expect.objectContaining({
valueRenderMode: 'FORMATTED_VALUE',
}),
);
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
valueRenderMode: 'FORMATTED_VALUE',
}),
);
});
it('should call checkForSchemaChanges for node version >= 4.4', async () => {
const mockSchema = [
{ id: 'id', displayName: 'ID' },
{ id: 'name', displayName: 'Name' },
];
const mockNode = mock<INode>({ typeVersion: 4.5 });
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': mockSchema,
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Test Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test Name' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
// Mock the checkForSchemaChanges import to verify it's called
const GoogleSheetsUtils = await import('../../../v2/helpers/GoogleSheets.utils');
jest.spyOn(GoogleSheetsUtils, 'checkForSchemaChanges').mockImplementation(() => {});
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(GoogleSheetsUtils.checkForSchemaChanges).toHaveBeenCalledWith(
mockNode, // node
['id', 'name'], // columnNames
mockSchema, // schema
);
});
});
describe('Google Sheet - Append or Update v4.6 vs v4.7 Behavior', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
afterEach(() => {
jest.resetAllMocks();
});
it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }))
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }));
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {},
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': {
id: 1,
name: 'John',
// email field is NOT present here because user typed '' in UI
// and v4.6 frontend filtered it out (allowEmptyValues: false)
},
};
return params[parameterName];
});
mockGoogleSheet.getData.mockResolvedValueOnce([
['id', 'name', 'email'],
['1', 'Old Name', 'old@email.com'],
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [
{
id: 1,
name: 'John',
// email is not included, so it keeps old value
},
],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// v4.6: Only fields with non-empty values are sent to prepareDataForUpdateOrUpsert
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
inputData: [
{
id: 1,
name: 'John',
// email is NOT in the inputData, so cell keeps old value
},
],
}),
);
});
it('v4.7: empty string in UI is preserved and sent to backend to clear cell', async () => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.7 }))
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.7 }));
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {},
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': {
id: 1,
name: 'John',
email: '', // Empty string is preserved in v4.7 (allowEmptyValues: true)
},
};
return params[parameterName];
});
mockGoogleSheet.getData.mockResolvedValueOnce([
['id', 'name', 'email'],
['1', 'Old Name', 'old@email.com'],
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [
{
id: 1,
name: 'John',
email: '', // Empty string will clear the cell
},
],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// v4.7: Empty strings are preserved and sent to prepareDataForUpdateOrUpsert
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
inputData: [
{
id: 1,
name: 'John',
email: '', // Empty string is preserved and will clear the cell
},
],
}),
);
});
});
@@ -0,0 +1,105 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/clear.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Clear', () => {
let mockExecuteFunctions: Partial<IExecuteFunctions>;
let mockSheet: Partial<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = {
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNodeParameter: jest.fn(),
} as Partial<IExecuteFunctions>;
mockSheet = {
clearData: jest.fn(),
getData: jest.fn().mockResolvedValue([['Header1', 'Header2']]), // Mock first-row data
updateRows: jest.fn(),
} as Partial<GoogleSheet>;
});
test('should clear the whole sheet', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'clear') return 'wholeSheet';
return false;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1');
});
test('should clear specific rows', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'clear') return 'specificRows';
if (param === 'startIndex') return 2;
if (param === 'rowsToDelete') return 3;
return false;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1!2:4');
});
test('should clear specific columns', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'clear') return 'specificColumns';
if (param === 'startIndex') return 'B';
if (param === 'columnsToDelete') return 2;
return false;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1!B:C');
});
test('should clear a specific range', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'clear') return 'specificRange';
if (param === 'range') return 'A1:C5';
return false;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1!A1:C5');
});
test('should keep the first row when clearing the whole sheet', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'clear') return 'wholeSheet';
if (param === 'keepFirstRow') return true;
return false;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.getData).toHaveBeenCalledWith('Sheet1!1:1', 'FORMATTED_VALUE');
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1');
expect(mockSheet.updateRows).toHaveBeenCalledWith('Sheet1', [['Header1', 'Header2']], 'RAW', 1);
});
});
@@ -0,0 +1,100 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/create.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
import { getExistingSheetNames, hexToRgb } from '../../../v2/helpers/GoogleSheets.utils';
import { apiRequest } from '../../../v2/transport';
jest.mock('../../../v2/helpers/GoogleSheets.utils', () => ({
getExistingSheetNames: jest.fn(),
hexToRgb: jest.fn(),
}));
jest.mock('../../../v2/transport', () => ({
apiRequest: jest.fn(),
}));
describe('Google Sheet - Create', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockExecuteFunctions = {
getInputData: jest.fn(),
getNodeParameter: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn(),
},
} as unknown as Partial<IExecuteFunctions>;
const sheet = {} as Partial<GoogleSheet>;
const sheetName = 'test-sheet';
test('should create a new sheet with given title and options', async () => {
const items = [{ json: {} }];
const existingSheetNames = ['existing-sheet'];
const sheetTitle = 'new-sheet';
const options = { tabColor: '0aa55c' };
const rgbColor = { red: 10, green: 165, blue: 92 };
const responseData = {
replies: [{ addSheet: { properties: { title: sheetTitle } } }],
};
(mockExecuteFunctions.getInputData as jest.Mock).mockReturnValue(items);
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
if (paramName === 'title') return sheetTitle;
if (paramName === 'options') return options;
});
(getExistingSheetNames as jest.Mock).mockResolvedValue(existingSheetNames);
(hexToRgb as jest.Mock).mockReturnValue(rgbColor);
(apiRequest as jest.Mock).mockResolvedValue(responseData);
(mockExecuteFunctions as IExecuteFunctions).helpers.constructExecutionMetaData = jest
.fn()
.mockReturnValue([{ json: responseData }]);
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
sheet as GoogleSheet,
sheetName,
);
expect(result).toEqual([{ json: responseData }]);
expect(getExistingSheetNames).toHaveBeenCalledWith(sheet);
expect(apiRequest).toHaveBeenCalledWith('POST', `/v4/spreadsheets/${sheetName}:batchUpdate`, {
requests: [
{
addSheet: {
properties: {
title: sheetTitle,
tabColor: { red: 10 / 255, green: 165 / 255, blue: 92 / 255 },
},
},
},
],
});
});
test('should skip creating a sheet if the title already exists', async () => {
const items = [{ json: {} }];
const existingSheetNames = ['existing-sheet'];
const sheetTitle = 'existing-sheet';
(mockExecuteFunctions as IExecuteFunctions).getInputData = jest.fn().mockReturnValue(items);
(mockExecuteFunctions as IExecuteFunctions).getNodeParameter = jest
.fn()
.mockImplementation((paramName: string) => {
if (paramName === 'title') return sheetTitle;
if (paramName === 'options') return {};
});
(getExistingSheetNames as jest.Mock).mockResolvedValue(existingSheetNames);
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
sheet as GoogleSheet,
sheetName,
);
expect(result).toEqual([]);
expect(getExistingSheetNames).toHaveBeenCalledWith(sheet);
expect(apiRequest).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,154 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/delete.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Delete', () => {
let mockExecuteFunctions: Partial<IExecuteFunctions>;
let mockSheet: Partial<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = {
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNodeParameter: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn((data) => ({ json: data })),
},
} as unknown as Partial<IExecuteFunctions>;
mockSheet = {
spreadsheetBatchUpdate: jest.fn(),
} as Partial<GoogleSheet>;
});
test('should delete a single row', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'toDelete') return 'rows';
if (param === 'startIndex') return 2;
if (param === 'numberToDelete') return 1;
return null;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
{
deleteDimension: {
range: {
sheetId: 'Sheet1',
dimension: 'ROWS',
startIndex: 1, // Adjusted for zero-based index
endIndex: 2,
},
},
},
]);
});
test('should delete multiple rows', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'toDelete') return 'rows';
if (param === 'startIndex') return 3;
if (param === 'numberToDelete') return 2;
return null;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
{
deleteDimension: {
range: {
sheetId: 'Sheet1',
dimension: 'ROWS',
startIndex: 2,
endIndex: 4,
},
},
},
]);
});
test('should delete a single column', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'toDelete') return 'columns';
if (param === 'startIndex') return 'B';
if (param === 'numberToDelete') return 1;
return null;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
{
deleteDimension: {
range: {
sheetId: 'Sheet1',
dimension: 'COLUMNS',
startIndex: 1, // 'B' corresponds to index 1 (zero-based)
endIndex: 2,
},
},
},
]);
});
test('should delete multiple columns', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'toDelete') return 'columns';
if (param === 'startIndex') return 'C';
if (param === 'numberToDelete') return 3;
return null;
}) as unknown as IExecuteFunctions['getNodeParameter'];
await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
{
deleteDimension: {
range: {
sheetId: 'Sheet1',
dimension: 'COLUMNS',
startIndex: 2, // 'C' corresponds to index 2 (zero-based)
endIndex: 5,
},
},
},
]);
});
test('should return wrapped success response', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
if (param === 'toDelete') return 'rows';
if (param === 'startIndex') return 2;
if (param === 'numberToDelete') return 1;
return null;
}) as unknown as IExecuteFunctions['getNodeParameter'];
mockExecuteFunctions.helpers = {
constructExecutionMetaData: jest.fn((data) => data),
} as unknown as IExecuteFunctions['helpers'];
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(result).toEqual([{ json: { success: true } }]);
});
});
@@ -0,0 +1,81 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/read.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Read', () => {
let mockExecuteFunctions: Partial<IExecuteFunctions>;
let mockSheet: Partial<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = {
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNode: jest.fn().mockReturnValue({ typeVersion: 4.5 }),
getNodeParameter: jest.fn((param) => {
const mockParams: { [key: string]: unknown } = {
options: {},
'filtersUI.values': [],
combineFilters: 'AND',
};
return mockParams[param];
}),
} as Partial<IExecuteFunctions>;
mockSheet = {
getData: jest.fn().mockResolvedValue([
['Header1', 'Header2'],
['Value1', 'Value2'],
]),
lookupValues: jest.fn().mockResolvedValue([{ Header1: 'Value1', Header2: 'Value2' }]),
structureArrayDataByColumn: jest
.fn()
.mockReturnValue([{ Header1: 'Value1', Header2: 'Value2' }]),
};
});
test('should return structured sheet data when no filters are applied', async () => {
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.getData).toHaveBeenCalled();
expect(mockSheet.structureArrayDataByColumn).toHaveBeenCalled();
expect(result).toEqual([
{
json: { Header1: 'Value1', Header2: 'Value2' },
pairedItem: { item: 0 },
},
]);
});
test('should call lookupValues when filters are provided', async () => {
mockExecuteFunctions.getNodeParameter = jest.fn((param) => {
if (param === 'filtersUI.values') return [{ lookupColumn: 'Header1', lookupValue: 'Value1' }];
return '';
}) as unknown as IExecuteFunctions['getNodeParameter'];
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(mockSheet.lookupValues).toHaveBeenCalled();
expect(result).toEqual([
{
json: { Header1: 'Value1', Header2: 'Value2' },
pairedItem: { item: 0 },
},
]);
});
test('should return an empty array when sheet data is empty', async () => {
mockSheet.getData = jest.fn().mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
mockSheet as GoogleSheet,
'Sheet1',
);
expect(result).toEqual([]);
});
});
@@ -0,0 +1,113 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/remove.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
import { apiRequest } from '../../../v2/transport';
jest.mock('../../../v2/transport', () => ({
apiRequest: jest.fn(),
}));
describe('Google Sheet - Remove', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockExecuteFunctions = {
getInputData: jest.fn(),
getNodeParameter: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn(),
},
} as unknown as Partial<IExecuteFunctions>;
const sheet = {} as Partial<GoogleSheet>;
const sheetName = 'spreadsheet123||sheet456';
test('should process a single item', async () => {
const items = [{ json: {} }];
((mockExecuteFunctions as IExecuteFunctions).getInputData as jest.Mock).mockReturnValue(items);
const apiResponse = { replies: [{ some: 'data' }], foo: 'bar' };
(apiRequest as jest.Mock).mockResolvedValue(apiResponse);
const constructedData = [{ json: { foo: 'bar', index: 0 } }];
(
(mockExecuteFunctions as IExecuteFunctions).helpers.constructExecutionMetaData as jest.Mock
).mockReturnValue(constructedData);
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
sheet as GoogleSheet,
sheetName,
);
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith('POST', '/v4/spreadsheets/spreadsheet123:batchUpdate', {
requests: [
{
deleteSheet: {
sheetId: 'sheet456',
},
},
],
});
expect(result).toEqual(constructedData);
});
test('should process multiple items', async () => {
const items = [{ json: {} }, { json: {} }];
((mockExecuteFunctions as IExecuteFunctions).getInputData as jest.Mock).mockReturnValue(items);
const apiResponses = [
{ replies: [{ some: 'data1' }], foo: 'bar1' },
{ replies: [{ some: 'data2' }], foo: 'bar2' },
];
(apiRequest as jest.Mock)
.mockResolvedValueOnce(apiResponses[0])
.mockResolvedValueOnce(apiResponses[1]);
const constructedDataItem0 = [{ json: { foo: 'bar1', index: 0 } }];
const constructedDataItem1 = [{ json: { foo: 'bar2', index: 1 } }];
((mockExecuteFunctions as IExecuteFunctions).helpers.constructExecutionMetaData as jest.Mock)
.mockReturnValueOnce(constructedDataItem0)
.mockReturnValueOnce(constructedDataItem1);
const result = await execute.call(
mockExecuteFunctions as IExecuteFunctions,
sheet as GoogleSheet,
sheetName,
);
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/v4/spreadsheets/spreadsheet123:batchUpdate',
{
requests: [
{
deleteSheet: {
sheetId: 'sheet456',
},
},
],
},
);
expect(apiRequest).toHaveBeenNthCalledWith(
2,
'POST',
'/v4/spreadsheets/spreadsheet123:batchUpdate',
{
requests: [
{
deleteSheet: {
sheetId: 'sheet456',
},
},
],
},
);
expect(result).toEqual([...constructedDataItem0, ...constructedDataItem1]);
});
});
@@ -0,0 +1,468 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/update.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
describe('Google Sheet - Update', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should update by row_number and not insert it as a new column', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(['row_number']); // columnsToMatchOn
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([
['id', 'name', 'text'],
['1', 'a', 'a'],
['2', 'x', 'x'],
['3', 'b', 'b'],
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdatingByRowNumber.mockReturnValueOnce({
updateData: [
{
range: 'Sheet1!B3',
values: [['NEW NAME']],
},
{
range: 'Sheet1!C3',
values: [['NEW TEXT']],
},
],
});
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
expect(mockGoogleSheet.getData).toHaveBeenCalledWith('Sheet1', 'FORMATTED_VALUE');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
range: 'Sheet1!A:Z',
keyIndex: -1,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
sheetData: [
['id', 'name', 'text'],
['1', 'a', 'a'],
['2', 'x', 'x'],
['3', 'b', 'b'],
],
});
expect(mockGoogleSheet.prepareDataForUpdatingByRowNumber).toHaveBeenCalledWith(
[
{
row_number: 3,
name: 'NEW NAME',
text: 'NEW TEXT',
},
],
'Sheet1!A:Z',
[['id', 'name', 'text']],
);
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
[
{
range: 'Sheet1!B3',
values: [['NEW NAME']],
},
{
range: 'Sheet1!C3',
values: [['NEW TEXT']],
},
],
'USER_ENTERED',
);
});
it('should update rows by column values with special character', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: 3,
name: '** δ$% " []',
text: 'δ$% " []',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: string | object } = {
options: {},
'options.cellFormat': 'USER_ENTERED',
'columns.matchingColumns': ['row_number'],
'columns.value': {
'ha []': 'kayyyy$',
macarena: 'baile',
'Real.1': 't&c',
'21 "': 'Σ',
},
dataMode: 'autoMapInputData',
};
return params[parameterName];
});
mockGoogleSheet.getData.mockResolvedValueOnce([
['Real.1', '21 "', 'dfd', 'ha []', 'macarena'],
['aye.2', '"book"', 'ee', 'dd', 'dance'],
['t&c', 'Σ', 'baz', 'kayyyy$', 'baile'],
['fudge.2', '9080', 'live', 'dog', 'brazil'],
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdatingByRowNumber.mockReturnValueOnce({
updateData: [
{
range: 'Sheet1!B3',
values: [['** δ$% " []']],
},
{
range: 'Sheet1!C3',
values: [['δ$% " []']],
},
],
});
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
expect(mockGoogleSheet.getData).toHaveBeenCalledWith('Sheet1', 'FORMATTED_VALUE');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
range: 'Sheet1!A:Z',
keyIndex: -1,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
sheetData: [
['Real.1', '21 "', 'dfd', 'ha []', 'macarena'],
['aye.2', '"book"', 'ee', 'dd', 'dance'],
['t&c', 'Σ', 'baz', 'kayyyy$', 'baile'],
['fudge.2', '9080', 'live', 'dog', 'brazil'],
],
});
expect(mockGoogleSheet.prepareDataForUpdatingByRowNumber).toHaveBeenCalledWith(
[
{
'21 "': 'Σ',
'Real.1': 't&c',
'ha []': 'kayyyy$',
macarena: 'baile',
},
],
'Sheet1!A:Z',
[['Real.1', '21 "', 'dfd', 'ha []', 'macarena']],
);
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
[
{
range: 'Sheet1!B3',
values: [['** δ$% " []']],
},
{
range: 'Sheet1!C3',
values: [['δ$% " []']],
},
],
'USER_ENTERED',
);
});
});
describe('Google Sheet - Update 4.6', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }));
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('row_number input error', () => {
it.each([{ rowNumber: undefined }])(
'displays a helpful error message when row_number is $rowNumber',
async ({ rowNumber }) => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: rowNumber,
name: 'name',
text: 'txt',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: string | object } = {
options: {},
'options.cellFormat': 'USER_ENTERED',
'columns.matchingColumns': ['row_number'],
'columns.value': {
row_number: rowNumber, // TODO: Test for undefined
},
dataMode: 'defineBelow',
};
return params[parameterName];
});
mockGoogleSheet.getData.mockResolvedValueOnce([['macarena'], ['boomboom']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1')).rejects.toEqual(
expect.objectContaining({
message: 'row_number is null or undefined',
description:
"Since it's being used to determine the row to update, it cannot be null or undefined",
}),
);
},
);
});
describe('non-row_number undefined', () => {
it.each([{ nonRowNumber: undefined }])(
'displays a helpful error message when row_number is $rowNumber',
async ({ nonRowNumber }) => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {
row_number: 2,
nonRowNumber: 'name',
text: 'txt',
},
pairedItem: {
item: 0,
input: undefined,
},
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: string | object } = {
options: {},
'options.cellFormat': 'USER_ENTERED',
'columns.matchingColumns': ['nonRowNumber'],
'columns.value': {
nonRowNumber,
},
dataMode: 'defineBelow',
};
return params[parameterName];
});
mockGoogleSheet.getData.mockResolvedValueOnce([['macarena'], ['boomboom']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [],
});
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
expect(mockExecuteFunctions.addExecutionHints).toHaveBeenCalledWith({
message: 'Warning: The value of column to match is null or undefined',
location: 'outputPane',
});
},
);
});
});
describe('Google Sheet - Update v4.6 vs v4.7 Behavior', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
afterEach(() => {
jest.resetAllMocks();
});
it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }));
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {},
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: string | object } = {
options: {},
'options.cellFormat': 'USER_ENTERED',
'columns.matchingColumns': ['id'],
'columns.mappingMode': 'defineBelow',
'columns.value': {
id: 1,
name: 'John',
// email field is NOT present here because user typed '' in UI
// and v4.6 frontend filtered it out (allowEmptyStrings: false)
},
};
return params[parameterName];
});
mockGoogleSheet.getData.mockResolvedValueOnce([
['id', 'name', 'email'],
['1', 'Old Name', 'old@email.com'],
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [
{
range: 'Sheet1!B2',
values: [['John']],
},
// No update for email column - it keeps its old value
],
appendData: [],
});
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
// v4.6: Only name field is updated, email is not included in the update
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({
inputData: [
{
id: 1,
name: 'John',
// email is NOT in the inputData, so cell keeps old value
},
],
indexKey: 'id',
range: 'Sheet1!A:Z',
keyRowIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
columnNamesList: [['id', 'name', 'email']],
columnValuesList: ['1'],
});
});
it('v4.7: empty string in UI is preserved and sent to backend to clear cell', async () => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.7 }));
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: {},
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: string | object } = {
options: {},
'options.cellFormat': 'USER_ENTERED',
'columns.matchingColumns': ['id'],
'columns.mappingMode': 'defineBelow',
'columns.value': {
id: 1,
name: 'John',
email: '', // Empty string is preserved in v4.7 (allowEmptyStrings: true)
},
};
return params[parameterName];
});
mockGoogleSheet.getData.mockResolvedValueOnce([
['id', 'name', 'email'],
['1', 'Old Name', 'old@email.com'],
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [
{
range: 'Sheet1!B2',
values: [['John']],
},
{
range: 'Sheet1!C2',
values: [['']],
},
],
appendData: [],
});
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
// v4.7: Both name and email fields are updated, email is cleared with empty string
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({
inputData: [
{
id: 1,
name: 'John',
email: '', // Empty string is preserved and will clear the cell
},
],
indexKey: 'id',
range: 'Sheet1!A:Z',
keyRowIndex: 0,
dataStartRowIndex: 1,
valueRenderMode: 'UNFORMATTED_VALUE',
columnNamesList: [['id', 'name', 'email']],
columnValuesList: ['1'],
});
});
});
@@ -0,0 +1,179 @@
import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow';
import { getGoogleAccessToken } from '../../../../GenericFunctions';
import { apiRequest, apiRequestAllItems } from '../../../v2/transport';
jest.mock('../../../../GenericFunctions', () => ({
getGoogleAccessToken: jest.fn(),
}));
describe('Google Sheets Transport', () => {
let mockExecuteFunction: IExecuteFunctions;
let mockLoadOptionsFunction: ILoadOptionsFunctions;
beforeEach(() => {
mockExecuteFunction = {
getNode: jest.fn(),
getNodeParameter: jest.fn(),
getCredentials: jest.fn(),
helpers: {
request: jest.fn(),
requestOAuth2: jest.fn(),
},
} as unknown as IExecuteFunctions;
mockLoadOptionsFunction = {
...mockExecuteFunction,
} as unknown as ILoadOptionsFunctions;
jest.clearAllMocks();
});
const mockAccessToken = 'mock-access-token';
describe('apiRequest', () => {
it('should make successful request with service account authentication', async () => {
const method = 'GET';
const resource = '/v4/spreadsheets';
const mockResponse = { data: 'test' };
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({
email: 'test@test.com',
privateKey: 'private-key',
});
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: mockAccessToken });
mockExecuteFunction.helpers.request = jest.fn().mockResolvedValue(mockResponse);
const result = await apiRequest.call(mockExecuteFunction, method, resource);
expect(result).toEqual(mockResponse);
expect(mockExecuteFunction.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Bearer ${mockAccessToken}`,
}),
method,
uri: `https://sheets.googleapis.com${resource}`,
}),
);
});
it('should make successful request with OAuth2 authentication', async () => {
const method = 'GET';
const resource = '/v4/spreadsheets';
const mockResponse = { data: 'test' };
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('oAuth2');
mockExecuteFunction.helpers.requestOAuth2 = jest.fn().mockResolvedValue(mockResponse);
const result = await apiRequest.call(mockExecuteFunction, method, resource);
expect(result).toEqual(mockResponse);
expect(mockExecuteFunction.helpers.requestOAuth2).toHaveBeenCalled();
});
it('should handle custom headers and query parameters', async () => {
const method = 'GET';
const resource = '/v4/spreadsheets';
const headers = { 'Custom-Header': 'value' };
const qs = { param: 'value' };
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({});
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: 'token' });
await apiRequest.call(mockExecuteFunction, method, resource, {}, qs, undefined, headers);
expect(mockExecuteFunction.helpers.request).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining(headers),
qs,
}),
);
});
it('should handle PERMISSION_DENIED error with custom description', async () => {
const method = 'GET';
const resource = '/v4/spreadsheets';
const error = new Error('PERMISSION_DENIED');
error.message = 'PERMISSION_DENIED Some error';
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({});
mockExecuteFunction.helpers.request = jest.fn().mockRejectedValue(error);
await expect(apiRequest.call(mockExecuteFunction, method, resource)).rejects.toThrow();
});
it('should handle SSL certificate errors', async () => {
const method = 'GET';
const resource = '/v4/spreadsheets';
const error = new Error('ERR_OSSL_PEM_NO_START_LINE');
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({});
mockExecuteFunction.helpers.request = jest.fn().mockRejectedValue(error);
await expect(apiRequest.call(mockExecuteFunction, method, resource)).rejects.toThrow();
});
});
describe('apiRequestAllItems', () => {
it('should fetch all pages of results', async () => {
const propertyName = 'items';
const method = 'GET';
const endpoint = '/v4/spreadsheets';
const firstPage = {
items: [{ id: 1 }, { id: 2 }],
nextPageToken: 'token1',
};
const secondPage = {
items: [{ id: 3 }, { id: 4 }],
nextPageToken: undefined,
};
mockLoadOptionsFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
mockLoadOptionsFunction.getCredentials = jest.fn().mockResolvedValue({});
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: mockAccessToken });
mockExecuteFunction.helpers.request = jest
.fn()
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce(secondPage);
const result = await apiRequestAllItems.call(
mockLoadOptionsFunction,
propertyName,
method,
endpoint,
);
expect(result).toHaveLength(4);
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]);
});
it('should handle empty response', async () => {
const propertyName = 'items';
const method = 'GET';
const endpoint = '/v4/spreadsheets';
const emptyResponse = {
items: [],
};
mockLoadOptionsFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
mockLoadOptionsFunction.getCredentials = jest.fn().mockResolvedValue({});
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: mockAccessToken });
mockExecuteFunction.helpers.request = jest.fn().mockResolvedValue(emptyResponse);
const result = await apiRequestAllItems.call(
mockLoadOptionsFunction,
propertyName,
method,
endpoint,
);
expect(result).toHaveLength(0);
});
});
});
@@ -0,0 +1,735 @@
import {
NodeOperationError,
type IExecuteFunctions,
type INode,
type ResourceMapperField,
} from 'n8n-workflow';
import { GOOGLE_SHEETS_SHEET_URL_REGEX } from '../../../../constants';
import { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
import {
addRowNumber,
autoMapInputData,
checkForSchemaChanges,
getColumnName,
getColumnNumber,
getExistingSheetNames,
getRangeString,
getSheetId,
getSpreadsheetId,
hexToRgb,
mapFields,
prepareSheetData,
removeEmptyColumns,
removeEmptyRows,
trimLeadingEmptyRows,
trimToFirstEmptyRow,
} from '../../../v2/helpers/GoogleSheets.utils';
describe('Test Google Sheets, addRowNumber', () => {
it('should add row nomber', () => {
const data = [
['id', 'col1', 'col2', 'col3'],
[0, 'A', 'B', 'C'],
[1, 'a', 'b', 'c'],
[2, 'd', 'e', 'f'],
[3, 'g', 'h', 'i'],
];
const result = addRowNumber(data, 0);
expect(result).toBeDefined();
expect(result).toEqual([
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 0, 'A', 'B', 'C'],
[3, 1, 'a', 'b', 'c'],
[4, 2, 'd', 'e', 'f'],
[5, 3, 'g', 'h', 'i'],
]);
});
});
describe('Test Google Sheets, trimToFirstEmptyRow', () => {
it('should trimToFirstEmptyRow without row numbers', () => {
const data = [
['id', 'col1', 'col2', 'col3'],
[0, 'A', 'B', 'C'],
['', '', '', ''],
[2, 'd', 'e', 'f'],
[3, 'g', 'h', 'i'],
];
const result = trimToFirstEmptyRow(data, false);
expect(result).toBeDefined();
expect(result).toEqual([
['id', 'col1', 'col2', 'col3'],
[0, 'A', 'B', 'C'],
]);
});
it('should trimToFirstEmptyRow with row numbers', () => {
const data = [
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 0, 'A', 'B', 'C'],
[3, 1, 'a', 'b', 'c'],
[4, '', '', '', ''],
[5, 3, 'g', 'h', 'i'],
];
const result = trimToFirstEmptyRow(data);
expect(result).toBeDefined();
expect(result).toEqual([
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 0, 'A', 'B', 'C'],
[3, 1, 'a', 'b', 'c'],
]);
});
});
describe('Test Google Sheets, removeEmptyRows', () => {
it('should removeEmptyRows without row numbers', () => {
const data = [
['id', 'col1', 'col2', 'col3'],
[0, 'A', 'B', 'C'],
['', '', '', ''],
[2, 'd', 'e', 'f'],
['', '', '', ''],
];
const result = removeEmptyRows(data, false);
expect(result).toBeDefined();
expect(result).toEqual([
['id', 'col1', 'col2', 'col3'],
[0, 'A', 'B', 'C'],
[2, 'd', 'e', 'f'],
]);
});
it('should removeEmptyRows with row numbers', () => {
const data = [
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 0, 'A', 'B', 'C'],
[3, 1, 'a', 'b', 'c'],
[4, '', '', '', ''],
[5, 3, 'g', 'h', 'i'],
];
const result = removeEmptyRows(data);
expect(result).toBeDefined();
expect(result).toEqual([
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 0, 'A', 'B', 'C'],
[3, 1, 'a', 'b', 'c'],
[5, 3, 'g', 'h', 'i'],
]);
});
});
describe('Test Google Sheets, trimLeadingEmptyRows', () => {
it('should trimLeadingEmptyRows without row numbers', () => {
const data = [
['', '', '', ''],
['', '', '', ''],
[2, 'd', 'e', 'f'],
['', '', '', ''],
];
const result = trimLeadingEmptyRows(data, false);
expect(result).toBeDefined();
expect(result).toEqual([
[2, 'd', 'e', 'f'],
['', '', '', ''],
]);
});
it('should trimLeadingEmptyRows with row numbers', () => {
const data = [
[1, '', '', '', ''],
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 0, 'A', 'B', 'C'],
[3, 1, 'a', 'b', 'c'],
[5, 3, 'g', 'h', 'i'],
];
const result = trimLeadingEmptyRows(data, true);
expect(result).toBeDefined();
expect(result).toEqual([
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 0, 'A', 'B', 'C'],
[3, 1, 'a', 'b', 'c'],
[5, 3, 'g', 'h', 'i'],
]);
});
});
describe('Test Google Sheets, removeEmptyColumns', () => {
it('should removeEmptyColumns without row numbers', () => {
const data = [
['id', 'col1', '', 'col3'],
[0, 'A', '', 'C'],
[1, 'a', '', 'c'],
[2, 'd', '', 'f'],
[3, 'g', '', 'i'],
];
const result = removeEmptyColumns(data);
expect(result).toBeDefined();
expect(result).toEqual([
['id', 'col1', 'col3'],
[0, 'A', 'C'],
[1, 'a', 'c'],
[2, 'd', 'f'],
[3, 'g', 'i'],
]);
});
});
describe('Test Google Sheets, prepareSheetData', () => {
it('should prepareSheetData without row numbers', () => {
const data = [
['id', 'col1', 'col2', 'col3'],
[1, 'A', 'B', 'C'],
['', '', '', ''],
[2, 'd', 'e', 'f'],
['', '', '', ''],
];
const result = prepareSheetData(data, { rangeDefinition: 'detectAutomatically' }, true);
expect(result).toBeDefined();
expect(result).toEqual({
data: [
['row_number', 'id', 'col1', 'col2', 'col3'],
[2, 1, 'A', 'B', 'C'],
[4, 2, 'd', 'e', 'f'],
],
firstDataRow: 1,
headerRow: 0,
});
});
});
describe('Test Google Sheets, autoMapInputData', () => {
it('should autoMapInputData', async () => {
const node: INode = {
id: '1',
name: 'Postgres node',
typeVersion: 2,
type: 'n8n-nodes-base.postgres',
position: [60, 760],
parameters: {
operation: 'executeQuery',
},
};
const items = [
{
json: {
id: 1,
name: 'Jon',
data: 'A',
},
},
{
json: {
id: 2,
name: 'Sam',
data: 'B',
},
},
{
json: {
id: 3,
name: 'Ron',
data: 'C',
info: 'some info',
},
},
];
const fakeExecuteFunction = {
getNode() {
return node;
},
} as unknown as IExecuteFunctions;
const getData = (GoogleSheet.prototype.getData = jest.fn().mockResolvedValue([[]]));
const updateRows = (GoogleSheet.prototype.updateRows = jest.fn().mockResolvedValue(true));
const googleSheet = new GoogleSheet('spreadsheetId', fakeExecuteFunction);
const result = await autoMapInputData.call(
fakeExecuteFunction,
'foo 1',
googleSheet,
items,
{},
);
expect(getData).toHaveBeenCalledTimes(1);
expect(getData).toHaveBeenCalledWith('foo 1!1:1', 'FORMATTED_VALUE');
expect(updateRows).toHaveBeenCalledTimes(2);
expect(updateRows).toHaveBeenCalledWith('foo 1', [['id', 'name', 'data']], 'RAW', 1);
expect(updateRows).toHaveBeenCalledWith('foo 1', [['id', 'name', 'data', 'info']], 'RAW', 1);
expect(result).toBeDefined();
expect(result).toEqual([
{
id: 1,
name: 'Jon',
data: 'A',
},
{
id: 2,
name: 'Sam',
data: 'B',
},
{
id: 3,
name: 'Ron',
data: 'C',
info: 'some info',
},
]);
});
});
describe('Test Google Sheets, lookupValues', () => {
const inputData = [
['row_number', 'id', 'num', 'text'],
[2, 1, '111', 'bar'],
[3, 3, 1, 'bar'],
[4, 4, 1, 'baz'],
[5, 5, 1, 'baz'],
[6, 6, 66, 'foo'],
[7, 7, 77, 'foo'],
] as string[][];
it('should return rows by combining filters by OR', async () => {
const fakeExecuteFunction = {
getNode() {
return {};
},
} as unknown as IExecuteFunctions;
const googleSheet = new GoogleSheet('spreadsheetId', fakeExecuteFunction);
const result = await googleSheet.lookupValues({
inputData,
keyRowIndex: 0,
dataStartRowIndex: 1,
lookupValues: [
{
lookupColumn: 'num',
lookupValue: '1',
},
{
lookupColumn: 'text',
lookupValue: 'foo',
},
],
returnAllMatches: true,
combineFilters: 'OR',
nodeVersion: 4.5,
});
expect(result).toBeDefined();
expect(result).toEqual([
{
row_number: 3,
id: 3,
num: 1,
text: 'bar',
},
{
row_number: 4,
id: 4,
num: 1,
text: 'baz',
},
{
row_number: 5,
id: 5,
num: 1,
text: 'baz',
},
{
row_number: 6,
id: 6,
num: 66,
text: 'foo',
},
{
row_number: 7,
id: 7,
num: 77,
text: 'foo',
},
]);
});
it('should return rows by combining filters by AND', async () => {
const fakeExecuteFunction = {
getNode() {
return {};
},
} as unknown as IExecuteFunctions;
const googleSheet = new GoogleSheet('spreadsheetId', fakeExecuteFunction);
const result = await googleSheet.lookupValues({
inputData,
keyRowIndex: 0,
dataStartRowIndex: 1,
lookupValues: [
{
lookupColumn: 'num',
lookupValue: '1',
},
{
lookupColumn: 'text',
lookupValue: 'baz',
},
],
returnAllMatches: true,
combineFilters: 'AND',
nodeVersion: 4.5,
});
expect(result).toBeDefined();
expect(result).toEqual([
{
row_number: 4,
id: 4,
num: 1,
text: 'baz',
},
{
row_number: 5,
id: 5,
num: 1,
text: 'baz',
},
]);
});
});
describe('Test Google Sheets, checkForSchemaChanges', () => {
it('should not to throw error', async () => {
const node: INode = {
id: '1',
name: 'Google Sheets',
typeVersion: 4.4,
type: 'n8n-nodes-base.googleSheets',
position: [60, 760],
parameters: {
operation: 'append',
},
};
expect(() =>
checkForSchemaChanges(node, ['id', 'name', 'data'], [
{ id: 'id' },
{ id: 'name' },
{ id: 'data' },
] as ResourceMapperField[]),
).not.toThrow();
});
it('should throw error when columns were renamed', async () => {
const node: INode = {
id: '1',
name: 'Google Sheets',
typeVersion: 4.4,
type: 'n8n-nodes-base.googleSheets',
position: [60, 760],
parameters: {
operation: 'append',
},
};
expect(() =>
checkForSchemaChanges(node, ['id', 'name', 'data'], [
{ id: 'id' },
{ id: 'name' },
{ id: 'text' },
] as ResourceMapperField[]),
).toThrow("Column names were updated after the node's setup");
});
it('should filter out empty columns without throwing an error', async () => {
const node: INode = {
id: '1',
name: 'Google Sheets',
typeVersion: 4.4,
type: 'n8n-nodes-base.googleSheets',
position: [60, 760],
parameters: {
operation: 'append',
},
};
expect(() =>
checkForSchemaChanges(node, ['', '', 'id', 'name', 'data'], [
{ id: 'id' },
{ id: 'name' },
{ id: 'data' },
] as ResourceMapperField[]),
).not.toThrow();
});
});
describe('Test Google Sheets, getSpreadsheetId', () => {
let mockNode: INode;
beforeEach(() => {
mockNode = { name: 'Google Sheets' } as INode;
jest.clearAllMocks();
});
it('should throw an error if value is empty', () => {
expect(() => getSpreadsheetId(mockNode, 'url', '')).toThrow(NodeOperationError);
});
it('should return the ID from a valid URL', () => {
const url =
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0';
const result = getSpreadsheetId(mockNode, 'url', url);
expect(result).toBe('1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms');
});
it('should return an empty string for an invalid URL', () => {
const url = 'https://docs.google.com/spreadsheets/d/';
const result = getSpreadsheetId(mockNode, 'url', url);
expect(result).toBe('');
});
it('should return the value for documentIdType byId or byList', () => {
const value = '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms';
expect(getSpreadsheetId(mockNode, 'id', value)).toBe(value);
expect(getSpreadsheetId(mockNode, 'list', value)).toBe(value);
});
});
describe('Test Google Sheets, Google Sheets Sheet URL Regex', () => {
const regex = new RegExp(GOOGLE_SHEETS_SHEET_URL_REGEX);
it('should match a valid Google Sheets URL', () => {
const urls = [
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0',
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=123456',
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit?gid=654321#gid=654321',
];
for (const url of urls) {
expect(regex.test(url)).toBe(true);
}
});
it('should not match an invalid Google Sheets URL', () => {
const url = 'https://docs.google.com/spreadsheets/d/';
expect(regex.test(url)).toBe(false);
});
it('should not match a URL that does not match the pattern', () => {
const url =
'https://example.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0';
expect(regex.test(url)).toBe(false);
});
it('should extract the gid from a valid Google Sheets URL', () => {
const urls = [
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=12345',
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit?gid=12345#gid=12345',
];
for (const url of urls) {
const match = url.match(regex);
expect(match).not.toBeNull();
expect(match?.[1]).toBe('12345');
}
});
});
describe('Test Google Sheets, getColumnNumber', () => {
it('should return the correct number for single-letter columns', () => {
expect(getColumnNumber('A')).toBe(1);
expect(getColumnNumber('Z')).toBe(26);
});
it('should return the correct number for multi-letter columns', () => {
expect(getColumnNumber('AA')).toBe(27);
expect(getColumnNumber('AZ')).toBe(52);
expect(getColumnNumber('BA')).toBe(53);
expect(getColumnNumber('ZZ')).toBe(702);
expect(getColumnNumber('AAA')).toBe(703);
});
});
describe('Test Google Sheets, hexToRgb', () => {
it('should correctly convert a full hex code to RGB', () => {
expect(hexToRgb('#0033FF')).toEqual({ red: 0, green: 51, blue: 255 });
expect(hexToRgb('#FF5733')).toEqual({ red: 255, green: 87, blue: 51 });
});
it('should correctly convert a shorthand hex code to RGB', () => {
expect(hexToRgb('#03F')).toEqual({ red: 0, green: 51, blue: 255 });
expect(hexToRgb('#F00')).toEqual({ red: 255, green: 0, blue: 0 });
});
it('should return null for invalid hex codes', () => {
expect(hexToRgb('#XYZ123')).toBeNull(); // Invalid characters
expect(hexToRgb('#12345')).toBeNull(); // Incorrect length
expect(hexToRgb('')).toBeNull(); // Empty input
expect(hexToRgb('#')).toBeNull(); // Just a hash
});
});
describe('Test Google Sheets, getRangeString', () => {
it('should return the range in A1 notation when "specifyRangeA1" is set', () => {
const result = getRangeString('Sheet1', { rangeDefinition: 'specifyRangeA1', range: 'A1:B2' });
expect(result).toBe('Sheet1!A1:B2');
});
it('should return only the sheet name if no range is specified', () => {
const result = getRangeString('Sheet1', { rangeDefinition: 'specifyRangeA1', range: '' });
expect(result).toBe('Sheet1');
});
it('should return only the sheet name if rangeDefinition is not "specifyRangeA1"', () => {
const result = getRangeString('Sheet1', { rangeDefinition: 'detectAutomatically' });
expect(result).toBe('Sheet1');
});
});
describe('Test Google Sheets, getExistingSheetNames', () => {
const mockGoogleSheetInstance: Partial<GoogleSheet> = {
spreadsheetGetSheets: jest.fn(),
};
it('should return an array of sheet names', async () => {
mockGoogleSheetInstance.spreadsheetGetSheets = jest.fn().mockResolvedValue({
sheets: [{ properties: { title: 'Sheet1' } }, { properties: { title: 'Sheet2' } }],
});
const result = await getExistingSheetNames(mockGoogleSheetInstance as GoogleSheet);
expect(result).toEqual(['Sheet1', 'Sheet2']);
});
it('should return an empty array if no sheets are present', async () => {
mockGoogleSheetInstance.spreadsheetGetSheets = jest.fn().mockResolvedValue({ sheets: [] });
const result = await getExistingSheetNames(mockGoogleSheetInstance as GoogleSheet);
expect(result).toEqual([]);
});
it('should handle a case where sheets are undefined', async () => {
mockGoogleSheetInstance.spreadsheetGetSheets = jest.fn().mockResolvedValue({});
const result = await getExistingSheetNames(mockGoogleSheetInstance as GoogleSheet);
expect(result).toEqual([]);
});
});
describe('Test Google Sheets, mapFields', () => {
const fakeExecuteFunction: Partial<IExecuteFunctions> = {};
beforeEach(() => {
fakeExecuteFunction.getNode = jest.fn();
fakeExecuteFunction.getNodeParameter = jest.fn();
});
it('should map fields for node version < 4', () => {
fakeExecuteFunction.getNode = jest.fn().mockReturnValue({ typeVersion: 3 });
fakeExecuteFunction.getNodeParameter = jest.fn().mockImplementation((_, i) => [
{ fieldId: 'field1', fieldValue: `value${i}` },
{ fieldId: 'field2', fieldValue: `value${i * 2}` },
]);
const result = mapFields.call(fakeExecuteFunction as IExecuteFunctions, 2);
expect(result).toEqual([
{ field1: 'value0', field2: 'value0' },
{ field1: 'value1', field2: 'value2' },
]);
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledTimes(2);
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledWith(
'fieldsUi.fieldValues',
0,
[],
);
});
it('should map columns for node version >= 4', () => {
fakeExecuteFunction.getNode = jest.fn().mockReturnValue({ typeVersion: 4 });
fakeExecuteFunction.getNodeParameter = jest.fn().mockImplementation((_, i) => ({
column1: `value${i}`,
column2: `value${i * 2}`,
}));
const result = mapFields.call(fakeExecuteFunction as IExecuteFunctions, 2);
expect(result).toEqual([
{ column1: 'value0', column2: 'value0' },
{ column1: 'value1', column2: 'value2' },
]);
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledTimes(2);
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledWith('columns.value', 0);
});
it('should throw an error if no values are added in version >= 4', () => {
fakeExecuteFunction.getNode = jest.fn().mockReturnValue({ typeVersion: 4 });
fakeExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({});
expect(() => mapFields.call(fakeExecuteFunction as IExecuteFunctions, 1)).toThrow(
"At least one value has to be added under 'Values to Send'",
);
});
it('should return an empty array when inputSize is 0', () => {
const result = mapFields.call(fakeExecuteFunction as IExecuteFunctions, 0);
expect(result).toEqual([]);
});
});
describe('Test Google Sheets, getSheetId', () => {
it('should return 0 when value is "gid=0"', () => {
expect(getSheetId('gid=0')).toBe(0);
});
it('should return a parsed integer when value is a numeric string', () => {
expect(getSheetId('123')).toBe(123);
expect(getSheetId('456')).toBe(456);
});
it('should return NaN for non-numeric strings', () => {
expect(getSheetId('abc')).toBeNaN();
expect(getSheetId('gid=abc')).toBeNaN();
});
});
describe('Test Google Sheets, getColumnName', () => {
it('should return "A" for column number 1', () => {
expect(getColumnName(1)).toBe('A');
});
it('should return "Z" for column number 26', () => {
expect(getColumnName(26)).toBe('Z');
});
it('should return "AA" for column number 27', () => {
expect(getColumnName(27)).toBe('AA');
});
it('should return "AZ" for column number 52', () => {
expect(getColumnName(52)).toBe('AZ');
});
it('should return "BA" for column number 53', () => {
expect(getColumnName(53)).toBe('BA');
});
it('should return "ZZ" for column number 702', () => {
expect(getColumnName(702)).toBe('ZZ');
});
it('should return "AAA" for column number 703', () => {
expect(getColumnName(703)).toBe('AAA');
});
});