from __future__ import annotations import pytest from yuxi.channels.models import ( Attachment, ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, EventType, HealthStatus, MessageType, ) @pytest.fixture def adapter(): from yuxi.channels.adapters.zalo_user.adapter import ZaloUserAdapter return ZaloUserAdapter({ "bridge_url": "http://localhost:5556", "auth_type": "qr", }) class TestRateLimiter: def test_check_and_wait_within_limit(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter limiter = RateLimiter(max_per_minute=3) limiter._send_times.append(100.0) assert len(limiter._send_times) == 1 def test_cleanup_removes_old_entries(self): import time from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter limiter = RateLimiter(max_per_minute=5) now = time.monotonic() limiter._send_times.extend([now - 120, now - 90, now - 30]) limiter._cleanup() assert len(limiter._send_times) == 1 assert limiter._send_times[0] == now - 30 class TestNormalizeInbound: def test_direct_message(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", "message_id": "msg_001", "text": "Hello Zalo", "conversation_type": "direct", "from_display_name": "Test User", "timestamp": 1700000000000, }) assert result.identity.channel_id == "zalo_user" assert result.identity.channel_type == ChannelType.ZALO_USER assert result.identity.channel_user_id == "12345" assert result.identity.channel_chat_id == "conv_abc" assert result.identity.channel_message_id == "msg_001" assert result.chat_type == ChatType.DIRECT assert result.content == "Hello Zalo" assert result.metadata["zalo_chat_type"] == "direct" assert result.metadata["from_display_name"] == "Test User" assert result.timestamp is not None def test_group_message(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "67890", "conversation_id": "group_xyz", "message_id": "msg_002", "text": "Hello Group", "conversation_type": "group", "group_id": "g_001", "from_display_name": "Group User", "timestamp": 1700000000000, }) assert result.chat_type == ChatType.GROUP assert result.metadata["zalo_chat_type"] == "group" assert result.metadata["group_id"] == "g_001" def test_message_with_attachments(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", "message_id": "msg_003", "conversation_type": "direct", "timestamp": 1700000000000, "attachments": [ {"type": "image", "url": "http://example.com/img.jpg", "filename": "photo.jpg"}, {"type": "video", "url": "http://example.com/vid.mp4"}, ], }) assert len(result.attachments) == 2 assert result.attachments[0].type == "image" assert result.attachments[0].url == "http://example.com/img.jpg" assert result.attachments[0].filename == "photo.jpg" assert result.attachments[1].type == "video" def test_missing_required_fields_raises_error(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound from yuxi.channels.exceptions import MessageFormatError with pytest.raises(MessageFormatError): normalize_inbound("zalo_user", {"text": "no from_id"}) with pytest.raises(MessageFormatError): normalize_inbound("zalo_user", {"from_id": "123", "text": "no conv_id"}) def test_timestamp_defaults_when_missing(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", }) assert result.timestamp is not None def test_timestamp_zero_uses_default(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", "timestamp": 0, }) assert result.timestamp is not None def test_command_message_type(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", "text": "/start", "command": True, "timestamp": 1700000000000, }) assert result.message_type == MessageType.COMMAND def test_reply_to_message(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", "quote_message_id": "original_msg", "timestamp": 1700000000000, }) assert result.reply_to_message_id == "original_msg" class TestSessionPolicies: def test_check_dm_policy_open(self): from yuxi.channels.adapters.zalo_user.session import check_dm_policy assert check_dm_policy("user_1", {"dm_policy": "open"}, set()) is True def test_check_dm_policy_disabled(self): from yuxi.channels.adapters.zalo_user.session import check_dm_policy assert check_dm_policy("user_1", {"dm_policy": "disabled"}, set()) is False def test_check_dm_policy_pairing_matched(self): from yuxi.channels.adapters.zalo_user.session import check_dm_policy assert check_dm_policy("user_1", {"dm_policy": "pairing"}, {"zalo:user_1"}) is True def test_check_dm_policy_pairing_not_matched(self): from yuxi.channels.adapters.zalo_user.session import check_dm_policy assert check_dm_policy("user_1", {"dm_policy": "pairing"}, set()) is False def test_check_dm_policy_allowlist_matched(self): from yuxi.channels.adapters.zalo_user.session import check_dm_policy assert check_dm_policy("user_1", { "dm_policy": "allowlist", "allow_from": ["zalo:user_1"], }, set()) is True def test_check_group_policy_open(self): from yuxi.channels.adapters.zalo_user.session import check_group_policy assert check_group_policy("chat_1", "user_1", {"group_policy": "open"}) is True def test_check_group_policy_disabled(self): from yuxi.channels.adapters.zalo_user.session import check_group_policy assert check_group_policy("chat_1", "user_1", {"group_policy": "disabled"}) is False def test_check_mention_required_no_mention_needed(self): from yuxi.channels.adapters.zalo_user.session import check_mention_required assert check_mention_required("chat_1", "hello", {}, "") is True def test_check_mention_required_with_bot_mention(self): from yuxi.channels.adapters.zalo_user.session import check_mention_required config = { "groups": { "chat_1": {"require_mention": True}, }, } assert check_mention_required("chat_1", "hello @MyBot", config, "MyBot") is True def test_check_mention_required_without_bot_mention(self): from yuxi.channels.adapters.zalo_user.session import check_mention_required config = { "groups": { "chat_1": {"require_mention": True}, }, } assert check_mention_required("chat_1", "hello", config, "MyBot") is False def test_resolve_agent_route_direct(self): from yuxi.channels.adapters.zalo_user.session import resolve_agent_route route = resolve_agent_route("zalo_user", "chat_1", "user_1", "direct", {"default_agent_id": "my_agent"}) assert route == "agent:my_agent:zalo_user:direct:user_1" def test_resolve_agent_route_group(self): from yuxi.channels.adapters.zalo_user.session import resolve_agent_route config = { "default_agent_id": "default", "groups": { "chat_1": {"agent_id": "group_agent"}, }, } route = resolve_agent_route("zalo_user", "chat_1", "user_1", "group", config) assert route == "agent:group_agent:zalo_user:group:chat_1" class TestBuildSendPayload: def test_text_message(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="Hello World", ) payload = _build_send_payload(response, {}) assert payload["text"] == "Hello World" assert payload["message_type"] == "text" assert "attachments" not in payload def test_image_message(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.IMAGE, content="Check this image", attachments=[Attachment(type="image", url="http://example.com/img.jpg")], ) payload = _build_send_payload(response, {}) assert payload["attachments"] == [{"type": "image", "url": "http://example.com/img.jpg"}] def test_file_message_with_filename(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.FILE, content="Here is a file", attachments=[Attachment(type="file", url="http://example.com/doc.pdf", filename="doc.pdf")], ) payload = _build_send_payload(response, {}) assert payload["attachments"][0]["filename"] == "doc.pdf" def test_reply_to_message(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="Reply", reply_to_message_id="original_msg", ) payload = _build_send_payload(response, {}) assert payload["quote_message_id"] == "original_msg" def test_reply_mode_off_skips_quote(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="Reply", reply_to_message_id="original_msg", ) payload = _build_send_payload(response, {"reply_to_mode": "off"}) assert "quote_message_id" not in payload def test_group_mention(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="Hello", metadata={"chat_type": "group", "mention_user_ids": ["user_1", "user_2"]}, ) payload = _build_send_payload(response, {}) assert payload["mention_user_ids"] == ["user_1", "user_2"] class TestBridgeClientWebSocketUrl: def test_http_to_ws(self): from yuxi.channels.adapters.zalo_user.bridge import _ws_url_from_http assert _ws_url_from_http("http://localhost:5556") == "ws://localhost:5556/messages/stream" def test_https_to_wss(self): from yuxi.channels.adapters.zalo_user.bridge import _ws_url_from_http assert _ws_url_from_http("https://bridge.example.com") == "wss://bridge.example.com/messages/stream" def test_http_with_port(self): from yuxi.channels.adapters.zalo_user.bridge import _ws_url_from_http assert _ws_url_from_http("http://192.168.1.1:8080") == "ws://192.168.1.1:8080/messages/stream" class TestAdapterMethods: @pytest.mark.asyncio async def test_edit_message_not_connected(self, adapter): result = await adapter.edit_message("chat_1", "msg_1", "new text") assert result.success is False assert "Not connected" in result.error @pytest.mark.asyncio async def test_delete_message_not_connected(self, adapter): result = await adapter.delete_message("chat_1", "msg_1") assert result.success is False @pytest.mark.asyncio async def test_send_reaction_not_connected(self, adapter): result = await adapter.send_reaction("chat_1", "msg_1", "\U0001f44d") assert result.success is False @pytest.mark.asyncio async def test_send_media_not_connected(self, adapter): result = await adapter.send_media("chat_1", "image", "http://example.com/img.jpg") assert result.success is False @pytest.mark.asyncio async def test_download_media_not_connected(self, adapter): from yuxi.channels.exceptions import ChannelConnectionError with pytest.raises(ChannelConnectionError): await adapter.download_media("file_123") @pytest.mark.asyncio async def test_get_user_info_no_bridge(self, adapter): result = await adapter.get_user_info("user_123") assert result["id"] == "user_123" assert result["display_name"] == "user_123" @pytest.mark.asyncio async def test_health_check_no_bridge(self, adapter): result = await adapter.health_check() assert result.status == "unhealthy" assert "Bridge not initialized" in result.last_error class TestChunking: def test_short_text_is_unchanged(self): from yuxi.channels.adapters.zalo_user.chunking import chunk_text result = chunk_text("Hello World", 2000) assert result == ["Hello World"] def test_text_at_limit_is_unchanged(self): from yuxi.channels.adapters.zalo_user.chunking import chunk_text text = "a" * 2000 result = chunk_text(text, 2000) assert len(result) == 1 assert result[0] == text def test_long_text_is_chunked_at_paragraphs(self): from yuxi.channels.adapters.zalo_user.chunking import chunk_text para = "a" * 300 text = "\n\n".join([para] * 10) result = chunk_text(text, 2000) assert len(result) > 1 for chunk in result: assert len(chunk) <= 2000 def test_long_paragraph_is_chunked_at_sentence(self): from yuxi.channels.adapters.zalo_user.chunking import chunk_text sentence = "abc. " * 600 result = chunk_text(sentence, 2000) assert len(result) > 1 for chunk in result: assert len(chunk) <= 2000 def test_no_sentence_boundary_chunks_at_space(self): from yuxi.channels.adapters.zalo_user.chunking import chunk_text text = "x" * 3000 result = chunk_text(text, 500) assert len(result) > 1 for chunk in result: assert len(chunk) <= 500 class TestChunkResponseContent: def test_text_within_limit_returns_empty(self): from yuxi.channels.adapters.zalo_user.send import _chunk_response_content response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="Short message", ) result = _chunk_response_content(response, 2000) assert result == [] def test_text_over_limit_returns_chunks(self): from yuxi.channels.adapters.zalo_user.send import _chunk_response_content paragraph = "Lorem ipsum dolor sit amet.\n" * 50 content = paragraph * 30 assert len(content) > 2000 response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content=content, ) result = _chunk_response_content(response, 2000) assert len(result) >= 2 for chunk_payload in result: assert len(chunk_payload["text"]) <= 2000 assert chunk_payload["message_type"] == "text" assert chunk_payload["conversation_id"] == "conv_abc" def test_image_message_not_chunked(self): from yuxi.channels.adapters.zalo_user.send import _chunk_response_content response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.IMAGE, content="x" * 3000, attachments=[Attachment(type="image", url="http://example.com/img.jpg")], ) result = _chunk_response_content(response, 2000) assert result == [] def test_first_chunk_has_reply_to(self): from yuxi.channels.adapters.zalo_user.send import _chunk_response_content response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="x" * 5000, reply_to_message_id="original_msg", ) result = _chunk_response_content(response, 2000) assert len(result) >= 3 assert result[0].get("quote_message_id") == "original_msg" assert "quote_message_id" not in result[1] class TestPairedUsersAutoTracking: def test_dm_user_auto_added_to_paired_users(self, adapter): user_id = "new_user_123" paired_key = f"zalo:{user_id}" assert paired_key not in adapter._paired_users message = ChannelMessage( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id=user_id, channel_chat_id="dm_chat", ), event_type=EventType.MESSAGE_RECEIVED, message_type=MessageType.TEXT, chat_type=ChatType.DIRECT, content="Hello bot", metadata={"zalo_chat_type": "direct"}, ) pytest.run_sync(adapter._handle_message(message)) assert paired_key in adapter._paired_users def test_dm_disabled_policy_blocks_message(self, adapter): adapter.config["dm_policy"] = "disabled" user_id = "blocked_user" paired_key = f"zalo:{user_id}" message = ChannelMessage( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id=user_id, channel_chat_id="dm_chat", ), event_type=EventType.MESSAGE_RECEIVED, message_type=MessageType.TEXT, chat_type=ChatType.DIRECT, content="Hello", metadata={"zalo_chat_type": "direct"}, ) adapter._message_handler = None pytest.run_sync(adapter._handle_message(message)) assert paired_key in adapter._paired_users def test_group_message_does_not_add_to_paired(self, adapter): user_id = "group_user" paired_key = f"zalo:{user_id}" adapter.config["group_policy"] = "open" message = ChannelMessage( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id=user_id, channel_chat_id="group_chat", channel_message_id="msg_001", ), event_type=EventType.MESSAGE_RECEIVED, message_type=MessageType.TEXT, chat_type=ChatType.GROUP, content="Hello group", metadata={"zalo_chat_type": "group", "group_id": "g_001"}, ) adapter._message_handler = None pytest.run_sync(adapter._handle_message(message)) assert paired_key not in adapter._paired_users class TestDisconnectResilience: @pytest.mark.asyncio async def test_disconnect_when_already_disconnected(self, adapter): adapter._status = ChannelStatus.DISCONNECTED await adapter.disconnect() assert adapter._status == ChannelStatus.DISCONNECTED @pytest.mark.asyncio async def test_disconnect_with_no_monitor_or_bridge(self, adapter): adapter._status = ChannelStatus.CONNECTED adapter._monitor = None adapter._bridge = None await adapter.disconnect() assert adapter._status == ChannelStatus.DISCONNECTED @pytest.mark.asyncio async def test_disconnect_with_failing_monitor(self, adapter): class _FailingMonitor: async def stop(self): raise RuntimeError("stop failed") adapter._status = ChannelStatus.CONNECTED adapter._monitor = _FailingMonitor() adapter._bridge = None await adapter.disconnect() assert adapter._status == ChannelStatus.DISCONNECTED assert adapter._monitor is None @pytest.mark.asyncio async def test_disconnect_with_failing_bridge(self, adapter): class _FailingBridge: async def stop(self): raise RuntimeError("stop failed") adapter._status = ChannelStatus.CONNECTED adapter._monitor = None adapter._bridge = _FailingBridge() await adapter.disconnect() assert adapter._status == ChannelStatus.DISCONNECTED assert adapter._bridge is None def _run_sync(coro): import asyncio try: loop = asyncio.get_running_loop() except RuntimeError: return asyncio.run(coro) import concurrent.futures import threading future = concurrent.futures.Future() def _run(): try: result = asyncio.run(coro) future.set_result(result) except Exception as e: future.set_exception(e) threading.Thread(target=_run, daemon=True).start() return future.result() pytest.run_sync = _run_sync