diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/channelMessage/getAll.limit.workflow.json b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/channelMessage/getAll.limit.workflow.json new file mode 100644 index 00000000000..13785790831 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/channelMessage/getAll.limit.workflow.json @@ -0,0 +1,101 @@ +{ + "name": "My workflow 36", + "nodes": [ + { + "parameters": {}, + "id": "6666-9999-88888", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [880, 380] + }, + { + "parameters": { + "resource": "channelMessage", + "operation": "getAll", + "teamId": { + "__rl": true, + "value": "1111-2222-3333", + "mode": "list", + "cachedResultName": "U.S. Sales" + }, + "channelId": { + "__rl": true, + "value": "42:aaabbbccc.tacv2", + "mode": "list", + "cachedResultName": "Sales West", + "cachedResultUrl": "https://teams.microsoft.com/l/channel/threadId/Sales%20West?groupId=1111-2222-3333&tenantId=tenantId-111-222-333&allowXTenantAccess=False" + }, + "returnAll": false, + "limit": 1 + }, + "id": "6666-5555-88888", + "name": "Microsoft Teams", + "type": "n8n-nodes-base.microsoftTeams", + "typeVersion": 2, + "position": [1100, 380], + "credentials": { + "microsoftTeamsOAuth2Api": { + "id": "6isd5ytvA0qV78eK", + "name": "Microsoft Teams account" + } + } + }, + { + "parameters": {}, + "id": "9d1a2e59-c71c-486c-b3ac-dec6adbc26b4", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1400, 380] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1698130964682", + "messageType": "message", + "body": { + "contentType": "html", + "content": "
First message
" + } + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Microsoft Teams", + "type": "main", + "index": 0 + } + ] + ] + }, + "Microsoft Teams": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "d89de79a-1819-4d29-b781-a1f3f00b4a2f", + "id": "i3NYGF0LXV4qDFVA", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/channelMessage/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/channelMessage/getAll.test.ts index 50feb0ab8ce..ec808f2a81c 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/channelMessage/getAll.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/channelMessage/getAll.test.ts @@ -64,8 +64,34 @@ describe('Test MicrosoftTeamsV2, channelMessage => getAll', () => { ], }); + nock('https://graph.microsoft.com') + .get('/beta/teams/1111-2222-3333/channels/42:aaabbbccc.tacv2/messages') + .query({ $top: 1 }) + .reply(200, { + '@odata.nextLink': + 'https://graph.microsoft.com/beta/teams/1111-2222-3333/channels/42:aaabbbccc.tacv2/messages?$skiptoken=abc', + value: [ + { + id: '1698130964682', + messageType: 'message', + body: { + contentType: 'html', + content: '
First message
', + }, + }, + { + id: '1698130964683', + messageType: 'message', + body: { + contentType: 'html', + content: '
Second message
', + }, + }, + ], + }); + new NodeTestHarness().setupTests({ credentials, - workflowFiles: ['getAll.workflow.json'], + workflowFiles: ['getAll.workflow.json', 'getAll.limit.workflow.json'], }); }); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/getAll.test.ts index 4b850a7abb1..7477057dab9 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/getAll.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/getAll.test.ts @@ -6,6 +6,7 @@ import { credentials } from '../../../credentials'; describe('Test MicrosoftTeamsV2, chatMessage => getAll', () => { nock('https://graph.microsoft.com') .get('/v1.0/chats/19:ebed9ad42c904d6c83adf0db360053ec@thread.v2/messages') + .query({ $top: 2 }) .reply(200, { value: [ { diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/servicePrincipal.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/servicePrincipal.test.ts index 3e6ae2d1560..2bde2c5f219 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/servicePrincipal.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/servicePrincipal.test.ts @@ -346,6 +346,8 @@ describe('Microsoft Teams V2 — Service Principal runtime guards', () => { 'GET', '/beta/teams/1111-2222-3333/channels/19:abc@thread.tacv2/messages', {}, + { $top: 1 }, + 1, ); }); }); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/transport.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/transport.test.ts new file mode 100644 index 00000000000..fb766168caa --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/transport.test.ts @@ -0,0 +1,88 @@ +import type { IExecuteFunctions, INode } from 'n8n-workflow'; + +import { microsoftApiRequestAllItems } from '../../v2/transport'; + +describe('MicrosoftTeamsV2 transport, microsoftApiRequestAllItems', () => { + const makeContext = (requestOAuth2: ReturnType) => + ({ + getNodeParameter: vi.fn().mockReturnValue(undefined), + getCredentials: vi.fn().mockResolvedValue({}), + getNode: vi.fn().mockReturnValue({ name: 'Microsoft Teams' } as INode), + helpers: { requestOAuth2 }, + }) as unknown as IExecuteFunctions; + + const optionsOfCall = (requestOAuth2: ReturnType, index: number) => + requestOAuth2.mock.calls[index][1] as { qs: Record; uri?: string }; + + it('forwards the query and stops before the next page once the limit is satisfied', async () => { + const requestOAuth2 = vi.fn().mockResolvedValue({ + value: [{ id: '1' }, { id: '2' }, { id: '3' }], + '@odata.nextLink': 'https://graph.microsoft.com/next-page', + }); + const ctx = makeContext(requestOAuth2); + + const result = await microsoftApiRequestAllItems.call( + ctx, + 'value', + 'GET', + '/beta/teams/1/channels/2/messages', + {}, + { $top: 2 }, + 2, + ); + + expect(result).toEqual([{ id: '1' }, { id: '2' }]); + expect(requestOAuth2).toHaveBeenCalledTimes(1); + expect(optionsOfCall(requestOAuth2, 0).qs).toEqual({ $top: 2 }); + }); + + it('paginates until the limit is reached without re-sending the query', async () => { + const page = (n: number) => Array.from({ length: n }, (_, i) => ({ id: String(i) })); + const requestOAuth2 = vi + .fn() + .mockResolvedValueOnce({ + value: page(50), + '@odata.nextLink': 'https://graph.microsoft.com/next-page', + }) + .mockResolvedValueOnce({ value: page(50) }); + const ctx = makeContext(requestOAuth2); + + const result = await microsoftApiRequestAllItems.call( + ctx, + 'value', + 'GET', + '/beta/teams/1/channels/2/messages', + {}, + { $top: 100 }, + 100, + ); + + expect(result).toHaveLength(100); + expect(requestOAuth2).toHaveBeenCalledTimes(2); + expect(optionsOfCall(requestOAuth2, 0).qs).toEqual({ $top: 100 }); + expect(optionsOfCall(requestOAuth2, 1).qs).toEqual({}); + expect(optionsOfCall(requestOAuth2, 1).uri).toBe('https://graph.microsoft.com/next-page'); + }); + + it('walks every page when no limit is set', async () => { + const requestOAuth2 = vi + .fn() + .mockResolvedValueOnce({ + value: [{ id: '1' }], + '@odata.nextLink': 'https://graph.microsoft.com/next-page', + }) + .mockResolvedValueOnce({ value: [{ id: '2' }] }); + const ctx = makeContext(requestOAuth2); + + const result = await microsoftApiRequestAllItems.call( + ctx, + 'value', + 'GET', + '/v1.0/teams/1/channels', + ); + + expect(result).toEqual([{ id: '1' }, { id: '2' }]); + expect(requestOAuth2).toHaveBeenCalledTimes(2); + expect(optionsOfCall(requestOAuth2, 0).qs).toEqual({}); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v1/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Teams/v1/GenericFunctions.ts index b4fe1631f1b..59d81584809 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v1/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v1/GenericFunctions.ts @@ -46,6 +46,7 @@ export async function microsoftApiRequestAllItems( body: any = {}, query: IDataObject = {}, + limit?: number, ): Promise { const returnData: IDataObject[] = []; @@ -53,12 +54,19 @@ export async function microsoftApiRequestAllItems( let uri: string | undefined; do { - responseData = await microsoftApiRequest.call(this, method, endpoint, body, query, uri); + // `@odata.nextLink` already carries the query params; don't re-send them + responseData = await microsoftApiRequest.call( + this, + method, + endpoint, + body, + uri ? {} : query, + uri, + ); uri = responseData['@odata.nextLink']; returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]); - const limit = query.limit as number | undefined; - if (limit && limit <= returnData.length) { - return returnData; + if (limit && returnData.length >= limit) { + return returnData.slice(0, limit); } } while (responseData['@odata.nextLink'] !== undefined); @@ -70,7 +78,6 @@ export async function microsoftApiRequestAllItemsSkip( propertyName: string, method: IHttpRequestMethods, endpoint: string, - body: any = {}, query: IDataObject = {}, ): Promise { diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v1/MicrosoftTeamsV1.node.ts b/packages/nodes-base/nodes/Microsoft/Teams/v1/MicrosoftTeamsV1.node.ts index a75646da434..2a6f5289276 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v1/MicrosoftTeamsV1.node.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v1/MicrosoftTeamsV1.node.ts @@ -275,7 +275,6 @@ export class MicrosoftTeamsV1 implements INodeType { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const length = items.length; - const qs: IDataObject = {}; let responseData; const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); @@ -339,15 +338,17 @@ export class MicrosoftTeamsV1 implements INodeType { `/v1.0/teams/${teamId}/channels`, ); } else { - qs.limit = this.getNodeParameter('limit', i); + const limit = this.getNodeParameter('limit', i); + // list-channels doesn't support $top; the limit still stops pagination early responseData = await microsoftApiRequestAllItems.call( this, 'value', 'GET', `/v1.0/teams/${teamId}/channels`, {}, + {}, + limit, ); - responseData = responseData.splice(0, qs.limit); } } //https://docs.microsoft.com/en-us/graph/api/channel-patch?view=graph-rest-beta&tabs=http @@ -424,15 +425,16 @@ export class MicrosoftTeamsV1 implements INodeType { `/beta/teams/${teamId}/channels/${channelId}/messages`, ); } else { - qs.limit = this.getNodeParameter('limit', i); + const limit = this.getNodeParameter('limit', i); responseData = await microsoftApiRequestAllItems.call( this, 'value', 'GET', `/beta/teams/${teamId}/channels/${channelId}/messages`, {}, + { $top: limit }, + limit, ); - responseData = responseData.splice(0, qs.limit); } } } @@ -484,15 +486,16 @@ export class MicrosoftTeamsV1 implements INodeType { `/v1.0/chats/${chatId}/messages`, ); } else { - qs.limit = this.getNodeParameter('limit', i); + const limit = this.getNodeParameter('limit', i); responseData = await microsoftApiRequestAllItems.call( this, 'value', 'GET', `/v1.0/chats/${chatId}/messages`, {}, + { $top: limit }, + limit, ); - responseData = responseData.splice(0, qs.limit); } } } @@ -575,15 +578,18 @@ export class MicrosoftTeamsV1 implements INodeType { `/v1.0/users/${memberId}/planner/tasks`, ); } else { - qs.limit = this.getNodeParameter('limit', i); + const limit = this.getNodeParameter('limit', i); + // Planner endpoints don't support OData query params like $top; + // the limit still stops pagination early responseData = await microsoftApiRequestAllItems.call( this, 'value', 'GET', `/v1.0/users/${memberId}/planner/tasks`, {}, + {}, + limit, ); - responseData = responseData.splice(0, qs.limit); } } else { //https://docs.microsoft.com/en-us/graph/api/plannerplan-list-tasks?view=graph-rest-1.0&tabs=http @@ -596,15 +602,16 @@ export class MicrosoftTeamsV1 implements INodeType { `/v1.0/planner/plans/${planId}/tasks`, ); } else { - qs.limit = this.getNodeParameter('limit', i); + const limit = this.getNodeParameter('limit', i); responseData = await microsoftApiRequestAllItems.call( this, 'value', 'GET', `/v1.0/planner/plans/${planId}/tasks`, {}, + {}, + limit, ); - responseData = responseData.splice(0, qs.limit); } } } diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channel/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channel/getAll.operation.ts index 88321b9c453..22e1979f60f 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channel/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channel/getAll.operation.ts @@ -27,7 +27,7 @@ export async function execute(this: IExecuteFunctions, i: number) { return await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint); } else { const limit = this.getNodeParameter('limit', i); - const responseData = await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint, {}); - return responseData.splice(0, limit); + // list-channels doesn't support $top; the limit still stops pagination early + return await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint, {}, {}, limit); } } diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channelMessage/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channelMessage/getAll.operation.ts index fd2bf2fdf14..790fb10e21d 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channelMessage/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/channelMessage/getAll.operation.ts @@ -36,7 +36,14 @@ export async function execute(this: IExecuteFunctions, i: number) { return await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint); } else { const limit = this.getNodeParameter('limit', i); - const responseData = await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint, {}); - return responseData.splice(0, limit); + return await microsoftApiRequestAllItems.call( + this, + 'value', + 'GET', + endpoint, + {}, + { $top: limit }, + limit, + ); } } diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/chatMessage/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/chatMessage/getAll.operation.ts index 6e2dbc68c2a..a33cb5489c5 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/chatMessage/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/chatMessage/getAll.operation.ts @@ -39,13 +39,14 @@ export async function execute(this: IExecuteFunctions, i: number) { ); } else { const limit = this.getNodeParameter('limit', i); - const responseData = await microsoftApiRequestAllItems.call( + return await microsoftApiRequestAllItems.call( this, 'value', 'GET', buildTeamsPath.call(this, ['/v1.0/chats/', { id: chatId }, '/messages']), {}, + { $top: limit }, + limit, ); - return responseData.splice(0, limit); } } diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/task/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/task/getAll.operation.ts index 73de83805e5..7966d543a69 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/task/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/task/getAll.operation.ts @@ -101,14 +101,17 @@ export async function execute(this: IExecuteFunctions, i: number) { ); } else { const limit = this.getNodeParameter('limit', i); - const responseData = await microsoftApiRequestAllItems.call( + // Planner endpoints don't support OData query params like $top; + // the limit still stops pagination early + return await microsoftApiRequestAllItems.call( this, 'value', 'GET', `/v1.0/users/${memberId}/planner/tasks`, {}, + {}, + limit, ); - return responseData.splice(0, limit); } } else { //https://docs.microsoft.com/en-us/graph/api/plannerplan-list-tasks?view=graph-rest-1.0&tabs=http @@ -118,14 +121,7 @@ export async function execute(this: IExecuteFunctions, i: number) { return await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint); } else { const limit = this.getNodeParameter('limit', i); - const responseData = await microsoftApiRequestAllItems.call( - this, - 'value', - 'GET', - endpoint, - {}, - ); - return responseData.splice(0, limit); + return await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint, {}, {}, limit); } } } diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts index a58f730aad3..ca8fc3b18bf 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts @@ -268,6 +268,7 @@ export async function microsoftApiRequestAllItems( endpoint: string, body: any = {}, query: IDataObject = {}, + limit?: number, ): Promise { const returnData: IDataObject[] = []; @@ -275,12 +276,19 @@ export async function microsoftApiRequestAllItems( let uri: string | undefined; do { - responseData = await microsoftApiRequest.call(this, method, endpoint, body, query, uri); + // `@odata.nextLink` already carries the query params; don't re-send them + responseData = await microsoftApiRequest.call( + this, + method, + endpoint, + body, + uri ? {} : query, + uri, + ); uri = responseData['@odata.nextLink']; returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]); - const limit = query.limit as number | undefined; - if (limit && limit <= returnData.length) { - return returnData; + if (limit && returnData.length >= limit) { + return returnData.slice(0, limit); } } while (responseData['@odata.nextLink'] !== undefined);