arcade-mcp/toolkits/slack/tests/test_utils.py
Renato Byrro ed89af4b4d
Slack Toolkit Refactoring (#453)
# Backwards-compatible refactoring of the Slack toolkit

Several performance improvements, streamlined tool set, and easier to
understand tool interfaces.

All "old" tools were kept for backwards compatibility, with the same
interfaces and response structure (but using the new and more performant
tools under the hood).

Full revision of unit-tests and evals.

##  Streamlined tool set

Multiple groups of tools were streamlined into a single one:

* "get conversation metadata" from 5 tools to one;
* "send message" from 2 tools to one;
* "get users in conversation" from 3 tools to one;
* "get messages" from 4 tools to one

## New capabilities

* Messages retrieved are now populated with the users' names, apart from
ID: makes it easier for LLMs to reference who sent a message, were
mentioned, or reacted to a message
* Retrieve users by username, email, and/or ID (before we only supported
ID)
* Retrieve multiple users in a single tool call

## Concurrency controls

All operations issuing multiple requests concurrently now have a
`Semaphore` to limit the concurrency level. The limit can be controlled
through the `SLACK_MAX_CONCURRENT_REQUESTS` env var (defaults to 3).

## Networking performance improvement

Various operations that used to make multiple API calls are now executed
more efficiently:

### Find users by username

* Before: a full scan of `users_list` was required (potentially multiple
pages for large workspaces);
* Now it stops as soon as we have all users needed (yes, it was dumb
before)

### Get multiple users by their IDs

* Before: for each user ID, we made one API call to the `users_info`
endpoint
* Now: we call `list_users` and scan the results to match the user IDs
(an estimate of 99.5% of Slack workspaces have < 200 users; for large
workspaces, we may need to paginate `list_users`)

### Get a conversation by its users

* Before:
  * Call to `list_conversations` (potentially paginating)
* For each conversation, one call to `conversations_members`
(potentially paginating)
  * Then loop and find which conversation matches the users' IDs
* Now:
  * A single call to `conversations_open`
2025-07-10 13:50:28 -03:00

689 lines
21 KiB
Python

import asyncio
import copy
import json
from unittest.mock import AsyncMock, call, patch
import pytest
from arcade_tdk.errors import RetryableToolError
from slack_sdk.errors import SlackApiError
from slack_sdk.web.async_client import AsyncWebClient
from arcade_slack.exceptions import PaginationTimeoutError
from arcade_slack.models import (
ConcurrencySafeCoroutineCaller,
FindMultipleUsersByUsernameSentinel,
FindUserByUsernameSentinel,
)
from arcade_slack.utils import (
async_paginate,
build_multiple_users_retrieval_response,
filter_conversations_by_user_ids,
gather_with_concurrency_limit,
is_valid_email,
populate_users_in_messages,
)
@pytest.mark.asyncio
async def test_async_paginate():
mock_slack_client = AsyncMock()
mock_slack_client.conversations_list.return_value = {
"ok": True,
"channels": [{"id": "123"}],
"response_metadata": {"next_cursor": None},
}
results, next_cursor = await async_paginate(
func=mock_slack_client.conversations_list,
response_key="channels",
)
assert results == [{"id": "123"}]
assert next_cursor is None
@pytest.mark.asyncio
async def test_async_paginate_with_find_user_sentinel():
mock_slack_client = AsyncMock()
mock_slack_client.users_list.side_effect = [
{
"ok": True,
"members": [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
],
"response_metadata": {"next_cursor": "cursor1"},
},
{
"ok": True,
"members": [{"id": "789", "name": "Jenifer"}],
"response_metadata": {"next_cursor": "cursor2"},
},
{
"ok": True,
"members": [{"id": "007", "name": "James"}],
"response_metadata": {"next_cursor": None},
},
]
results, next_cursor = await async_paginate(
func=mock_slack_client.users_list,
response_key="members",
sentinel=FindUserByUsernameSentinel(username="jenifer"),
)
assert results == [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
{"id": "789", "name": "Jenifer"},
]
assert next_cursor == "cursor2"
@pytest.mark.asyncio
async def test_async_paginate_with_find_user_sentinel_not_found():
mock_slack_client = AsyncMock()
mock_slack_client.users_list.side_effect = [
{
"ok": True,
"members": [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
],
"response_metadata": {"next_cursor": "cursor1"},
},
{
"ok": True,
"members": [{"id": "789", "name": "Jenifer"}],
"response_metadata": {"next_cursor": "cursor2"},
},
{
"ok": True,
"members": [{"id": "007", "name": "James"}],
"response_metadata": {"next_cursor": None},
},
]
results, next_cursor = await async_paginate(
func=mock_slack_client.users_list,
response_key="members",
sentinel=FindUserByUsernameSentinel(username="Do not find me"),
)
assert results == [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
{"id": "789", "name": "Jenifer"},
{"id": "007", "name": "James"},
]
assert next_cursor is None
@pytest.mark.asyncio
async def test_async_paginate_with_find_multiple_users_sentinel():
mock_slack_client = AsyncMock()
mock_slack_client.users_list.side_effect = [
{
"ok": True,
"members": [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
],
"response_metadata": {"next_cursor": "cursor1"},
},
{
"ok": True,
"members": [
{"id": "789", "name": "Jenifer"},
{"id": "101", "name": "Janis"},
],
"response_metadata": {"next_cursor": "cursor2"},
},
{
"ok": True,
"members": [{"id": "007", "name": "James"}],
"response_metadata": {"next_cursor": None},
},
]
results, next_cursor = await async_paginate(
func=mock_slack_client.users_list,
response_key="members",
sentinel=FindMultipleUsersByUsernameSentinel(usernames=["jenifer", "jack"]),
)
assert results == [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
{"id": "789", "name": "Jenifer"},
{"id": "101", "name": "Janis"},
]
assert next_cursor == "cursor2"
@pytest.mark.asyncio
async def test_async_paginate_with_find_multiple_users_sentinel_not_found():
mock_slack_client = AsyncMock()
mock_slack_client.users_list.side_effect = [
{
"ok": True,
"members": [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
],
"response_metadata": {"next_cursor": "cursor1"},
},
{
"ok": True,
"members": [
{"id": "789", "name": "Jenifer"},
{"id": "101", "name": "Janis"},
],
"response_metadata": {"next_cursor": "cursor2"},
},
{
"ok": True,
"members": [{"id": "007", "name": "James"}],
"response_metadata": {"next_cursor": None},
},
]
results, next_cursor = await async_paginate(
func=mock_slack_client.users_list,
response_key="members",
sentinel=FindMultipleUsersByUsernameSentinel(
usernames=["jenifer", "jack", "do not find me"]
),
)
assert results == [
{"id": "123", "name": "Jack"},
{"id": "456", "name": "John"},
{"id": "789", "name": "Jenifer"},
{"id": "101", "name": "Janis"},
{"id": "007", "name": "James"},
]
assert next_cursor is None
@pytest.mark.asyncio
async def test_async_paginate_with_response_error():
mock_slack_client = AsyncMock()
mock_slack_client.conversations_list.side_effect = SlackApiError(
message="slack_error",
response={"ok": False, "error": "slack_error"},
)
with pytest.raises(SlackApiError) as e:
await async_paginate(
func=mock_slack_client.conversations_list,
response_key="channels",
)
assert str(e.value) == "slack_error"
@pytest.mark.asyncio
async def test_async_paginate_with_custom_pagination_args():
mock_slack_client = AsyncMock()
mock_slack_client.conversations_list.return_value = {
"ok": True,
"channels": [{"id": "123"}],
"response_metadata": {"next_cursor": "456"},
}
results, next_cursor = await async_paginate(
func=mock_slack_client.conversations_list,
response_key="channels",
limit=1,
next_cursor="123",
hello="world",
)
assert results == [{"id": "123"}]
assert next_cursor == "456"
mock_slack_client.conversations_list.assert_called_once_with(
hello="world",
limit=1,
cursor="123",
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_limit, last_next_cursor, last_expected_limit",
[(5, "cursor3", 1), (None, None, 2)],
)
async def test_async_paginate_large_limit(test_limit, last_next_cursor, last_expected_limit):
mock_slack_client = AsyncMock(spec=AsyncWebClient)
mock_slack_client.conversations_list.side_effect = [
{
"ok": True,
"channels": [{"id": "channel1"}, {"id": "channel2"}],
"response_metadata": {"next_cursor": "cursor1"},
},
{
"ok": True,
"channels": [{"id": "channel3"}, {"id": "channel4"}],
"response_metadata": {"next_cursor": "cursor2"},
},
{
"ok": True,
"channels": [{"id": "channel5"}],
"response_metadata": {"next_cursor": last_next_cursor},
},
]
with patch("arcade_slack.utils.MAX_PAGINATION_SIZE_LIMIT", 2):
results, next_cursor = await async_paginate(
func=mock_slack_client.conversations_list,
response_key="channels",
limit=test_limit,
hello="world",
)
assert results == [
{"id": "channel1"},
{"id": "channel2"},
{"id": "channel3"},
{"id": "channel4"},
{"id": "channel5"},
]
assert next_cursor == last_next_cursor
assert mock_slack_client.conversations_list.call_count == 3
mock_slack_client.conversations_list.assert_has_calls([
call(hello="world", limit=2, cursor=None),
call(hello="world", limit=2, cursor="cursor1"),
call(hello="world", limit=last_expected_limit, cursor="cursor2"),
])
@pytest.mark.asyncio
async def test_async_paginate_large_limit_with_response_error():
mock_slack_client = AsyncMock()
mock_slack_client.conversations_list.side_effect = [
{
"ok": True,
"channels": [{"id": "channel1"}, {"id": "channel2"}],
"response_metadata": {"next_cursor": "cursor1"},
},
SlackApiError(message="slack_error", response={"ok": False, "error": "slack_error"}),
{
"ok": True,
"channels": [{"id": "channel5"}],
"response_metadata": {"next_cursor": "cursor3"},
},
]
with (
patch("arcade_slack.utils.MAX_PAGINATION_SIZE_LIMIT", 2),
pytest.raises(SlackApiError) as e,
):
await async_paginate(
func=mock_slack_client.conversations_list,
response_key="channels",
limit=5,
hello="world",
)
assert str(e.value) == "slack_error"
assert mock_slack_client.conversations_list.call_count == 2
mock_slack_client.conversations_list.assert_has_calls([
call(hello="world", limit=2, cursor=None),
call(hello="world", limit=2, cursor="cursor1"),
])
@pytest.mark.asyncio
async def test_async_paginate_with_timeout():
# Mock Slack client
mock_slack_client = AsyncMock()
# Simulate a network delay by making the mock function sleep
async def mock_conversations_list(*args, **kwargs):
await asyncio.sleep(1) # Sleep for 1 second to simulate delay
return {
"ok": True,
"channels": [{"id": "123"}],
"response_metadata": {"next_cursor": None},
}
mock_slack_client.conversations_list.side_effect = mock_conversations_list
# Set a low timeout to trigger the timeout error quickly during the test
max_pagination_timeout_seconds = 0.1 # 100 milliseconds
with pytest.raises(PaginationTimeoutError) as exc_info:
await async_paginate(
func=mock_slack_client.conversations_list,
response_key="channels",
max_pagination_timeout_seconds=max_pagination_timeout_seconds,
)
assert (
str(exc_info.value)
== f"The pagination process timed out after {max_pagination_timeout_seconds} seconds."
)
def test_filter_conversations_by_user_ids():
conversations = [
{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]},
{"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]},
]
response = filter_conversations_by_user_ids(
conversations=conversations,
user_ids=["user1", "user2"],
exact_match=False,
)
assert response == [
{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]},
]
def test_filter_conversations_by_user_ids_empty_response():
conversations = [
{"id": "123", "members": [{"id": "user1"}, {"id": "user3"}, {"id": "user4"}]},
{"id": "456", "members": [{"id": "user2"}, {"id": "user3"}, {"id": "user4"}]},
]
response = filter_conversations_by_user_ids(
conversations=conversations,
user_ids=["user1", "user2"],
exact_match=False,
)
assert response == []
def test_filter_conversations_by_user_ids_multiple_matches():
conversations = [
{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]},
{"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]},
{
"id": "789",
"members": [{"id": "user4"}, {"id": "user1"}, {"id": "user2"}, {"id": "user3"}],
},
]
response = filter_conversations_by_user_ids(
conversations=conversations,
user_ids=["user1", "user2"],
exact_match=False,
)
assert response == [
{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]},
{
"id": "789",
"members": [{"id": "user4"}, {"id": "user1"}, {"id": "user2"}, {"id": "user3"}],
},
]
def test_filter_conversations_by_user_ids_exact_match():
conversations = [
{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}]},
{"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]},
]
response = filter_conversations_by_user_ids(
conversations=conversations,
user_ids=["user1", "user2"],
exact_match=True,
)
assert response == [{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}]}]
def test_filter_conversations_by_user_ids_exact_match_empty_response():
conversations = [
{"id": "123", "members": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]},
{"id": "456", "members": [{"id": "user2"}, {"id": "user3"}]},
]
response = filter_conversations_by_user_ids(
conversations=conversations,
user_ids=["user1", "user2"],
exact_match=True,
)
assert response == []
@pytest.mark.parametrize(
"users_by_email, users_by_username, expected_response",
[
(
{"users": [{"id": "U1", "name": "user1"}]},
{"users": [{"id": "U2", "name": "user2"}]},
[{"id": "U1", "name": "user1"}, {"id": "U2", "name": "user2"}],
),
(
{"users": [{"id": "U1", "name": "user1"}]},
{"users": []},
[{"id": "U1", "name": "user1"}],
),
(
{"users": []},
{"users": [{"id": "U2", "name": "user2"}]},
[{"id": "U2", "name": "user2"}],
),
(
{"users": []},
{"users": []},
[],
),
],
)
@pytest.mark.asyncio
async def test_build_multiple_users_retrieval_response_success(
users_by_email,
users_by_username,
expected_response,
mock_context,
):
response = await build_multiple_users_retrieval_response(
context=mock_context,
users_responses=[users_by_email, users_by_username],
)
assert response == expected_response
@pytest.mark.parametrize(
"users_by_email, users_by_username",
[
# Both emails and usernames not found
(
{
"users": [{"id": "U1", "name": "user1"}],
"not_found": ["email_not_found"],
},
{
"users": [{"id": "U2", "name": "user2"}],
"not_found": ["username_not_found"],
"available_users": [{"id": "U3", "name": "user3"}],
},
),
# Email not found, usernames found
(
{
"users": [{"id": "U1", "name": "user1"}],
"not_found": ["email_not_found"],
},
{
"users": [{"id": "U2", "name": "user2"}],
"not_found": [],
},
),
# Email found, username not found
(
{
"users": [{"id": "U1", "name": "user1"}],
"not_found": [],
},
{
"users": [{"id": "U2", "name": "user2"}],
"not_found": ["username_not_found"],
"available_users": [{"id": "U3", "name": "user3"}],
},
),
],
)
@pytest.mark.asyncio
async def test_build_multiple_users_retrieval_response_not_found(
users_by_email,
users_by_username,
mock_context,
):
with pytest.raises(RetryableToolError) as error:
await build_multiple_users_retrieval_response(
context=mock_context,
users_responses=[users_by_email, users_by_username],
)
emails_not_found = users_by_email.get("not_found", [])
usernames_not_found = users_by_username.get("not_found", [])
available_users = users_by_username.get("available_users", [])
for email in emails_not_found:
assert email in error.value.message
for username in usernames_not_found:
assert username in error.value.message
for user in available_users:
assert json.dumps(user) in error.value.additional_prompt_content
def test_is_valid_email():
assert is_valid_email("test@example.com")
assert is_valid_email("test+123@example.com")
assert is_valid_email("test-123@example.com")
assert is_valid_email("test_123@example.com")
assert is_valid_email("test.123@example.com")
assert is_valid_email("test123@example.com")
assert is_valid_email("test@example.co")
assert is_valid_email("test@example.com.co")
assert not is_valid_email("test123@example")
assert not is_valid_email("test@example")
assert not is_valid_email("test@example.")
assert not is_valid_email("test@.com")
assert not is_valid_email("test@example.c")
assert not is_valid_email("test@example.com.")
assert not is_valid_email("test@example.com.c")
@pytest.mark.asyncio
async def test_gather_with_concurrency_limit():
mock_func1 = AsyncMock()
mock_func2 = AsyncMock()
caller1 = ConcurrencySafeCoroutineCaller(mock_func1, "arg1", "arg2", kwarg1="kwarg1")
caller2 = ConcurrencySafeCoroutineCaller(mock_func2, "arg1", "arg2", kwarg1="kwarg1")
mock_semaphore = AsyncMock(spec=asyncio.Semaphore)
response = await gather_with_concurrency_limit(
coroutine_callers=[caller1, caller2],
semaphore=mock_semaphore,
)
response = tuple(response)
assert len(response) == 2
assert response[0] == mock_func1.return_value
assert response[1] == mock_func2.return_value
mock_func1.assert_awaited_once_with("arg1", "arg2", kwarg1="kwarg1")
mock_func2.assert_awaited_once_with("arg1", "arg2", kwarg1="kwarg1")
assert mock_semaphore.__aenter__.await_count == 2
assert mock_semaphore.__aexit__.await_count == 2
@pytest.mark.asyncio
async def test_populate_users_in_messages(
mock_context,
mock_user_retrieval_slack_client,
dummy_message_factory,
dummy_reaction_factory,
dummy_user_factory,
):
user1 = dummy_user_factory(id_="U1", name="user1")
user2 = dummy_user_factory(id_="U2", name="user2")
user3 = dummy_user_factory(id_="U3", name="user3")
user4 = dummy_user_factory(id_="U4", name="user4")
user5 = dummy_user_factory(id_="U5", name="user5")
user1_short = {"id": user1["id"], "name": user1["name"]}
user2_short = {"id": user2["id"], "name": user2["name"]}
user3_short = {"id": user3["id"], "name": user3["name"]}
user4_short = {"id": user4["id"], "name": user4["name"]}
user2_mention = f"<@{user2['name']} (id:{user2['id']})>"
user5_mention = f"<@{user5['name']} (id:{user5['id']})>"
reactions = [
dummy_reaction_factory(name="thumbsup", user_ids=[user1["id"], user2["id"]]),
dummy_reaction_factory(name="partyparrot", user_ids=[user3["id"], user4["id"]]),
]
messages = [
dummy_message_factory(
user_id=user1["id"],
text=f"Hello <@{user2['id']}>",
),
dummy_message_factory(
user_id=user2["id"],
text="foobar",
reactions=copy.deepcopy(reactions[:1]),
),
dummy_message_factory(
user_id=user3["id"],
text=f"Is this @{user5['id']} a user mention?",
),
dummy_message_factory(
user_id=user4["id"],
text="hello",
reactions=copy.deepcopy(reactions),
),
]
mock_user_retrieval_slack_client.users_list.side_effect = [
{
"ok": True,
"members": [user1, user2, user3],
"response_metadata": {"next_cursor": "cursor1"},
},
{
"ok": True,
"members": [user4, user5],
"response_metadata": {"next_cursor": None},
},
]
response = await populate_users_in_messages(
auth_token=mock_context.get_auth_token_or_empty(),
messages=messages,
)
msg1 = response[0]
msg2 = response[1]
msg3 = response[2]
msg4 = response[3]
assert msg1["user"] == user1_short
assert msg1["text"] == f"Hello {user2_mention}"
assert "reactions" not in msg1
assert msg2["user"] == user2_short
assert msg2["text"] == "foobar"
assert "reactions" in msg2
assert len(msg2["reactions"]) == 1
assert msg2["reactions"][0]["name"] == "thumbsup"
assert msg2["reactions"][0]["users"] == [user1_short, user2_short]
assert msg3["user"] == user3_short
assert msg3["text"] == f"Is this @{user5['id']} a user mention?"
assert "reactions" not in msg3
assert user5_mention not in msg3["text"]
assert msg4["user"] == user4_short
assert msg4["text"] == "hello"
assert "reactions" in msg4
assert len(msg4["reactions"]) == 2
assert msg4["reactions"][0]["name"] == "thumbsup"
assert msg4["reactions"][0]["users"] == [user1_short, user2_short]
assert msg4["reactions"][1]["name"] == "partyparrot"
assert msg4["reactions"][1]["users"] == [user3_short, user4_short]