ForcePilot/backend/test/unit/channels/test_channels_zalo_user.py

669 lines
24 KiB
Python
Raw Normal View History

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