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
526 lines
12 KiB
TypeScript
526 lines
12 KiB
TypeScript
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',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
});
|