Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/future-brown-snake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/agents-work-apps": patch
---

Fix Slack API pagination for channels and membership checks
306 changes: 294 additions & 12 deletions packages/agents-work-apps/src/__tests__/slack/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
* - WebClient instantiation
* - User info retrieval
* - Team info retrieval
* - Channel listing
* - Channel listing (with cursor-based pagination)
* - Channel membership checking (with cursor-based pagination)
* - Message posting (channels and threads)
*/

import { WebClient } from '@slack/web-api';
import { retryPolicies, WebClient } from '@slack/web-api';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkUserIsChannelMember,
getSlackChannels,
getSlackClient,
getSlackTeamInfo,
Expand All @@ -30,11 +32,15 @@ vi.mock('@slack/web-api', () => ({
},
conversations: {
list: vi.fn(),
members: vi.fn(),
},
chat: {
postMessage: vi.fn(),
},
})),
retryPolicies: {
fiveRetriesInFiveMinutes: { retries: 5, factor: 3.86, randomize: true },
},
}));

vi.mock('../../logger', () => ({
Expand All @@ -52,12 +58,14 @@ describe('Slack Client', () => {
});

describe('getSlackClient', () => {
it('should create a WebClient with the provided token', () => {
it('should create a WebClient with the provided token and retry config', () => {
const token = 'xoxb-test-token';

const client = getSlackClient(token);

expect(WebClient).toHaveBeenCalledWith(token);
expect(WebClient).toHaveBeenCalledWith(token, {
retryConfig: retryPolicies.fiveRetriesInFiveMinutes,
});
expect(client).toBeDefined();
});

Expand All @@ -68,8 +76,8 @@ describe('Slack Client', () => {
getSlackClient(token1);
getSlackClient(token2);

expect(WebClient).toHaveBeenCalledWith(token1);
expect(WebClient).toHaveBeenCalledWith(token2);
expect(WebClient).toHaveBeenCalledWith(token1, expect.anything());
expect(WebClient).toHaveBeenCalledWith(token2, expect.anything());
});
});

Expand Down Expand Up @@ -232,7 +240,7 @@ describe('Slack Client', () => {
]);
});

it('should fetch public and private channels with default limit', async () => {
it('should fetch public and private channels with default limit of 200', async () => {
const mockClient = {
conversations: {
list: vi.fn().mockResolvedValue({ ok: true, channels: [] }),
Expand All @@ -244,7 +252,8 @@ describe('Slack Client', () => {
expect(mockClient.conversations.list).toHaveBeenCalledWith({
types: 'public_channel,private_channel',
exclude_archived: true,
limit: 20,
limit: 200,
cursor: undefined,
});
});

Expand All @@ -261,10 +270,168 @@ describe('Slack Client', () => {
types: 'public_channel,private_channel',
exclude_archived: true,
limit: 50,
cursor: undefined,
});
});

it('should return empty array when request fails', async () => {
it('should cap page size at 200 even with higher limit', async () => {
const mockClient = {
conversations: {
list: vi.fn().mockResolvedValue({ ok: true, channels: [] }),
},
} as unknown as WebClient;

await getSlackChannels(mockClient, 500);

expect(mockClient.conversations.list).toHaveBeenCalledWith(
expect.objectContaining({ limit: 200 })
);
});

it('should paginate through multiple pages using cursor', async () => {
const mockClient = {
conversations: {
list: vi
.fn()
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C1', name: 'ch1', num_members: 1, is_member: true, is_private: false },
{ id: 'C2', name: 'ch2', num_members: 2, is_member: true, is_private: false },
],
response_metadata: { next_cursor: 'cursor_page2' },
})
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C3', name: 'ch3', num_members: 3, is_member: false, is_private: true },
],
response_metadata: { next_cursor: '' },
}),
},
} as unknown as WebClient;

const result = await getSlackChannels(mockClient, 200);

expect(mockClient.conversations.list).toHaveBeenCalledTimes(2);
expect(mockClient.conversations.list).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ cursor: undefined })
);
expect(mockClient.conversations.list).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ cursor: 'cursor_page2' })
);
expect(result).toHaveLength(3);
expect(result.map((c) => c.id)).toEqual(['C1', 'C2', 'C3']);
});

it('should stop paginating once limit is reached', async () => {
const mockClient = {
conversations: {
list: vi
.fn()
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C1', name: 'ch1', num_members: 1, is_member: true, is_private: false },
{ id: 'C2', name: 'ch2', num_members: 2, is_member: true, is_private: false },
],
response_metadata: { next_cursor: 'cursor_page2' },
})
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C3', name: 'ch3', num_members: 3, is_member: false, is_private: false },
],
response_metadata: { next_cursor: 'cursor_page3' },
}),
},
} as unknown as WebClient;

const result = await getSlackChannels(mockClient, 2);

expect(mockClient.conversations.list).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(2);
});

it('should trim results to exact limit when last page overshoots', async () => {
const mockClient = {
conversations: {
list: vi
.fn()
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C1', name: 'ch1', num_members: 1, is_member: true, is_private: false },
{ id: 'C2', name: 'ch2', num_members: 2, is_member: true, is_private: false },
],
response_metadata: { next_cursor: 'cursor_page2' },
})
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C3', name: 'ch3', num_members: 3, is_member: false, is_private: false },
{ id: 'C4', name: 'ch4', num_members: 4, is_member: false, is_private: false },
],
response_metadata: { next_cursor: '' },
}),
},
} as unknown as WebClient;

const result = await getSlackChannels(mockClient, 3);

expect(result).toHaveLength(3);
expect(result.map((c) => c.id)).toEqual(['C1', 'C2', 'C3']);
});

it('should return partial results when ok: false occurs mid-pagination', async () => {
const mockClient = {
conversations: {
list: vi
.fn()
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C1', name: 'ch1', num_members: 1, is_member: true, is_private: false },
],
response_metadata: { next_cursor: 'cursor_page2' },
})
.mockResolvedValueOnce({
ok: false,
error: 'ratelimited',
response_metadata: { next_cursor: '' },
}),
},
} as unknown as WebClient;

const result = await getSlackChannels(mockClient, 200);

expect(mockClient.conversations.list).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('C1');
});

it('should throw when error occurs mid-pagination', async () => {
const mockClient = {
conversations: {
list: vi
.fn()
.mockResolvedValueOnce({
ok: true,
channels: [
{ id: 'C1', name: 'ch1', num_members: 1, is_member: true, is_private: false },
],
response_metadata: { next_cursor: 'cursor_page2' },
})
.mockRejectedValueOnce(new Error('ratelimited')),
},
} as unknown as WebClient;

await expect(getSlackChannels(mockClient, 200)).rejects.toThrow('ratelimited');
});

it('should return empty array when ok: false on first page', async () => {
const mockClient = {
conversations: {
list: vi.fn().mockResolvedValue({ ok: false }),
Expand All @@ -276,16 +443,131 @@ describe('Slack Client', () => {
expect(result).toEqual([]);
});

it('should return empty array on error', async () => {
it('should throw on API error', async () => {
const mockClient = {
conversations: {
list: vi.fn().mockRejectedValue(new Error('API error')),
},
} as unknown as WebClient;

const result = await getSlackChannels(mockClient);
await expect(getSlackChannels(mockClient)).rejects.toThrow('API error');
});
});

expect(result).toEqual([]);
describe('checkUserIsChannelMember', () => {
it('should return true when user is found on first page', async () => {
const mockClient = {
conversations: {
members: vi.fn().mockResolvedValue({
ok: true,
members: ['U001', 'U002', 'U003'],
response_metadata: { next_cursor: '' },
}),
},
} as unknown as WebClient;

const result = await checkUserIsChannelMember(mockClient, 'C123', 'U002');

expect(result).toBe(true);
expect(mockClient.conversations.members).toHaveBeenCalledTimes(1);
});

it('should return false when user is not a member', async () => {
const mockClient = {
conversations: {
members: vi.fn().mockResolvedValue({
ok: true,
members: ['U001', 'U002'],
response_metadata: { next_cursor: '' },
}),
},
} as unknown as WebClient;

const result = await checkUserIsChannelMember(mockClient, 'C123', 'U999');

expect(result).toBe(false);
});

it('should paginate to find user on later page', async () => {
const mockClient = {
conversations: {
members: vi
.fn()
.mockResolvedValueOnce({
ok: true,
members: ['U001', 'U002'],
response_metadata: { next_cursor: 'members_cursor2' },
})
.mockResolvedValueOnce({
ok: true,
members: ['U003', 'U004'],
response_metadata: { next_cursor: '' },
}),
},
} as unknown as WebClient;

const result = await checkUserIsChannelMember(mockClient, 'C123', 'U004');

expect(result).toBe(true);
expect(mockClient.conversations.members).toHaveBeenCalledTimes(2);
expect(mockClient.conversations.members).toHaveBeenNthCalledWith(1, {
channel: 'C123',
limit: 200,
cursor: undefined,
});
expect(mockClient.conversations.members).toHaveBeenNthCalledWith(2, {
channel: 'C123',
limit: 200,
cursor: 'members_cursor2',
});
});

it('should return false when user not found after all pages', async () => {
const mockClient = {
conversations: {
members: vi
.fn()
.mockResolvedValueOnce({
ok: true,
members: ['U001', 'U002'],
response_metadata: { next_cursor: 'members_cursor2' },
})
.mockResolvedValueOnce({
ok: true,
members: ['U003'],
response_metadata: { next_cursor: '' },
}),
},
} as unknown as WebClient;

const result = await checkUserIsChannelMember(mockClient, 'C123', 'U999');

expect(result).toBe(false);
expect(mockClient.conversations.members).toHaveBeenCalledTimes(2);
});

it('should return false when API returns ok: false', async () => {
const mockClient = {
conversations: {
members: vi.fn().mockResolvedValue({ ok: false }),
},
} as unknown as WebClient;

const result = await checkUserIsChannelMember(mockClient, 'C123', 'U001');

expect(result).toBe(false);
});

it('should throw on API error', async () => {
const mockClient = {
conversations: {
members: vi.fn().mockRejectedValue(new Error('channel_not_found')),
},
} as unknown as WebClient;

await expect(checkUserIsChannelMember(mockClient, 'C123', 'U001')).rejects.toThrow(
'channel_not_found'
);
});
});

Expand Down
Loading
Loading