from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.models import DeliveryResult class TestMessageActionAdapterWithAdapter: def test_get_handler_returns_none_without_adapter(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter adapter = WeChatMessageActionAdapter() assert adapter.get_handler("send") is None assert adapter.get_handler("reply") is None assert adapter.get_handler("react") is None def test_get_handler_returns_callable_with_adapter(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_wechat = MagicMock() mock_wechat.channel_id = "wechat" mock_wechat.channel_type = "wechat" action_adapter = WeChatMessageActionAdapter(mock_wechat) handler = action_adapter.get_handler("send") assert handler is not None assert callable(handler) handler = action_adapter.get_handler("reply") assert handler is not None assert callable(handler) handler = action_adapter.get_handler("sendAttachment") assert handler is not None assert callable(handler) def test_get_handler_returns_none_for_unsupported_actions(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_wechat = MagicMock() mock_wechat.channel_id = "wechat" mock_wechat.channel_type = "wechat" action_adapter = WeChatMessageActionAdapter(mock_wechat) assert action_adapter.get_handler("react") is None assert action_adapter.get_handler("edit") is None assert action_adapter.get_handler("unsend") is None assert action_adapter.get_handler("delete") is None def test_is_supported_works_without_adapter(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter assert WeChatMessageActionAdapter.is_supported("send") is True assert WeChatMessageActionAdapter.is_supported("reply") is True assert WeChatMessageActionAdapter.is_supported("react") is False assert WeChatMessageActionAdapter.is_supported("edit") is False @pytest.mark.asyncio async def test_send_handler_calls_adapter_send(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_wechat = MagicMock() mock_wechat.channel_id = "wechat" mock_wechat.channel_type = "wechat" mock_wechat.send = AsyncMock(return_value=DeliveryResult(success=True)) action_adapter = WeChatMessageActionAdapter(mock_wechat) handler = action_adapter.get_handler("send") result = await handler(chat_id="test_chat", content="Hello World", channel_user_id="user_123") assert result["success"] is True mock_wechat.send.assert_called_once() @pytest.mark.asyncio async def test_send_handler_fails_without_chat_id(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_wechat = MagicMock() mock_wechat.channel_id = "wechat" mock_wechat.channel_type = "wechat" action_adapter = WeChatMessageActionAdapter(mock_wechat) handler = action_adapter.get_handler("send") result = await handler(content="Hello World") assert result["success"] is False assert "missing chat_id" in result["error"] @pytest.mark.asyncio async def test_reply_handler_calls_adapter_send_with_reply_metadata(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_wechat = MagicMock() mock_wechat.channel_id = "wechat" mock_wechat.channel_type = "wechat" mock_wechat.send = AsyncMock(return_value=DeliveryResult(success=True)) action_adapter = WeChatMessageActionAdapter(mock_wechat) handler = action_adapter.get_handler("reply") result = await handler( chat_id="test_chat", content="Reply message", channel_user_id="user_123", reply_to_msg_id="msg_456", reply_to_channel_user_id="sender_789", chat_type="direct", ) assert result["success"] is True mock_wechat.send.assert_called_once() @pytest.mark.asyncio async def test_download_file_handler_calls_download_media(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_data = b"fake_media_data" mock_wechat = MagicMock() mock_wechat.channel_id = "wechat" mock_wechat.channel_type = "wechat" mock_wechat.download_media = AsyncMock(return_value=mock_data) action_adapter = WeChatMessageActionAdapter(mock_wechat) handler = action_adapter.get_handler("download-file") result = await handler(file_id="media_123") assert result["success"] is True assert result["data"] == mock_data @pytest.mark.asyncio async def test_download_file_handler_fails_without_file_id(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_wechat = MagicMock() action_adapter = WeChatMessageActionAdapter(mock_wechat) handler = action_adapter.get_handler("download-file") result = await handler() assert result["success"] is False @pytest.mark.asyncio async def test_read_handler_calls_read_message(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter mock_wechat = MagicMock() mock_wechat.channel_id = "wechat" mock_wechat.channel_type = "wechat" mock_wechat.read_message = AsyncMock(return_value={"msg_id": "123", "available": True}) action_adapter = WeChatMessageActionAdapter(mock_wechat) handler = action_adapter.get_handler("read") result = await handler(msg_id="123") assert result["available"] is True def test_list_supported_actions_static(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter supported = WeChatMessageActionAdapter.list_supported_actions() assert "send" in supported assert "reply" in supported assert "read" in supported assert "react" not in supported assert "edit" not in supported def test_list_all_actions_static(self): from yuxi.channels.adapters.wechat.message_actions import WeChatMessageActionAdapter all_actions = WeChatMessageActionAdapter.list_all_actions() assert len(all_actions) == 56 assert "send" in all_actions assert "react" in all_actions class TestWeChatHandleAction: @pytest.mark.asyncio async def test_handle_action_delegates_to_get_handler(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) adapter._mode = "wecom" mock_client = MagicMock() mock_client.get_access_token = AsyncMock(return_value="test_token") adapter._wecom_client = mock_client adapter._http_client = AsyncMock() from yuxi.channels.adapters.wechat.wecom import WeComMonitor class FakeMonitor: async def start(self, cb): pass async def stop(self): pass adapter._monitor = FakeMonitor() adapter._message_actions = MagicMock() mock_handler = AsyncMock(return_value={"success": True}) adapter._message_actions.get_handler.return_value = mock_handler adapter._message_actions.is_supported.return_value = True from yuxi.channels.protocols.actions import ActionContext ctx = ActionContext( action="send", channel_id="wechat", chat_id="test_chat", msg_id="msg_001", args={"content": "Hello", "channel_user_id": "user_1"}, ) result = await adapter.handle_action(ctx) assert result.success is True adapter._message_actions.get_handler.assert_called_once_with("send") mock_handler.assert_called_once() @pytest.mark.asyncio async def test_handle_action_returns_error_for_unsupported_action(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) adapter._message_actions = MagicMock() adapter._message_actions.get_handler.return_value = None adapter._message_actions.is_supported.return_value = False from yuxi.channels.protocols.actions import ActionContext ctx = ActionContext( action="react", channel_id="wechat", chat_id="test_chat", msg_id="msg_001", args={"emoji": "👍"}, ) result = await adapter.handle_action(ctx) assert result.success is False assert "not supported" in result.error def test_supports_action(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) adapter._message_actions = MagicMock() adapter._message_actions.is_supported.side_effect = lambda action: action == "send" assert adapter.supports_action("send") is True assert adapter.supports_action("react") is False def test_resolve_execution_mode(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) assert adapter.resolve_execution_mode("send") == "direct" @pytest.mark.asyncio async def test_handle_action_handler_exception(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) adapter._message_actions = MagicMock() async def failing_handler(**kwargs): raise RuntimeError("boom") adapter._message_actions.get_handler.return_value = failing_handler adapter._message_actions.is_supported.return_value = True from yuxi.channels.protocols.actions import ActionContext ctx = ActionContext( action="send", channel_id="wechat", chat_id="test_chat", msg_id="msg_001", args={}, ) result = await adapter.handle_action(ctx) assert result.success is False assert "boom" in result.error class TestWeChatAuthAdapter: def test_get_dm_exposure(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() assert auth.get_dm_exposure({}) == "pairing" assert auth.get_dm_exposure({"dm_policy": "allowlist"}) == "allowlist" assert auth.get_dm_exposure({"dm_policy": "open"}) == "open" def test_get_authorization_url_with_config(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() assert auth.get_authorization_url({}) is None assert auth.get_authorization_url({"wecom_auth_url": "https://example.com/auth"}) == "https://example.com/auth" def test_build_wecom_oauth_url(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() url = auth.build_wecom_oauth_url("corp_123", "https://myapp.com/callback", state="state_456") assert "open.weixin.qq.com" in url assert "appid=corp_123" in url assert "redirect_uri=https://myapp.com/callback" in url assert "state=state_456" in url assert url.endswith("#wechat_redirect") def test_build_wecom_oauth_url_without_state(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() url = auth.build_wecom_oauth_url("corp_123", "https://myapp.com/callback") assert "state" not in url or url.endswith("#wechat_redirect") def test_build_mp_oauth_url(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() url = auth.build_mp_oauth_url("wx_app_123", "https://myapp.com/callback", state="state_789", scope="snsapi_userinfo") assert "open.weixin.qq.com" in url assert "appid=wx_app_123" in url assert "scope=snsapi_userinfo" in url assert "state=state_789" in url def test_build_mp_oauth_url_default_scope(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() url = auth.build_mp_oauth_url("wx_app_123", "https://myapp.com/callback") assert "scope=snsapi_userinfo" in url def test_get_oauth_enabled(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() assert auth.get_oauth_enabled({}) is False assert auth.get_oauth_enabled({"wecom_oauth_enabled": True}) is True assert auth.get_oauth_enabled({"mp_oauth_enabled": True}) is True @pytest.mark.asyncio async def test_exchange_wecom_code_success(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client_cls.return_value.__aenter__.return_value = mock_client token_resp = MagicMock() token_resp.json.return_value = {"access_token": "token_abc", "expires_in": 7200} user_resp = MagicMock() user_resp.json.return_value = {"errcode": 0, "UserId": "user_123"} mock_client.get.side_effect = [token_resp, user_resp] result = await auth.exchange_wecom_code("auth_code", "corp_123", "corp_secret_456") assert result["success"] is True assert result["access_token"] == "token_abc" assert result["user_info"]["UserId"] == "user_123" @pytest.mark.asyncio async def test_exchange_wecom_code_token_failure(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client_cls.return_value.__aenter__.return_value = mock_client token_resp = MagicMock() token_resp.json.return_value = {"errcode": 40001, "errmsg": "invalid credential"} mock_client.get.return_value = token_resp result = await auth.exchange_wecom_code("auth_code", "corp_123", "corp_secret_456") assert result["success"] is False @pytest.mark.asyncio async def test_exchange_mp_code_success(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client_cls.return_value.__aenter__.return_value = mock_client token_resp = MagicMock() token_resp.json.return_value = { "access_token": "mp_token_abc", "openid": "openid_123", "refresh_token": "refresh_xyz", } userinfo_resp = MagicMock() userinfo_resp.json.return_value = {"openid": "openid_123", "nickname": "TestUser"} mock_client.get.side_effect = [token_resp, userinfo_resp] result = await auth.exchange_mp_code("auth_code", "wx_app_123", "app_secret_456") assert result["success"] is True assert result["access_token"] == "mp_token_abc" assert result["openid"] == "openid_123" assert result["refresh_token"] == "refresh_xyz" assert result["user_info"]["nickname"] == "TestUser" @pytest.mark.asyncio async def test_exchange_mp_code_failure(self): from yuxi.channels.adapters.wechat.auth_adapter import WeChatAuthAdapter auth = WeChatAuthAdapter() with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client_cls.return_value.__aenter__.return_value = mock_client token_resp = MagicMock() token_resp.json.return_value = {"errcode": 40029, "errmsg": "invalid code"} mock_client.get.return_value = token_resp result = await auth.exchange_mp_code("bad_code", "wx_app_123", "app_secret_456") assert result["success"] is False class TestWeChatSubAccount: def test_sub_account_tracking_fields_in_init(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) assert isinstance(adapter._sub_monitors, dict) assert len(adapter._sub_monitors) == 0 assert isinstance(adapter._sub_clients, dict) assert len(adapter._sub_clients) == 0 assert isinstance(adapter._sub_polling_tasks, dict) assert len(adapter._sub_polling_tasks) == 0 @pytest.mark.asyncio async def test_connect_sub_accounts_skips_when_only_default(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) adapter._account_mgr = MagicMock() adapter._account_mgr.list_account_ids.return_value = ["default"] await adapter._connect_sub_accounts() assert len(adapter._sub_monitors) == 0 @pytest.mark.asyncio async def test_connect_sub_accounts_skips_disabled(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={}) adapter._account_mgr = MagicMock() adapter._account_mgr.list_account_ids.return_value = ["default", "sub1"] mock_account = MagicMock() mock_account.enabled = False mock_account.mode = "wecom" adapter._account_mgr.resolve_account.return_value = mock_account await adapter._connect_sub_accounts() assert len(adapter._sub_monitors) == 0 @pytest.mark.asyncio async def test_connect_sub_accounts_respects_limit(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter(config={"max_sub_accounts": 2}) adapter._account_mgr = MagicMock() adapter._account_mgr.list_account_ids.return_value = [ "default", "sub1", "sub2", "sub3", "sub4" ] mock_account = MagicMock() mock_account.enabled = True mock_account.mode = "mp" adapter._account_mgr.resolve_account.return_value = mock_account await adapter._connect_sub_accounts() adapter._account_mgr.resolve_account.assert_called() call_count = adapter._account_mgr.resolve_account.call_count assert call_count <= 2