first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+594
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+200
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+119
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user