diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/spreadsheet/create.operation.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/spreadsheet/create.operation.test.ts new file mode 100644 index 0000000000..2895e688f2 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/spreadsheet/create.operation.test.ts @@ -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: [], + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/spreadsheet/delete.operation.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/spreadsheet/delete.operation.test.ts new file mode 100644 index 0000000000..fef69e9666 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/spreadsheet/delete.operation.test.ts @@ -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 } }]]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/helpers/GoogleSheet.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/helpers/GoogleSheet.test.ts new file mode 100644 index 0000000000..94934dccc5 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/helpers/GoogleSheet.test.ts @@ -0,0 +1,236 @@ +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 = { + 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' }, + ]); + }); + }); + + describe('lookupValues', () => { + it('should find matching rows with OR combination', async () => { + const inputData = [ + ['name', 'age', 'city'], + ['John', '30', 'NY'], + ['Jane', '25', 'LA'], + ['Bob', '30', 'SF'], + ]; + + const lookupValues = [{ lookupColumn: 'age', lookupValue: '30' }]; + + const result = await googleSheet.lookupValues({ + inputData, + keyRowIndex: 0, + dataStartRowIndex: 1, + lookupValues, + returnAllMatches: true, + combineFilters: 'OR', + }); + + expect(result).toEqual([ + { name: 'John', age: '30', city: 'NY' }, + { name: 'Bob', age: '30', city: 'SF' }, + ]); + }); + + it('should find matching rows with AND combination', async () => { + const inputData = [ + ['name', 'age', 'city'], + ['John', '30', 'NY'], + ['Jane', '25', 'LA'], + ['Bob', '30', 'SF'], + ]; + + 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', + }); + + expect(result).toEqual([{ name: 'John', age: '30', city: 'NY' }]); + }); + + it('should throw error for invalid key row', async () => { + const inputData = [['name', 'age']]; + const lookupValues = [{ lookupColumn: 'age', lookupValue: '30' }]; + + await expect( + googleSheet.lookupValues({ + inputData, + keyRowIndex: -1, + dataStartRowIndex: 1, + lookupValues, + }), + ).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, + }, + }, + ], + }, + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/methods/listSearch.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/methods/listSearch.test.ts new file mode 100644 index 0000000000..61f2286b6c --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/methods/listSearch.test.ts @@ -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; + + 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'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/clear.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/clear.test.ts new file mode 100644 index 0000000000..dc825b09f7 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/clear.test.ts @@ -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; + let mockSheet: Partial; + + beforeEach(() => { + mockExecuteFunctions = { + getInputData: jest.fn().mockReturnValue([{ json: {} }]), + getNodeParameter: jest.fn(), + } as Partial; + + mockSheet = { + clearData: jest.fn(), + getData: jest.fn().mockResolvedValue([['Header1', 'Header2']]), // Mock first-row data + updateRows: jest.fn(), + } as Partial; + }); + + 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); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/create.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/create.test.ts new file mode 100644 index 0000000000..54abddf7a9 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/create.test.ts @@ -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; + + const sheet = {} as Partial; + 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(); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/delete.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/delete.test.ts new file mode 100644 index 0000000000..988065baa9 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/delete.test.ts @@ -0,0 +1,148 @@ +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; + let mockSheet: Partial; + + beforeEach(() => { + mockExecuteFunctions = { + getInputData: jest.fn().mockReturnValue([{ json: {} }]), + getNodeParameter: jest.fn(), + } as Partial; + + mockSheet = { + spreadsheetBatchUpdate: jest.fn(), + } as Partial; + }); + + 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']; + + const result = await execute.call( + mockExecuteFunctions as IExecuteFunctions, + mockSheet as GoogleSheet, + 'Sheet1', + ); + expect(result).toEqual([{ json: { success: true } }]); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/read.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/read.test.ts new file mode 100644 index 0000000000..0f4f5c4be1 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/read.test.ts @@ -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; + let mockSheet: Partial; + + 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; + + 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([]); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/remove.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/remove.test.ts new file mode 100644 index 0000000000..828b795bb3 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/remove.test.ts @@ -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; + + const sheet = {} as Partial; + 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]); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/transport/index.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/transport/index.test.ts new file mode 100644 index 0000000000..b1e38cb2ae --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/transport/index.test.ts @@ -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); + }); + }); +});