import { mock, mockDeep } from 'jest-mock-extended'; import type { IExecuteFunctions, NodeParameterValueType } from 'n8n-workflow'; import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet'; import { EvaluationTrigger } from '../EvaluationTrigger/EvaluationTrigger.node.ee'; import * as utils from '../utils/evaluationTriggerUtils'; describe('Evaluation Trigger Node', () => { const sheetName = 'Sheet5'; const spreadsheetId = '1oqFpPgEPTGDw7BPkp1SfPXq3Cb3Hyr1SROtf-Ec4zvA'; let mockExecuteFunctions = mock({ getInputData: jest.fn().mockReturnValue([{ json: {} }]), getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }), }); let mockDataTable: { getManyRowsAndCount: jest.Mock; getColumns: jest.Mock }; describe('execute', () => { describe('without filters', () => { beforeEach(() => { jest.resetAllMocks(); mockExecuteFunctions = mock({ getInputData: jest.fn().mockReturnValue([{ json: {} }]), getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }), }); jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => { return { sheetId: 1, title: sheetName }; }); // Mocks getResults() and getRowsLeft() jest.spyOn(GoogleSheet.prototype, 'getData').mockImplementation(async (range: string) => { if (range === `${sheetName}!1:1`) { return [['Header1', 'Header2']]; } else if (range === `${sheetName}!2:1000`) { return [ ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ]; } else if (range === `${sheetName}!2:2`) { // getRowsLeft with limit return []; } else if (range === sheetName) { return [ ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ]; } else { return []; } }); }); test('credential test for googleApi should be in methods', async () => { const evaluationTrigger = new EvaluationTrigger(); expect(evaluationTrigger.methods.credentialTest.googleApiCredentialTest).toBeDefined(); }); test('should return a single row from google sheet', async () => { mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'googleSheets', options: {}, 'filtersUI.values': [], combineFilters: 'AND', documentId: { mode: 'id', value: spreadsheetId, }, sheetName, sheetMode: 'id', }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions); expect(result).toEqual([ [ { json: { row_number: 2, Header1: 'Value1', Header2: 'Value2', _rowsLeft: 2, }, pairedItem: { item: 0, }, }, ], ]); }); test('should return the next row from google sheet', async () => { mockExecuteFunctions.getInputData.mockReturnValue([ { json: { row_number: 2, Header1: 'Value1', Header2: 'Value2', _rowsLeft: 1, }, pairedItem: { item: 0, input: undefined, }, }, ]); mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'googleSheets', options: {}, 'filtersUI.values': [], combineFilters: 'AND', documentId: { mode: 'id', value: spreadsheetId, }, sheetName, sheetMode: 'id', }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions); expect(result).toEqual([ [ { json: { row_number: 3, Header1: 'Value3', Header2: 'Value4', _rowsLeft: 0, }, pairedItem: { item: 0, }, }, ], ]); }); test('should return the first row from google sheet if no rows left', async () => { mockExecuteFunctions.getInputData.mockReturnValue([ { json: { row_number: 3, Header1: 'Value3', Header2: 'Value4', _rowsLeft: 0, }, pairedItem: { item: 0, input: undefined, }, }, ]); mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'googleSheets', options: {}, 'filtersUI.values': [], combineFilters: 'AND', documentId: { mode: 'id', value: spreadsheetId, }, sheetName, sheetMode: 'id', }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions); expect(result).toEqual([ [ { json: { row_number: 2, Header1: 'Value1', Header2: 'Value2', _rowsLeft: 2, }, pairedItem: { item: 0, }, }, ], ]); }); test('should return a single row from google sheet with limit', async () => { mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'googleSheets', options: {}, 'filtersUI.values': [], combineFilters: 'AND', documentId: { mode: 'id', value: spreadsheetId, }, sheetName, sheetMode: 'id', limitRows: true, maxRows: 1, }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions); expect(result).toEqual([ [ { json: { row_number: 2, Header1: 'Value1', Header2: 'Value2', _rowsLeft: 0, }, pairedItem: { item: 0, }, }, ], ]); }); }); describe('with filters', () => { beforeEach(() => { jest.resetAllMocks(); mockExecuteFunctions = mock({ getInputData: jest.fn().mockReturnValue([{ json: {} }]), getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }), }); jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => { return { sheetId: 1, title: sheetName }; }); }); test('should return a single row from google sheet using filter', async () => { jest .spyOn(GoogleSheet.prototype, 'getData') .mockResolvedValueOnce([ // operationResult ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ]) .mockResolvedValueOnce([ // rowsLeft ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ]); mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'googleSheets', limitRows: true, maxRows: 2, 'filtersUI.values': [{ lookupColumn: 'Header1', lookupValue: 'Value1' }], options: {}, combineFilters: 'AND', documentId: { mode: 'id', value: spreadsheetId, }, sheetName, sheetMode: 'id', }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); jest.spyOn(utils, 'getRowsLeft').mockResolvedValue(0); const evaluationTrigger = new EvaluationTrigger(); const result = await evaluationTrigger.execute.call(mockExecuteFunctions); expect(result).toEqual([ [ { json: { row_number: 2, Header1: 'Value1', Header2: 'Value2', _rowsLeft: 0, }, pairedItem: { item: 0, }, }, ], ]); }); }); describe('Data tables with filters', () => { beforeEach(() => { jest.resetAllMocks(); mockDataTable = { getManyRowsAndCount: jest.fn(), getColumns: jest.fn().mockResolvedValue([{ name: 'processed', type: 'number' }]), }; mockExecuteFunctions = mockDeep({ getNode: jest.fn().mockReturnValue({ typeVersion: 4.7 }), helpers: { getDataTableProxy: jest.fn().mockResolvedValue(mockDataTable), }, }); }); test('should process rows sequentially with filters when dataset changes', async () => { // Simulate the user's scenario: 5 rows with processed=1, updating to processed=2 after each execution // With each execution, one row is processed and thus no longer matches the filter mockDataTable.getManyRowsAndCount .mockResolvedValueOnce({ data: [{ id: 1, processed: 1 }], count: 5, }) .mockResolvedValueOnce({ data: [{ id: 2, processed: 1 }], count: 4, }) .mockResolvedValueOnce({ data: [{ id: 3, processed: 1 }], count: 3, }); mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'dataTable', limitRows: false, dataTableId: 'mockDataTableId', 'filters.conditions': [ { keyName: 'processed', condition: 'eq', keyValue: '1', }, ], matchType: 'anyCondition', }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); const evaluationTrigger = new EvaluationTrigger(); // First execution - no previous data mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); const result1 = await evaluationTrigger.execute.call(mockExecuteFunctions); expect(result1[0][0].json.row_id).toBe(1); expect(result1[0][0].json.row_number).toBe(0); expect(result1[0][0].json._rowsLeft).toBe(4); // Verify first call used user filter only (no id filter yet) expect(mockDataTable.getManyRowsAndCount).toHaveBeenNthCalledWith(1, { skip: 0, take: 1, filter: { type: 'or', filters: [ { columnName: 'processed', condition: 'eq', value: '1', }, ], }, }); // Second execution - previous row was id=1 mockExecuteFunctions.getInputData.mockReturnValue(result1[0]); const result2 = await evaluationTrigger.execute.call(mockExecuteFunctions); expect(result2[0][0].json.row_id).toBe(2); expect(result2[0][0].json.row_number).toBe(1); // Verify second call includes id > 1 filter expect(mockDataTable.getManyRowsAndCount).toHaveBeenNthCalledWith(2, { skip: 0, take: 1, filter: { type: 'and', filters: [ { columnName: 'processed', condition: 'eq', value: '1', }, { columnName: 'id', condition: 'gt', value: 1, }, ], }, }); // Third execution - previous row was id=2 mockExecuteFunctions.getInputData.mockReturnValue(result2[0]); const result3 = await evaluationTrigger.execute.call(mockExecuteFunctions); expect(result3[0][0].json.row_id).toBe(3); expect(result3[0][0].json.row_number).toBe(2); // Verify third call includes id > 2 filter expect(mockDataTable.getManyRowsAndCount).toHaveBeenNthCalledWith(3, { skip: 0, take: 1, filter: { type: 'and', filters: [ { columnName: 'processed', condition: 'eq', value: '1', }, { columnName: 'id', condition: 'gt', value: 2, }, ], }, }); }); }); }); describe('customOperations.dataset.getRows', () => { describe('Data tables', () => { beforeEach(() => { jest.resetAllMocks(); mockDataTable = { getManyRowsAndCount: jest.fn().mockResolvedValue({ data: [ { id: 1, field1: 'value1', field2: 'value2' }, { id: 2, field1: 'value3', field2: 'value4' }, ], }), getColumns: jest.fn().mockResolvedValue([ { name: 'field1', type: 'string' }, { name: 'field2', type: 'string' }, ]), }; mockExecuteFunctions = mockDeep({ getNode: jest.fn().mockReturnValue({ typeVersion: 4.7 }), helpers: { getDataTableProxy: jest.fn().mockResolvedValue(mockDataTable), }, }); }); test('should return the rows with limits applied, without filters', async () => { mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'dataTable', limitRows: true, maxRows: 2, dataTableId: 'mockDataTableId', }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); const result = await new EvaluationTrigger().customOperations.dataset.getRows.call( mockExecuteFunctions, ); expect(mockDataTable.getManyRowsAndCount).toHaveBeenCalledWith({ skip: 0, take: 2, filter: { filters: [], type: 'or' }, }); expect(result).toEqual([ [ { json: { id: 1, row_id: 1, row_number: 0, field1: 'value1', field2: 'value2', }, pairedItem: { item: 0, }, }, { json: { id: 2, row_id: 2, row_number: 1, field1: 'value3', field2: 'value4', }, pairedItem: { item: 0, }, }, ], ]); }); test('should return the rows with limits applied, with filters', async () => { mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'dataTable', limitRows: true, maxRows: 2, dataTableId: 'mockDataTableId', 'filters.conditions': [ { keyName: 'field1', condition: 'like', keyValue: '1', }, { keyName: 'field2', condition: 'eq', keyValue: 'value4', }, ], }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); await new EvaluationTrigger().customOperations.dataset.getRows.call(mockExecuteFunctions); expect(mockDataTable.getManyRowsAndCount).toHaveBeenCalledWith({ skip: 0, take: 2, filter: { filters: [ { columnName: 'field1', condition: 'like', value: '1', }, { columnName: 'field2', condition: 'eq', value: 'value4', }, ], type: 'or', }, }); }); }); describe('Google Sheets', () => { beforeEach(() => { jest.resetAllMocks(); mockExecuteFunctions = mock({ getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }), }); jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => { return { sheetId: 1, title: sheetName }; }); // Mocks getResults() and getRowsLeft() jest.spyOn(GoogleSheet.prototype, 'getData').mockImplementation(async (range: string) => { if (range === `${sheetName}!1:1`) { return [['Header1', 'Header2']]; } else if (range === `${sheetName}!2:1000`) { return [ ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ]; } else if (range === `${sheetName}!2:2`) { // getRowsLeft with limit return []; } else if (range === sheetName) { return [ ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ]; } else { return []; } }); }); test('should return the sheet with limits applied, without filters', async () => { mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'googleSheets', options: {}, 'filtersUI.values': [], combineFilters: 'AND', documentId: { mode: 'id', value: spreadsheetId, }, sheetName, sheetMode: 'id', limitRows: true, maxRows: 2, }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); const result = await new EvaluationTrigger().customOperations.dataset.getRows.call( mockExecuteFunctions, ); expect(result).toEqual([ [ { json: { row_number: 2, Header1: 'Value1', Header2: 'Value2', }, pairedItem: { item: 0, }, }, { json: { row_number: 3, Header1: 'Value3', Header2: 'Value4', }, pairedItem: { item: 0, }, }, ], ]); }); test('should return all relevant rows from google sheet using filters', async () => { mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); jest .spyOn(GoogleSheet.prototype, 'getData') .mockResolvedValueOnce([ // operationResult ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ['Value1', 'Value4'], ]) .mockResolvedValueOnce([ // rowsLeft ['Header1', 'Header2'], ['Value1', 'Value2'], ['Value3', 'Value4'], ['Value1', 'Value4'], ]); mockExecuteFunctions.getNodeParameter.mockImplementation( (key: string, _: number, fallbackValue?: string | number | boolean | object) => { const mockParams: { [key: string]: unknown } = { source: 'googleSheets', 'filtersUI.values': [{ lookupColumn: 'Header1', lookupValue: 'Value1' }], options: {}, combineFilters: 'AND', documentId: { mode: 'id', value: spreadsheetId, }, sheetName, sheetMode: 'id', }; return (mockParams[key] ?? fallbackValue) as NodeParameterValueType; }, ); jest.spyOn(utils, 'getRowsLeft').mockResolvedValue(0); const evaluationTrigger = new EvaluationTrigger(); const result = await evaluationTrigger.customOperations.dataset.getRows.call(mockExecuteFunctions); expect(result).toEqual([ [ { json: { row_number: 2, Header1: 'Value1', Header2: 'Value2' }, pairedItem: { item: 0, }, }, { json: { row_number: 4, Header1: 'Value1', Header2: 'Value4' }, pairedItem: { item: 0, }, }, ], ]); }); }); }); });