diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/methods/loadOptions.test.ts b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/methods/loadOptions.test.ts index 101dedd84d7..d99f90832b4 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/methods/loadOptions.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/methods/loadOptions.test.ts @@ -48,6 +48,9 @@ describe('Microsoft Excel V2 - loadOptions', () => { `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`, undefined, { select: 'values' }, + undefined, + undefined, + 0, ); expect(result).toEqual([ @@ -79,6 +82,10 @@ describe('Microsoft Excel V2 - loadOptions', () => { 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, { select: 'values' }, + undefined, + undefined, + undefined, + 0, ); expect(result).toEqual([ diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/table/append.sp.batch.test.ts b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/table/append.sp.batch.test.ts new file mode 100644 index 00000000000..f740f91fdb3 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/table/append.sp.batch.test.ts @@ -0,0 +1,52 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { credentials } from '../../../credentials'; + +// Pins the whole-batch rule: table:append sends ONE request-set for all input items and +// reads every param at item 0 — so even with per-item target expressions that differ, +// all four session requests go to item 0's (alice's) root. Bob's root is never mocked, +// so any request there fails (disableNetConnect); empty pendingMocks pins the exact set. +describe('Test MicrosoftExcelV2, Service Principal table => append uses item 0 target for the whole batch', () => { + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/tables/%7B317CA469-7D1C-4A5D-9B0B-424444BF0336%7D/columns', + ) + .reply(200, { value: [{ name: 'row' }] }); + + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .post( + '/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/createSession', + { + persistChanges: true, + }, + ) + .reply(200, { id: 'session-1' }); + + // Both items' rows land in ONE add call under alice's root. + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .matchHeader('workbook-session-id', 'session-1') + .post( + '/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/tables/%7B317CA469-7D1C-4A5D-9B0B-424444BF0336%7D/rows/add', + { values: [['r1'], ['r2']] }, + ) + .reply(200, { index: 0, values: [['r1'], ['r2']] }); + + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .matchHeader('workbook-session-id', 'session-1') + .post( + '/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/closeSession', + {}, + ) + .reply(200); + + new NodeTestHarness().setupTests({ + credentials, + workflowFiles: ['append.sp.batch.workflow.json'], + customAssertions: () => expect(nock.pendingMocks()).toEqual([]), + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/table/append.sp.batch.workflow.json b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/table/append.sp.batch.workflow.json new file mode 100644 index 00000000000..dbfd68da79c --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/table/append.sp.batch.workflow.json @@ -0,0 +1,91 @@ +{ + "name": "microsoft excel 365 - table append (service principal, whole-batch item-0 target)", + "nodes": [ + { + "parameters": {}, + "id": "9a000000-0000-4000-8000-000000000001", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "data": [ + { "user": "alice@contoso.com", "row": "r1" }, + { "user": "bob@contoso.com", "row": "r2" } + ] + }, + "id": "9a000000-0000-4000-8000-000000000002", + "name": "Test Data", + "type": "n8n-nodes-testing.testData", + "typeVersion": 1, + "position": [200, 0] + }, + { + "parameters": { + "authentication": "microsoftEntraServicePrincipalApi", + "userTarget": { + "__rl": true, + "value": "={{ $json.user }}", + "mode": "id" + }, + "resource": "table", + "operation": "append", + "workbook": { + "__rl": true, + "value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I", + "mode": "id" + }, + "worksheet": { + "__rl": true, + "value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}", + "mode": "id" + }, + "table": { + "__rl": true, + "value": "{317CA469-7D1C-4A5D-9B0B-424444BF0336}", + "mode": "id" + }, + "dataMode": "autoMap", + "options": {} + }, + "id": "9a000000-0000-4000-8000-000000000003", + "name": "Microsoft Excel 365", + "type": "n8n-nodes-base.microsoftExcel", + "typeVersion": 2, + "position": [400, 0], + "credentials": { + "microsoftEntraServicePrincipalApi": { + "id": "1", + "name": "Microsoft Entra Service Principal account" + } + } + } + ], + "pinData": { + "Microsoft Excel 365": [ + { + "json": { + "row": "r1" + } + }, + { + "json": { + "row": "r2" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [[{ "node": "Test Data", "type": "main", "index": 0 }]] + }, + "Test Data": { + "main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": {}, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.drive.perItem.test.ts b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.drive.perItem.test.ts new file mode 100644 index 00000000000..be69091e3ba --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.drive.perItem.test.ts @@ -0,0 +1,30 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { credentials } from '../../../credentials'; + +// Drive-mode per-item resolution: with `Access As: Drive` the target id IS the drive, +// addressed as `/drives/{id}` (no `/drive` suffix). Two items with distinct drive-id +// expressions must each read from their own drive root. Consume-once + empty +// pendingMocks pin the exact request set. +describe('Test MicrosoftExcelV2, Service Principal worksheet => readRows per-item drive target', () => { + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/drives/b!driveA/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange', + ) + .reply(200, { values: [['row'], ['drive-a-row']] }); + + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/drives/b!driveB/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange', + ) + .reply(200, { values: [['row'], ['drive-b-row']] }); + + new NodeTestHarness().setupTests({ + credentials, + workflowFiles: ['readRows.sp.drive.perItem.workflow.json'], + customAssertions: () => expect(nock.pendingMocks()).toEqual([]), + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.drive.perItem.workflow.json b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.drive.perItem.workflow.json new file mode 100644 index 00000000000..fd8f4fecf5c --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.drive.perItem.workflow.json @@ -0,0 +1,83 @@ +{ + "name": "microsoft excel 365 - read rows (service principal, per-item drive target)", + "nodes": [ + { + "parameters": {}, + "id": "8a000000-0000-4000-8000-000000000001", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "data": [{ "drive": "b!driveA" }, { "drive": "b!driveB" }] + }, + "id": "8a000000-0000-4000-8000-000000000002", + "name": "Test Data", + "type": "n8n-nodes-testing.testData", + "typeVersion": 1, + "position": [200, 0] + }, + { + "parameters": { + "authentication": "microsoftEntraServicePrincipalApi", + "resourceTarget": "drive", + "driveTarget": { + "__rl": true, + "value": "={{ $json.drive }}", + "mode": "id" + }, + "resource": "worksheet", + "operation": "readRows", + "workbook": { + "__rl": true, + "value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I", + "mode": "id" + }, + "worksheet": { + "__rl": true, + "value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}", + "mode": "id" + }, + "options": {} + }, + "id": "8a000000-0000-4000-8000-000000000003", + "name": "Microsoft Excel 365", + "type": "n8n-nodes-base.microsoftExcel", + "typeVersion": 2, + "position": [400, 0], + "credentials": { + "microsoftEntraServicePrincipalApi": { + "id": "1", + "name": "Microsoft Entra Service Principal account" + } + } + } + ], + "pinData": { + "Microsoft Excel 365": [ + { + "json": { + "row": "drive-a-row" + } + }, + { + "json": { + "row": "drive-b-row" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [[{ "node": "Test Data", "type": "main", "index": 0 }]] + }, + "Test Data": { + "main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": {}, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItem.test.ts b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItem.test.ts new file mode 100644 index 00000000000..a91d4651734 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItem.test.ts @@ -0,0 +1,30 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { credentials } from '../../../credentials'; + +// The target RLC accepts expressions, so it must be resolved per input item: the Test +// Data node fans out to two users and each item's read must be rebased onto that item's +// `/users/{id}/drive` root (encoding pin preserved). Consume-once interceptors + empty +// pendingMocks pin the exact request set in both directions. +describe('Test MicrosoftExcelV2, Service Principal worksheet => readRows per-item target', () => { + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/users/jane%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange', + ) + .reply(200, { values: [['row'], ['jane-row']] }); + + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/users/john%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange', + ) + .reply(200, { values: [['row'], ['john-row']] }); + + new NodeTestHarness().setupTests({ + credentials, + workflowFiles: ['readRows.sp.perItem.workflow.json'], + customAssertions: () => expect(nock.pendingMocks()).toEqual([]), + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItem.workflow.json b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItem.workflow.json new file mode 100644 index 00000000000..774eccbfc19 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItem.workflow.json @@ -0,0 +1,82 @@ +{ + "name": "microsoft excel 365 - read rows (service principal, per-item target)", + "nodes": [ + { + "parameters": {}, + "id": "6a000000-0000-4000-8000-000000000001", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "data": [{ "user": "jane@contoso.com" }, { "user": "john@contoso.com" }] + }, + "id": "6a000000-0000-4000-8000-000000000002", + "name": "Test Data", + "type": "n8n-nodes-testing.testData", + "typeVersion": 1, + "position": [200, 0] + }, + { + "parameters": { + "authentication": "microsoftEntraServicePrincipalApi", + "userTarget": { + "__rl": true, + "value": "={{ $json.user }}", + "mode": "id" + }, + "resource": "worksheet", + "operation": "readRows", + "workbook": { + "__rl": true, + "value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I", + "mode": "id" + }, + "worksheet": { + "__rl": true, + "value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}", + "mode": "id" + }, + "options": {} + }, + "id": "6a000000-0000-4000-8000-000000000003", + "name": "Microsoft Excel 365", + "type": "n8n-nodes-base.microsoftExcel", + "typeVersion": 2, + "position": [400, 0], + "credentials": { + "microsoftEntraServicePrincipalApi": { + "id": "1", + "name": "Microsoft Entra Service Principal account" + } + } + } + ], + "pinData": { + "Microsoft Excel 365": [ + { + "json": { + "row": "jane-row" + } + }, + { + "json": { + "row": "john-row" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [[{ "node": "Test Data", "type": "main", "index": 0 }]] + }, + "Test Data": { + "main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": {}, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItemContinueOnFail.test.ts b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItemContinueOnFail.test.ts new file mode 100644 index 00000000000..413346315ca --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItemContinueOnFail.test.ts @@ -0,0 +1,23 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { credentials } from '../../../credentials'; + +// Per-item error isolation under continueOnFail: item 0's bogus target ("not a user" +// fails the shape validators) must produce an error item for item 0 only, while item 1 +// still reads from its own user's drive. Only jane is mocked; consume-once + empty +// pendingMocks pin the exact request set. +describe('Test MicrosoftExcelV2, Service Principal worksheet => readRows fails only the bad item under continueOnFail', () => { + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/users/jane%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange', + ) + .reply(200, { values: [['row'], ['jane-row']] }); + + new NodeTestHarness().setupTests({ + credentials, + workflowFiles: ['readRows.sp.perItemContinueOnFail.workflow.json'], + customAssertions: () => expect(nock.pendingMocks()).toEqual([]), + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItemContinueOnFail.workflow.json b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItemContinueOnFail.workflow.json new file mode 100644 index 00000000000..9e5976117eb --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/readRows.sp.perItemContinueOnFail.workflow.json @@ -0,0 +1,83 @@ +{ + "name": "microsoft excel 365 - read rows (service principal, per-item target, continueOnFail)", + "nodes": [ + { + "parameters": {}, + "id": "7a000000-0000-4000-8000-000000000001", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "data": [{ "user": "not a user" }, { "user": "jane@contoso.com" }] + }, + "id": "7a000000-0000-4000-8000-000000000002", + "name": "Test Data", + "type": "n8n-nodes-testing.testData", + "typeVersion": 1, + "position": [200, 0] + }, + { + "parameters": { + "authentication": "microsoftEntraServicePrincipalApi", + "userTarget": { + "__rl": true, + "value": "={{ $json.user }}", + "mode": "id" + }, + "resource": "worksheet", + "operation": "readRows", + "workbook": { + "__rl": true, + "value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I", + "mode": "id" + }, + "worksheet": { + "__rl": true, + "value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}", + "mode": "id" + }, + "options": {} + }, + "id": "7a000000-0000-4000-8000-000000000003", + "name": "Microsoft Excel 365", + "type": "n8n-nodes-base.microsoftExcel", + "typeVersion": 2, + "position": [400, 0], + "onError": "continueRegularOutput", + "credentials": { + "microsoftEntraServicePrincipalApi": { + "id": "1", + "name": "Microsoft Entra Service Principal account" + } + } + } + ], + "pinData": { + "Microsoft Excel 365": [ + { + "json": { + "error": "The target ID is not valid" + } + }, + { + "json": { + "row": "jane-row" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [[{ "node": "Test Data", "type": "main", "index": 0 }]] + }, + "Test Data": { + "main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": {}, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/upsert.sp.batch.test.ts b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/upsert.sp.batch.test.ts new file mode 100644 index 00000000000..a39ce12fb75 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/upsert.sp.batch.test.ts @@ -0,0 +1,63 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { credentials } from '../../../credentials'; + +// Pins the whole-batch rule for worksheet:upsert: one request set for all input items and +// every request-level parameter (including the SP target) is read at item 0 — so even with +// per-item target expressions that differ, all three requests (usedRange read, append-range +// probe, final write) go to item 0's (alice's) root. Bob's root is never mocked, so any +// request there fails (disableNetConnect); empty pendingMocks pins the exact set. +describe('Test MicrosoftExcelV2, Service Principal worksheet => upsert uses item 0 target for the whole batch', () => { + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange', + ) + .reply(200, { + address: 'Sheet1!A1:B3', + values: [ + ['id', 'data'], + ['1', 'old1'], + ['2', 'old2'], + ], + }); + + // Item 1 appends, so the node re-reads the used range (address only) — still under alice. + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .get( + '/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/usedRange', + ) + .query({ select: 'address' }) + .reply(200, { address: 'Sheet1!A1:B3' }); + + // Both items' rows (item 0's update + item 1's append) land in ONE write under alice's root. + nock('https://graph.microsoft.com/v1.0') + .matchHeader('Authorization', 'Bearer test-access-token') + .patch( + "/users/alice%40contoso.com/drive/items/01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I/workbook/worksheets/%7BA0883CFE-D27E-4ECC-B94B-981830AAD55B%7D/range(address='A1:B4')", + { + values: [ + ['id', 'data'], + ['1', 'new1'], + ['2', 'old2'], + ['3', 'new3'], + ], + }, + ) + .reply(200, { + values: [ + ['id', 'data'], + ['1', 'new1'], + ['2', 'old2'], + ['3', 'new3'], + ], + }); + + new NodeTestHarness().setupTests({ + credentials, + workflowFiles: ['upsert.sp.batch.workflow.json'], + customAssertions: () => expect(nock.pendingMocks()).toEqual([]), + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/upsert.sp.batch.workflow.json b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/upsert.sp.batch.workflow.json new file mode 100644 index 00000000000..ab654b41f1f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Excel/test/v2/node/worksheet/upsert.sp.batch.workflow.json @@ -0,0 +1,89 @@ +{ + "name": "microsoft excel 365 - worksheet upsert (service principal, whole-batch item-0 target)", + "nodes": [ + { + "parameters": {}, + "id": "5a000000-0000-4000-8000-000000000001", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0] + }, + { + "parameters": { + "data": [ + { "user": "alice@contoso.com", "id": "1", "data": "new1" }, + { "user": "bob@contoso.com", "id": "3", "data": "new3" } + ] + }, + "id": "5a000000-0000-4000-8000-000000000002", + "name": "Test Data", + "type": "n8n-nodes-testing.testData", + "typeVersion": 1, + "position": [200, 0] + }, + { + "parameters": { + "authentication": "microsoftEntraServicePrincipalApi", + "userTarget": { + "__rl": true, + "value": "={{ $json.user }}", + "mode": "id" + }, + "resource": "worksheet", + "operation": "upsert", + "workbook": { + "__rl": true, + "value": "01FUWX3BQ4ATCOZNR265GLA6IJEZDQUE4I", + "mode": "id" + }, + "worksheet": { + "__rl": true, + "value": "{A0883CFE-D27E-4ECC-B94B-981830AAD55B}", + "mode": "id" + }, + "dataMode": "autoMap", + "columnToMatchOn": "id", + "options": {} + }, + "id": "5a000000-0000-4000-8000-000000000003", + "name": "Microsoft Excel 365", + "type": "n8n-nodes-base.microsoftExcel", + "typeVersion": 2.1, + "position": [400, 0], + "credentials": { + "microsoftEntraServicePrincipalApi": { + "id": "1", + "name": "Microsoft Entra Service Principal account" + } + } + } + ], + "pinData": { + "Microsoft Excel 365": [ + { + "json": { + "id": "1", + "data": "new1" + } + }, + { + "json": { + "id": "3", + "data": "new3" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [[{ "node": "Test Data", "type": "main", "index": 0 }]] + }, + "Test Data": { + "main": [[{ "node": "Microsoft Excel 365", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": {}, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/addTable.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/addTable.operation.ts index 80db436622e..5bbb519c3e9 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/addTable.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/addTable.operation.ts @@ -7,6 +7,7 @@ import type { import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest } from '../../transport'; import { workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -94,6 +95,9 @@ export async function execute( { select: 'address', }, + undefined, + undefined, + i, ); range = address.split('!')[1]; } else { @@ -108,6 +112,10 @@ export async function execute( address: range, hasHeaders, }, + undefined, + undefined, + undefined, + i, ); const executionData = this.helpers.constructExecutionMetaData( @@ -125,7 +133,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/append.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/append.operation.ts index 11e345c28b0..e8898c8313a 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/append.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/append.operation.ts @@ -159,6 +159,7 @@ export async function execute( try { // TODO: At some point it should be possible to use item dependent parameters. // Is however important to then not make one separate request each. + // Whole-batch operation: one request set for all items; request-level params (including the SP target) are read at item 0. const workbookId = this.getNodeParameter('workbook', 0, undefined, { extractValue: true, }) as string; @@ -179,6 +180,10 @@ export async function execute( 'GET', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`, {}, + undefined, + undefined, + undefined, + 0, ); const columnsRow = columnsData.value.map((column: IDataObject) => column.name); @@ -242,6 +247,10 @@ export async function execute( 'POST', `/drive/items/${workbookId}/workbook/createSession`, { persistChanges: true }, + undefined, + undefined, + undefined, + 0, ); const responseData = await microsoftApiRequest.call( this, @@ -251,6 +260,7 @@ export async function execute( {}, '', { 'workbook-session-id': id }, + 0, ); await microsoftApiRequest.call( this, @@ -260,6 +270,7 @@ export async function execute( {}, '', { 'workbook-session-id': id }, + 0, ); const rawData = options.rawData as boolean; diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/convertToRange.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/convertToRange.operation.ts index a09cb8b36a2..708b787b26b 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/convertToRange.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/convertToRange.operation.ts @@ -7,6 +7,7 @@ import type { import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest } from '../../transport'; import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -45,6 +46,11 @@ export async function execute( this, 'POST', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/convertToRange`, + undefined, + undefined, + undefined, + undefined, + i, ); const executionData = this.helpers.constructExecutionMetaData( @@ -62,7 +68,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/deleteTable.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/deleteTable.operation.ts index fb1673ea21e..24b2775fb2e 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/deleteTable.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/deleteTable.operation.ts @@ -2,6 +2,7 @@ import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest } from '../../transport'; import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -40,6 +41,11 @@ export async function execute( this, 'DELETE', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}`, + undefined, + undefined, + undefined, + undefined, + i, ); const executionData = this.helpers.constructExecutionMetaData( @@ -57,7 +63,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getColumns.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getColumns.operation.ts index 17aa9da13b4..f68955ea37b 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getColumns.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getColumns.operation.ts @@ -7,6 +7,7 @@ import type { import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport'; import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -129,6 +130,7 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`, {}, qs, + i, ); } else { qs.$top = this.getNodeParameter('limit', i); @@ -138,6 +140,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`, {}, qs, + undefined, + undefined, + i, ); responseData = responseData.value; } @@ -163,7 +168,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getRows.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getRows.operation.ts index 90ae8d69038..97337477175 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getRows.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/getRows.operation.ts @@ -7,6 +7,7 @@ import type { import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport'; import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -147,6 +148,7 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`, {}, qs, + i, ); } else { const rowsQs = { ...qs }; @@ -157,6 +159,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`, {}, rowsQs, + undefined, + undefined, + i, ); responseData = responseData.value; } @@ -171,6 +176,7 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`, {}, columnsQs, + i, ); columns = (columns as IDataObject[]).map((column) => column.name); @@ -220,7 +226,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/lookup.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/lookup.operation.ts index ccc11f6b745..73c48a5390c 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/lookup.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/table/lookup.operation.ts @@ -9,6 +9,7 @@ import { NodeApiError } from 'n8n-workflow'; import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequestAllItemsSkip } from '../../transport'; import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -95,6 +96,7 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`, {}, {}, + i, ); qs.$select = 'name'; @@ -106,6 +108,7 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`, {}, qs, + i, ); columns = columns.map((column: IDataObject) => column.name); @@ -155,7 +158,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/addWorksheet.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/addWorksheet.operation.ts index ca1ce95ae35..fa349984a2e 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/addWorksheet.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/addWorksheet.operation.ts @@ -7,6 +7,7 @@ import type { import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest } from '../../transport'; import { workbookRLC } from '../common.descriptions'; @@ -63,6 +64,10 @@ export async function execute( 'POST', `/drive/items/${workbookId}/workbook/createSession`, { persistChanges: true }, + undefined, + undefined, + undefined, + i, ); const responseData = await microsoftApiRequest.call( this, @@ -72,6 +77,7 @@ export async function execute( {}, '', { 'workbook-session-id': id }, + i, ); await microsoftApiRequest.call( this, @@ -81,6 +87,7 @@ export async function execute( {}, '', { 'workbook-session-id': id }, + i, ); if (Array.isArray(responseData)) { @@ -107,7 +114,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/deleteWorkbook.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/deleteWorkbook.operation.ts index ee389316dbf..46f7187d044 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/deleteWorkbook.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/deleteWorkbook.operation.ts @@ -3,6 +3,7 @@ import { NodeOperationError } from 'n8n-workflow'; import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest } from '../../transport'; import { workbookRLC } from '../common.descriptions'; @@ -29,7 +30,16 @@ export async function execute( }) as string; try { - await microsoftApiRequest.call(this, 'DELETE', `/drive/items/${workbookId}`); + await microsoftApiRequest.call( + this, + 'DELETE', + `/drive/items/${workbookId}`, + undefined, + undefined, + undefined, + undefined, + i, + ); } catch (error) { if (error?.description.includes('Lock token does not match existing lock')) { const errorDescription = @@ -70,7 +80,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/getAll.operation.ts index d2f121db875..8c7e6826daf 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/workbook/getAll.operation.ts @@ -8,6 +8,7 @@ import { NodeOperationError } from 'n8n-workflow'; import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { getExcelCredentialType, microsoftApiRequest, @@ -100,6 +101,7 @@ export async function execute( "/drive/root/search(q='.xlsx')", {}, qs, + i, ); } else { qs.$top = this.getNodeParameter('limit', i); @@ -109,6 +111,9 @@ export async function execute( "/drive/root/search(q='.xlsx')", {}, qs, + undefined, + undefined, + i, ); responseData = responseData.value; } @@ -137,7 +142,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/append.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/append.operation.ts index 0d14038da37..64294bf3c87 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/append.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/append.operation.ts @@ -145,6 +145,7 @@ export async function execute( const nodeVersion = this.getNode().typeVersion; + // Whole-batch operation: one request set for all items; request-level params (including the SP target) are read at item 0. const workbookId = this.getNodeParameter('workbook', 0, undefined, { extractValue: true, }) as string; @@ -159,6 +160,11 @@ export async function execute( this, 'GET', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`, + undefined, + undefined, + undefined, + undefined, + 0, ); let values: string[][] = []; @@ -254,6 +260,10 @@ export async function execute( 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, { values }, + undefined, + undefined, + undefined, + 0, ); const rawData = this.getNodeParameter('options.rawData', 0, false) as boolean; diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/clear.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/clear.operation.ts index 427a6ab8e13..250e8baa4c5 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/clear.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/clear.operation.ts @@ -2,6 +2,7 @@ import type { INodeExecutionData, IExecuteFunctions, INodeProperties } from 'n8n import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest } from '../../transport'; import { workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -88,6 +89,10 @@ export async function execute( 'POST', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range/clear`, { applyTo }, + undefined, + undefined, + undefined, + i, ); } else { const range = this.getNodeParameter('range', i, '') as string; @@ -96,6 +101,10 @@ export async function execute( 'POST', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')/clear`, { applyTo }, + undefined, + undefined, + undefined, + i, ); } @@ -114,7 +123,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/deleteWorksheet.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/deleteWorksheet.operation.ts index 0d2271a5a19..ca64ec3be12 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/deleteWorksheet.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/deleteWorksheet.operation.ts @@ -2,6 +2,7 @@ import type { INodeExecutionData, IExecuteFunctions, INodeProperties } from 'n8n import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest } from '../../transport'; import { workbookRLC, worksheetRLC } from '../common.descriptions'; @@ -36,6 +37,11 @@ export async function execute( this, 'DELETE', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}`, + undefined, + undefined, + undefined, + undefined, + i, ); const executionData = this.helpers.constructExecutionMetaData( @@ -53,7 +59,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/getAll.operation.ts index c22b87f3318..78276d8a5a7 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/getAll.operation.ts @@ -7,6 +7,7 @@ import type { import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../transport'; import { workbookRLC } from '../common.descriptions'; @@ -90,6 +91,7 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets`, {}, qs, + i, ); } else { qs.$top = this.getNodeParameter('limit', i); @@ -99,6 +101,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets`, {}, qs, + undefined, + undefined, + i, ); responseData = responseData.value; } @@ -117,7 +122,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/readRows.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/readRows.operation.ts index 26977a8078a..8b9b0738852 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/readRows.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/readRows.operation.ts @@ -7,6 +7,7 @@ import type { import { updateDisplayOptions } from '@utils/utilities'; +import { stampItemIndexOnError } from '../../../../GenericFunctions'; import type { ExcelResponse } from '../../helpers/interfaces'; import { checkRange, prepareOutput } from '../../helpers/utils'; import { microsoftApiRequest } from '../../transport'; @@ -159,6 +160,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, {}, qs, + undefined, + undefined, + i, ); } else { responseData = await microsoftApiRequest.call( @@ -167,6 +171,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`, {}, qs, + undefined, + undefined, + i, ); } @@ -199,7 +206,8 @@ export async function execute( returnData.push(...executionErrorData); continue; } - throw error; + // A NodeError from the transport may be missing the itemIndex, add it + throw stampItemIndexOnError(error, i); } } diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/update.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/update.operation.ts index b21a6b039e0..b5fca369865 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/update.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/update.operation.ts @@ -252,6 +252,7 @@ export async function execute( qs.$select = options.fields; } + // Whole-batch operation: every param (including the SP target) is read at item 0. const workbookId = this.getNodeParameter('workbook', 0, undefined, { extractValue: true, }) as string; @@ -272,6 +273,11 @@ export async function execute( this, 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, + undefined, + undefined, + undefined, + undefined, + 0, ); } @@ -288,6 +294,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`, undefined, query, + undefined, + undefined, + 0, ); range = (worksheetData.address as string).split('!')[1]; @@ -305,6 +314,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, { values }, qs, + undefined, + undefined, + 0, ); returnData.push( @@ -361,6 +373,10 @@ export async function execute( 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, { values: updateSummary.updatedData }, + undefined, + undefined, + undefined, + 0, ); const { updatedRows } = updateSummary; diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/upsert.operation.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/upsert.operation.ts index 91fe386c0ab..d387b618a77 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/upsert.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/worksheet/upsert.operation.ts @@ -204,6 +204,7 @@ export async function execute( const nodeVersion = this.getNode().typeVersion; try { + // Whole-batch operation: every param (including the SP target) is read at item 0. const workbookId = this.getNodeParameter('workbook', 0, undefined, { extractValue: true, }) as string; @@ -224,6 +225,11 @@ export async function execute( this, 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, + undefined, + undefined, + undefined, + undefined, + 0, ); } @@ -240,6 +246,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`, undefined, query, + undefined, + undefined, + 0, ); range = (worksheetData.address as string).split('!')[1]; @@ -256,6 +265,10 @@ export async function execute( 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, { values }, + undefined, + undefined, + undefined, + 0, ); } @@ -351,6 +364,9 @@ export async function execute( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`, undefined, { select: 'address' }, + undefined, + undefined, + 0, ); const usedRange = parseAddress(address as string); @@ -366,6 +382,10 @@ export async function execute( 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, { values: updateSummary.updatedData }, + undefined, + undefined, + undefined, + 0, ); const { updatedRows } = updateSummary; diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/descriptions/TargetDescription.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/descriptions/TargetDescription.ts index 1d97b52e003..6ad9f02cb3d 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/descriptions/TargetDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/descriptions/TargetDescription.ts @@ -45,7 +45,8 @@ export const userTargetRLC: INodeProperties = { hint: 'The user principal name (UPN) or object ID of the user whose drive holds the workbooks', }, ], - description: 'The user whose drive the Service Principal should act on', + description: + "The user whose drive the Service Principal should act on. Evaluated per input item — an expression can target a different user for each item. Operations that write all rows in one request (append, update, upsert) use the first item's target.", displayOptions: { show: { authentication: ['microsoftEntraServicePrincipalApi'], @@ -69,7 +70,8 @@ export const driveTargetRLC: INodeProperties = { hint: "The drive's own ID (looks like `b!…`), not a file or folder ID. Get it from `GET /users/{upn}/drive` (the `id` field).", }, ], - description: 'The drive the Service Principal should act on', + description: + "The drive the Service Principal should act on. Evaluated per input item — an expression can target a different drive for each item. Operations that write all rows in one request (append, update, upsert) use the first item's target.", displayOptions: { show: { authentication: ['microsoftEntraServicePrincipalApi'], diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/listSearch.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/listSearch.ts index c5433c5518d..0c859171ef8 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/listSearch.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/listSearch.ts @@ -8,6 +8,9 @@ import { NodeOperationError } from 'n8n-workflow'; import { getExcelCredentialType, microsoftApiRequest } from '../transport'; +// listSearch context throughout this file: the transport's trailing `0` is its +// fallback read (getNodeParameter's 2nd arg here is a fallback, not an item index). + export async function searchWorkbooks( this: ILoadOptionsFunctions, filter?: string, @@ -39,6 +42,8 @@ export async function searchWorkbooks( undefined, undefined, paginationToken, // paginationToken contains the full URL + undefined, + 0, ); } else { response = await microsoftApiRequest.call( @@ -50,6 +55,9 @@ export async function searchWorkbooks( select: 'id,name,webUrl', $top: 100, }, + undefined, + undefined, + 0, ); } @@ -98,6 +106,9 @@ export async function getWorksheetsList( { select: 'id,name', }, + undefined, + undefined, + 0, ); return { @@ -133,6 +144,10 @@ export async function getWorksheetTables( 'GET', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables`, undefined, + undefined, + undefined, + undefined, + 0, ); const results: INodeListSearchItems[] = []; @@ -149,6 +164,9 @@ export async function getWorksheetTables( { select: 'address', }, + undefined, + undefined, + 0, ); const [sheetName, sheetRange] = address.split('!' as string); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/loadOptions.ts index 894b2ca0e6b..3c251ebf4be 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/loadOptions.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/methods/loadOptions.ts @@ -3,6 +3,9 @@ import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n import { microsoftApiRequest } from '../transport'; import { parseAddress } from '../helpers/utils'; +// loadOptions context throughout this file: the transport's trailing `0` is its +// fallback read (getNodeParameter's 2nd arg here is a fallback, not an item index). + export async function getWorksheetColumnRow( this: ILoadOptionsFunctions, ): Promise { @@ -24,6 +27,9 @@ export async function getWorksheetColumnRow( `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`, undefined, { select: 'values' }, + undefined, + undefined, + 0, ); columns = worksheetData.values[0] as string[]; @@ -36,6 +42,10 @@ export async function getWorksheetColumnRow( 'PATCH', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`, { select: 'values' }, + undefined, + undefined, + undefined, + 0, ); columns = worksheetData.values[0] as string[]; @@ -79,6 +89,10 @@ export async function getTableColumns( 'GET', `/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`, {}, + undefined, + undefined, + undefined, + 0, ); return (response.value as IDataObject[]).map((column) => ({ diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/test/transport/index.test.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/test/transport/index.test.ts index faea954fd8a..0c48ddb7c62 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/test/transport/index.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/test/transport/index.test.ts @@ -9,12 +9,14 @@ import { import type { Mock, Mocked } from 'vitest'; import { mockDeep } from 'vitest-mock-extended'; +import * as readRows from '../../actions/worksheet/readRows.operation'; import { searchWorkbooks } from '../../methods/listSearch'; import { driveEndpoint, getExcelCredentialType, getServicePrincipalResourceRoot, microsoftApiRequest, + microsoftApiRequestAllItemsSkip, resolveScopeRoot, validateResourceTargetId, } from '../../transport/index'; @@ -58,7 +60,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: 'https://graph.microsoft.us', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -80,7 +91,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: '', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -101,7 +121,16 @@ describe('Microsoft Excel Transport', () => { }, }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -123,7 +152,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: 'https://graph.microsoft.com/', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -145,7 +183,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: 'https://graph.microsoft.com///', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -167,7 +214,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: 'https://graph.microsoft.us', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -189,7 +245,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: 'https://dod-graph.microsoft.us', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -211,7 +276,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: 'https://microsoftgraph.chinacloudapi.cn', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestOAuth2).toHaveBeenCalledWith( 'microsoftExcelOAuth2Api', @@ -235,7 +309,16 @@ describe('Microsoft Excel Transport', () => { it('should use microsoftExcelOAuth2Api when authentication is not set (backward compatibility)', async () => { mockExecuteFunctions.getNodeParameter.mockReturnValue(undefined); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftExcelOAuth2Api'); expect(mockRequestOAuth2).toHaveBeenCalledWith( @@ -247,7 +330,16 @@ describe('Microsoft Excel Transport', () => { it('should use microsoftExcelOAuth2Api when explicitly selected', async () => { mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftExcelOAuth2Api'); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftExcelOAuth2Api'); expect(mockRequestOAuth2).toHaveBeenCalledWith( @@ -259,7 +351,16 @@ describe('Microsoft Excel Transport', () => { it('should use microsoftOAuth2Api when the generic credential is selected', async () => { mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftOAuth2Api'); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api'); expect(mockRequestOAuth2).toHaveBeenCalledWith('microsoftOAuth2Api', expect.anything()); @@ -268,7 +369,16 @@ describe('Microsoft Excel Transport', () => { it('should resolve the credential name from the authentication parameter at index 0', async () => { mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftOAuth2Api'); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockExecuteFunctions.getNodeParameter).toHaveBeenCalledWith('authentication', 0); }); @@ -276,7 +386,16 @@ describe('Microsoft Excel Transport', () => { it('should honor graphApiBaseUrl from the generic credential (sovereign cloud)', async () => { mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftOAuth2Api'); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api'); expect(mockRequestOAuth2).toHaveBeenCalledWith( @@ -386,7 +505,16 @@ describe('Microsoft Excel Transport', () => { }); it('routes through requestWithAuthentication, never requestOAuth2', async () => { - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( 'microsoftEntraServicePrincipalApi', @@ -399,7 +527,16 @@ describe('Microsoft Excel Transport', () => { }); it('rebases /me onto /users/{id}/drive for the user target', async () => { - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( 'microsoftEntraServicePrincipalApi', @@ -421,7 +558,16 @@ describe('Microsoft Excel Transport', () => { (name in params ? params[name as string] : fallback) as never, ); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( 'microsoftEntraServicePrincipalApi', @@ -434,7 +580,16 @@ describe('Microsoft Excel Transport', () => { it('rebases onto /drives/{id} for the drive target (no double /drive)', async () => { setSpParams({ resourceTarget: 'drive', driveTarget: { value: 'b!abc-123_XYZ' } }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( 'microsoftEntraServicePrincipalApi', @@ -449,7 +604,16 @@ describe('Microsoft Excel Transport', () => { graphApiBaseUrl: 'https://graph.microsoft.us', }); - await microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 0, + ); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( 'microsoftEntraServicePrincipalApi', @@ -470,6 +634,8 @@ describe('Microsoft Excel Transport', () => { {}, {}, nextLink, + undefined, + 0, ); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( @@ -478,9 +644,99 @@ describe('Microsoft Excel Transport', () => { ); }); + // The target RLC accepts expressions, so execute call sites pass the loop index + // and the transport must resolve the target at exactly that index. + const setSpParamsPerItem = () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((name, itemIndex, fallback) => { + if (name === 'authentication') return 'microsoftEntraServicePrincipalApi' as never; + if (name === 'resourceTarget') return 'user' as never; + if (name === 'userTarget') + return { value: itemIndex === 1 ? 'john@contoso.com' : 'jane@contoso.com' } as never; + return fallback as never; + }); + }; + + it('resolves the target at the passed itemIndex', async () => { + setSpParamsPerItem(); + + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 0, + ); + await microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 1, + ); + + expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith( + 1, + 'microsoftEntraServicePrincipalApi', + expect.objectContaining({ + uri: `${baseUrl}/v1.0/users/jane%40contoso.com/drive/items/WB/workbook/worksheets/WS/usedRange`, + }), + ); + expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith( + 2, + 'microsoftEntraServicePrincipalApi', + expect.objectContaining({ + uri: `${baseUrl}/v1.0/users/john%40contoso.com/drive/items/WB/workbook/worksheets/WS/usedRange`, + }), + ); + }); + + it('keeps the passed itemIndex root on every $skip page (AllItemsSkip)', async () => { + setSpParamsPerItem(); + mockRequestWithAuthentication + .mockResolvedValueOnce({ value: [{ id: 'row-1' }] }) + .mockResolvedValueOnce({ value: [] }); + + await microsoftApiRequestAllItemsSkip.call( + mockExecuteFunctions, + 'value', + 'GET', + SCOPED_RESOURCE, + {}, + {}, + 1, + ); + + const johnUri = `${baseUrl}/v1.0/users/john%40contoso.com/drive/items/WB/workbook/worksheets/WS/usedRange`; + expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith( + 1, + 'microsoftEntraServicePrincipalApi', + expect.objectContaining({ uri: johnUri }), + ); + expect(mockRequestWithAuthentication).toHaveBeenNthCalledWith( + 2, + 'microsoftEntraServicePrincipalApi', + expect.objectContaining({ uri: johnUri }), + ); + }); + it('throws OperationalError when a scoped resource does not start with /drive', async () => { await expect( - microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks'), + microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + '/workbooks', + undefined, + undefined, + undefined, + undefined, + 0, + ), ).rejects.toThrow(OperationalError); expect(mockRequestWithAuthentication).not.toHaveBeenCalled(); }); @@ -489,7 +745,16 @@ describe('Microsoft Excel Transport', () => { mockRequestWithAuthentication.mockRejectedValue(new Error('boom')); await expect( - microsoftApiRequest.call(mockExecuteFunctions, 'GET', SCOPED_RESOURCE), + microsoftApiRequest.call( + mockExecuteFunctions, + 'GET', + SCOPED_RESOURCE, + undefined, + undefined, + undefined, + undefined, + 0, + ), ).rejects.toThrow(NodeApiError); }); @@ -501,6 +766,10 @@ describe('Microsoft Excel Transport', () => { 'PATCH', "/drive/items/WB/workbook/worksheets/WS/range(address='A1:A1')", body, + undefined, + undefined, + undefined, + 0, ); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( @@ -521,6 +790,10 @@ describe('Microsoft Excel Transport', () => { 'POST', '/drive/items/WB/workbook/worksheets/add', body, + undefined, + undefined, + undefined, + 0, ); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( @@ -588,7 +861,7 @@ describe('Microsoft Excel Transport', () => { it('returns undefined for the OAuth2 credential', () => { mockExecuteFunctions.getNodeParameter.mockReturnValue('microsoftExcelOAuth2Api'); - expect(resolveScopeRoot.call(mockExecuteFunctions)).toBeUndefined(); + expect(resolveScopeRoot.call(mockExecuteFunctions, 0)).toBeUndefined(); }); it('resolves the user root for the Service Principal credential', () => { @@ -601,7 +874,7 @@ describe('Microsoft Excel Transport', () => { (name) => params[name as string] as never, ); - expect(resolveScopeRoot.call(mockExecuteFunctions)).toBe('/users/jane%40contoso.com'); + expect(resolveScopeRoot.call(mockExecuteFunctions, 0)).toBe('/users/jane%40contoso.com'); }); it('falls back to the user target when resourceTarget is unpersisted', () => { @@ -615,7 +888,43 @@ describe('Microsoft Excel Transport', () => { (name in params ? params[name as string] : fallback) as never, ); - expect(resolveScopeRoot.call(mockExecuteFunctions)).toBe('/users/jane%40contoso.com'); + expect(resolveScopeRoot.call(mockExecuteFunctions, 0)).toBe('/users/jane%40contoso.com'); + }); + }); + + describe('execute error attribution', () => { + it('stamps context.itemIndex on a NodeError from the failing item', async () => { + const mockRequestWithAuthentication = vi + .fn() + .mockResolvedValue({ values: [['row'], ['jane-row']] }); + mockExecuteFunctions.helpers.requestWithAuthentication = mockRequestWithAuthentication; + (mockExecuteFunctions.helpers.constructExecutionMetaData as Mock).mockReturnValue([]); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + mockExecuteFunctions.getCredentials.mockResolvedValue({ graphApiBaseUrl: '' }); + mockExecuteFunctions.getNodeParameter.mockImplementation((name, itemIndex, fallback) => { + const params: Record = { + authentication: 'microsoftEntraServicePrincipalApi', + resourceTarget: 'user', + workbook: 'WB', + worksheet: 'WS', + }; + if (name === 'userTarget') + return { value: itemIndex === 1 ? 'not a user' : 'jane@contoso.com' } as never; + return (name in params ? params[name as string] : fallback) as never; + }); + + let caught: unknown; + try { + await readRows.execute.call(mockExecuteFunctions, [{ json: {} }, { json: {} }]); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(NodeOperationError); + expect((caught as NodeOperationError).message).toBe('The target ID is not valid'); + expect((caught as NodeOperationError).context.itemIndex).toBe(1); + // Item 0 resolved and requested normally before item 1 failed validation. + expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/transport/index.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/transport/index.ts index aee323e9a2a..293d4b01b2a 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/transport/index.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/transport/index.ts @@ -86,12 +86,14 @@ export function driveEndpoint(root: string): string { /** * App-only Graph scope root (`/users/{id}` or `/drives/{id}`), or `undefined` for OAuth2 (`/me`). - * The `|| 'user'` and `''` fallbacks cover load-options, where the 2nd getNodeParameter arg is the - * fallback (not an itemIndex) — an unpersisted target then coalesces to the "target ID required" error. + * The target RLC accepts expressions and is resolved per item: execute call sites pass the loop's + * item index; load-options call sites pass a literal 0 — there the 2nd getNodeParameter arg is the + * fallback (not an itemIndex), which the `|| 'user'` and `''` fallbacks cover, so an unpersisted + * target coalesces to the "target ID required" error. */ export function resolveScopeRoot( this: IExecuteFunctions | ILoadOptionsFunctions, - itemIndex = 0, + itemIndex: number, ): string | undefined { if (getExcelCredentialType.call(this) !== 'microsoftEntraServicePrincipalApi') { return undefined; @@ -105,14 +107,19 @@ export function resolveScopeRoot( return getServicePrincipalResourceRoot(target, id, this.getNode()); } +// `itemIndex` is REQUIRED so the compiler enforces the per-item contract: execute +// call sites pass the loop index (batch ops pass a literal 0, matching their item-0 +// param reads); loadOptions/listSearch call sites pass a literal 0, where +// `getNodeParameter`'s 2nd arg is a fallback, not an index. export async function microsoftApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, resource: string, body: any = {}, qs: IDataObject = {}, - uri?: string, + uri: string | undefined, headers: IDataObject = {}, + itemIndex: number, ): Promise { const credentialType = getExcelCredentialType.call(this); const isServicePrincipal = credentialType === 'microsoftEntraServicePrincipalApi'; @@ -124,9 +131,9 @@ export async function microsoftApiRequest( ).replace(/\/+$/, ''); let uriToUse = uri || `${baseUrl}/v1.0/me${resource}`; - // App-only has no `/me`; rebase page-1 onto the user/drive root. Absolute `@odata.nextLink` pages pass through. + // App-only has no `/me`; rebase page-1 onto the item's user/drive root. Absolute `@odata.nextLink` pages pass through. if (!uri && isServicePrincipal) { - const driveScopeRoot = resolveScopeRoot.call(this); + const driveScopeRoot = resolveScopeRoot.call(this, itemIndex); if (driveScopeRoot) { // Programmer error, not user input: every Excel resource is `/drive`-rooted. if (!resource.startsWith('/drive')) { @@ -159,6 +166,7 @@ export async function microsoftApiRequest( } return await this.helpers.requestOAuth2.call(this, credentialType, options); } catch (error) { + // The operation's catch stamps the failing item's index. throw new NodeApiError(this.getNode(), error as JsonObject); } } @@ -170,6 +178,7 @@ export async function microsoftApiRequestAllItems( endpoint: string, body: any = {}, query: IDataObject = {}, + itemIndex: number, ): Promise { const returnData: IDataObject[] = []; @@ -178,7 +187,16 @@ export async function microsoftApiRequestAllItems( query.$top = 100; do { - responseData = await microsoftApiRequest.call(this, method, endpoint, body, query, uri); + responseData = await microsoftApiRequest.call( + this, + method, + endpoint, + body, + query, + uri, + undefined, + itemIndex, + ); uri = responseData['@odata.nextLink']; if (uri?.includes('$top')) { delete query.$top; @@ -196,6 +214,7 @@ export async function microsoftApiRequestAllItemsSkip( endpoint: string, body: any = {}, query: IDataObject = {}, + itemIndex: number, ): Promise { const returnData: IDataObject[] = []; @@ -204,7 +223,17 @@ export async function microsoftApiRequestAllItemsSkip( query.$skip = 0; do { - responseData = await microsoftApiRequest.call(this, method, endpoint, body, query); + // Every `$skip` page re-issues the scoped relative URL, re-resolved at the same index. + responseData = await microsoftApiRequest.call( + this, + method, + endpoint, + body, + query, + undefined, + undefined, + itemIndex, + ); query.$skip += query.$top; returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]); } while (responseData.value.length !== 0);