fix(Microsoft Teams Node): Respect limit server-side in getAll operations (#34851)

This commit is contained in:
Elias Meire
2026-07-24 13:41:45 +00:00
committed by GitHub
parent b5da0744c4
commit ded1697671
12 changed files with 281 additions and 37 deletions
@@ -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": "<div>First message</div>"
}
}
}
]
},
"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": []
}
@@ -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: '<div>First message</div>',
},
},
{
id: '1698130964683',
messageType: 'message',
body: {
contentType: 'html',
content: '<div>Second message</div>',
},
},
],
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['getAll.workflow.json'],
workflowFiles: ['getAll.workflow.json', 'getAll.limit.workflow.json'],
});
});
@@ -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: [
{
@@ -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,
);
});
});
@@ -0,0 +1,88 @@
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { microsoftApiRequestAllItems } from '../../v2/transport';
describe('MicrosoftTeamsV2 transport, microsoftApiRequestAllItems', () => {
const makeContext = (requestOAuth2: ReturnType<typeof vi.fn>) =>
({
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<typeof vi.fn>, index: number) =>
requestOAuth2.mock.calls[index][1] as { qs: Record<string, unknown>; 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({});
});
});
@@ -46,6 +46,7 @@ export async function microsoftApiRequestAllItems(
body: any = {},
query: IDataObject = {},
limit?: number,
): Promise<any> {
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<any> {
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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,
);
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -268,6 +268,7 @@ export async function microsoftApiRequestAllItems(
endpoint: string,
body: any = {},
query: IDataObject = {},
limit?: number,
): Promise<any> {
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);