deer-flow/backend/tests/test_wecom_group_bindings.py
Hyeonsang Cho 61d76d9a6a
fix(channels): key WeCom group conversations by chatid, not the sender (#7794)
* fix(channels): key WeCom group conversations by chatid, not the sender

Group messages used the sender's userid as chat_id and topic_id, the same
key as the sender's private chat. Wherever no group connection kept them
apart (auth-disabled mode, require_bound_identity: false, legacy group
bindings), a member's private chat and every group they wrote in ran in one
DeerFlow thread. Groups are now keyed by chatid with one thread per member.
A group-scoped connection carries its sender-keyed thread over once; a
legacy binding, which spans every group of the bot, does not.

* docs(changelog): reference #7794 for the WeCom group conversation fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 21:36:19 +08:00

69 lines
3.6 KiB
Python

"""WeCom group binding names and legacy identity boundaries, without provider I/O."""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from app.channels.message_bus import InboundMessage, MessageBus
from app.channels.wecom import WeComChannel
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
@pytest_asyncio.fixture
async def repo(tmp_path):
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'wecom.db'}", sqlite_dir=str(tmp_path))
try:
yield ChannelConnectionRepository(get_session_factory())
finally:
await close_engine()
def inbound(group="wr-group", bot="bot-1"):
return InboundMessage(channel_name="wecom", chat_id=group, user_id="alice", text="group follow-up", topic_id="alice", metadata={"aibotid": bot, "chattype": "group", "chatid": group})
@pytest.mark.asyncio
@pytest.mark.parametrize("private_metadata", [{"aibotid": "bot-1", "chattype": "single"}, {"aibotid": "bot-1"}, {}])
async def test_private_binding_does_not_authorize_an_unbound_group(repo, private_metadata):
await repo.upsert_connection(owner_user_id="owner", provider="wecom", external_account_id="alice", workspace_id="bot-1", metadata=private_metadata)
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
message = await channel._attach_connection_identity(inbound())
assert message.connection_id is None
assert message.owner_user_id is None
@pytest.mark.asyncio
async def test_legacy_group_binding_retains_owner(repo):
# The thread it had under the sender key is not continued; see
# test_wecom_conversation_keys.py.
legacy = await repo.upsert_connection(owner_user_id="owner", provider="wecom", external_account_id="alice", workspace_id="bot-1", metadata={"aibotid": "bot-1", "chattype": "group"})
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
message = await channel._attach_connection_identity(inbound())
assert message.connection_id == legacy["id"]
assert message.owner_user_id == "owner"
other_bot = await channel._attach_connection_identity(inbound(bot="bot-2"))
assert other_bot.connection_id is None
await repo.disconnect_connection(connection_id=legacy["id"], owner_user_id="owner")
revoked = await channel._attach_connection_identity(inbound())
assert revoked.connection_id is None
@pytest.mark.asyncio
async def test_named_group_connect_records_a_human_label_without_exposing_chatid(repo):
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
channel._ws_client = SimpleNamespace(reply=AsyncMock(), reply_stream=AsyncMock())
for code, group, label in (("code-1", "wr-group-1", "Release team"), ("code-2", "wr-group-2", "Support team")):
await repo.create_oauth_state(owner_user_id="owner", provider="wecom", state=code, expires_at=datetime.now(UTC) + timedelta(minutes=5), metadata={"notification_group_name": label})
await channel._publish_ws_inbound(
{"body": {"aibotid": "bot-1", "chattype": "group", "chatid": group, "msgid": f"msg-{code}", "from": {"userid": "alice"}, "text": {"content": f"/connect {code}"}}},
f"/connect {code}",
)
rows = await repo.list_connections("owner")
assert len(rows) == 2
assert {row["workspace_name"] for row in rows} == {"Release team", "Support team"}
assert {row["workspace_id"] for row in rows} == {"bot-1:group:wr-group-1", "bot-1:group:wr-group-2"}