from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from yuxi.channels.models import ( ChannelIdentity, ChannelResponse, ChannelType, DeliveryResult, ) class TestTokenBucketRateLimiter: def test_acquires_tokens_and_depletes(self): from yuxi.channels.adapters.wechat.rate_limiter import TokenBucketRateLimiter limiter = TokenBucketRateLimiter(rate=5, per=60.0) for _ in range(5): assert limiter.acquire_sync() is True assert limiter.acquire_sync() is False def test_available_tokens_reflects_remaining(self): from yuxi.channels.adapters.wechat.rate_limiter import TokenBucketRateLimiter limiter = TokenBucketRateLimiter(rate=5, per=60.0) assert limiter.available_tokens == 5.0 limiter.acquire_sync() assert limiter.available_tokens == 4.0 @pytest.mark.asyncio async def test_acquire_replenishes_over_time(self): from yuxi.channels.adapters.wechat.rate_limiter import TokenBucketRateLimiter limiter = TokenBucketRateLimiter(rate=5, per=0.2) for _ in range(5): assert await limiter.acquire() is True assert await limiter.acquire() is False await asyncio.sleep(0.25) assert await limiter.acquire() is True @pytest.mark.asyncio async def test_acquire_partial_refill(self): from yuxi.channels.adapters.wechat.rate_limiter import TokenBucketRateLimiter limiter = TokenBucketRateLimiter(rate=5, per=0.5) for _ in range(5): assert await limiter.acquire() is True assert await limiter.acquire() is False await asyncio.sleep(0.3) assert await limiter.acquire() is True @pytest.mark.asyncio async def test_never_exceeds_max_tokens(self): from yuxi.channels.adapters.wechat.rate_limiter import TokenBucketRateLimiter limiter = TokenBucketRateLimiter(rate=5, per=60.0) await asyncio.sleep(0.01) assert limiter.available_tokens <= 5.0 @pytest.mark.asyncio async def test_rate_property(self): from yuxi.channels.adapters.wechat.rate_limiter import TokenBucketRateLimiter limiter = TokenBucketRateLimiter(rate=20, per=60.0) assert limiter.rate == 20 assert limiter.period == 60.0 class TestMessageDedup: def test_first_message_not_duplicate(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "wecom" response = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="user1", channel_chat_id="user1", ), content="Hello", ) assert adapter._is_wecom_duplicate(response) is False def test_duplicate_within_window(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "wecom" response = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="user1", channel_chat_id="user1", ), content="Duplicate content", ) assert adapter._is_wecom_duplicate(response) is False assert adapter._is_wecom_duplicate(response) is True def test_different_users_not_duplicate(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "wecom" r1 = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="user1", channel_chat_id="user1", ), content="Same content", ) r2 = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="user2", channel_chat_id="user2", ), content="Same content", ) assert adapter._is_wecom_duplicate(r1) is False assert adapter._is_wecom_duplicate(r2) is False def test_different_content_not_duplicate(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "wecom" r1 = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="user1", channel_chat_id="user1", ), content="Content A", ) r2 = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="user1", channel_chat_id="user1", ), content="Content B", ) assert adapter._is_wecom_duplicate(r1) is False assert adapter._is_wecom_duplicate(r2) is False def test_ttl_cleanup(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "wecom" import time adapter._dedup[("old_user", "old_content")] = time.monotonic() - 10 response = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="user1", channel_chat_id="user1", ), content="New", ) assert adapter._is_wecom_duplicate(response) is False assert ("old_user", "old_content") not in adapter._dedup class TestSendRateLimiting: @pytest.mark.asyncio async def test_send_blocked_by_rate_limit(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "wecom" for _ in range(20): adapter._rate_limiter.acquire_sync() response = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="u1", channel_chat_id="u1", ), content="test", ) result = await adapter.send(response) assert result.success is False assert "Rate limit exceeded" in result.error @pytest.mark.asyncio async def test_send_blocked_by_dedup(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "wecom" response = ChannelResponse( identity=ChannelIdentity( channel_id="wechat", channel_type=ChannelType.WECHAT, channel_user_id="u1", channel_chat_id="u1", ), content="dup test", ) adapter._is_wecom_duplicate(response) result = await adapter.send(response) assert result.success is False assert "Duplicate message" in result.error class TestBridgeMediaPayload: def test_build_image_payload(self): from yuxi.channels.adapters.wechat.bridge import build_bridge_image_payload payload = build_bridge_image_payload("chat1", b"\x89PNG_IMAGE_DATA", is_group=False) assert payload["chat_id"] == "chat1" assert payload["msg_type"] == 3 assert "image_data" in payload assert isinstance(payload["image_data"], str) def test_build_file_payload(self): from yuxi.channels.adapters.wechat.bridge import build_bridge_file_payload payload = build_bridge_file_payload("chat1", b"PDF_CONTENT", "doc.pdf", is_group=True) assert payload["chat_id"] == "chat1" assert payload["msg_type"] == 6 assert payload["is_group"] is True assert payload["filename"] == "doc.pdf" assert payload["size"] == 11 assert "file_data" in payload def test_image_payload_base64_encodes(self): from yuxi.channels.adapters.wechat.bridge import build_bridge_image_payload payload = build_bridge_image_payload("chat1", b"\x00\x01\x02") import base64 expected = base64.b64encode(b"\x00\x01\x02").decode("ascii") assert payload["image_data"] == expected class TestBridgeSendMediaAdapter: @pytest.mark.asyncio async def test_bridge_image_send(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "personal" adapter._bridge_url = "http://localhost:5555" adapter._http_client = AsyncMock() mock_bridge = MagicMock() mock_bridge.send_media_message = AsyncMock(return_value=DeliveryResult(success=True)) adapter._bridge_client = mock_bridge result = await adapter._send_bridge_media("image", b"\x89PNG", "chat1") assert result.success is True mock_bridge.send_media_message.assert_called_once() @pytest.mark.asyncio async def test_bridge_file_send(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "personal" adapter._bridge_url = "http://localhost:5555" adapter._http_client = AsyncMock() mock_bridge = MagicMock() mock_bridge.send_media_message = AsyncMock(return_value=DeliveryResult(success=True)) adapter._bridge_client = mock_bridge result = await adapter._send_bridge_media("file", b"CONTENT", "chat1") assert result.success is True @pytest.mark.asyncio async def test_bridge_media_not_bytes(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "personal" adapter._bridge_client = MagicMock() result = await adapter._send_bridge_media("image", "string_data", "chat1") assert result.success is False assert "raw bytes" in result.error @pytest.mark.asyncio async def test_bridge_unsupported_media_type(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "personal" adapter._bridge_client = MagicMock() result = await adapter._send_bridge_media("voice", b"data", "chat1") assert result.success is False assert "Unsupported media_type" in result.error @pytest.mark.asyncio async def test_send_media_routes_to_bridge(self): from yuxi.channels.adapters.wechat.adapter import WeChatAdapter adapter = WeChatAdapter({"bridge_url": "http://localhost:5555", "dm_policy": "open"}) adapter._mode = "personal" adapter._bridge_url = "http://localhost:5555" adapter._http_client = AsyncMock() mock_bridge = MagicMock() mock_bridge.send_media_message = AsyncMock(return_value=DeliveryResult(success=True)) adapter._bridge_client = mock_bridge result = await adapter.send_media("chat1", "image", b"\x89PNG") assert result.success is True