ForcePilot/backend/test/unit/channels/test_zalo_oa_adapter.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

1211 lines
44 KiB
Python

from __future__ import annotations
import json
import pytest
from yuxi.channels.adapters.zalo_oa.adapter import ZaloOAAdapter
from yuxi.channels.adapters.zalo_oa.client import ZaloOAClient
from yuxi.channels.adapters.zalo_oa.formatter import ZaloOAMessageFormatter
from yuxi.channels.adapters.zalo_oa.normalizer import SkipMessageError, ZaloOAEventNormalizer
from yuxi.channels.adapters.zalo_oa.session import resolve_thread_key
from yuxi.channels.adapters.zalo_oa.signature import verify_zalo_oa_signature
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
EventType,
MessageType,
)
def make_text_webhook(text="hello", user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_text",
"app_id": "test_app_id",
"sender": {"id": user_id, "display_name": "Test User"},
"recipient": {"id": oa_id},
"message": {"msg_id": "msg_abc123", "text": text},
"timestamp": "1715760000",
}
def make_image_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_image",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_img001",
"text": "",
"attachments": [
{
"type": "image",
"payload": {"id": "img_001", "url": "https://example.com/img.jpg"},
}
],
},
"timestamp": "1715760000",
}
def make_file_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_file",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_file001",
"attachments": [
{
"type": "file",
"payload": {"url": "https://example.com/doc.pdf", "name": "doc.pdf", "size": 1024},
}
],
},
"timestamp": "1715760000",
}
def make_follow_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "follow",
"app_id": "test_app_id",
"sender": {"id": user_id, "display_name": "New Follower"},
"recipient": {"id": oa_id},
"message": {},
"timestamp": "1715760000",
}
def make_unfollow_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "unfollow",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {},
"timestamp": "1715760000",
}
def make_sticker_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_sticker",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {"msg_id": "msg_stk001", "text": "", "sticker_id": "stk_123"},
"timestamp": "1715760000",
}
def make_link_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_link",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_link001",
"text": "check this out",
"attachments": [
{
"type": "link",
"payload": {
"url": "https://example.com/article",
"title": "Example Article",
"description": "An interesting article about something",
},
}
],
},
"timestamp": "1715760000",
}
def make_business_card_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_business_card",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_card001",
"attachments": [
{
"type": "business_card",
"payload": {"name": "Nguyen Van A", "phone": "0912345678"},
}
],
},
"timestamp": "1715760000",
}
def make_reaction_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "reaction",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_react001",
"reaction": {"type": "like", "message_id": "msg_target_001"},
},
"timestamp": "1715760000",
}
def make_read_receipt_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "read_receipt",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"read_message_id": "msg_abc123",
"read_timestamp": "1715760001",
},
"timestamp": "1715760000",
}
def make_location_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_location",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_loc001",
"attachments": [
{
"type": "location",
"payload": {"latitude": 10.8231, "longitude": 106.6297},
}
],
},
"timestamp": "1715760000",
}
def make_video_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_video",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_vid001",
"attachments": [
{
"type": "video",
"payload": {"url": "https://example.com/video.mp4", "name": "video.mp4", "size": 2048},
}
],
},
"timestamp": "1715760000",
}
def make_audio_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_send_audio",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_aud001",
"attachments": [
{
"type": "audio",
"payload": {"url": "https://example.com/audio.mp3", "name": "audio.mp3", "size": 512},
}
],
},
"timestamp": "1715760000",
}
def make_forward_webhook(user_id="follower_001", oa_id="oa_001"):
return {
"event_name": "user_forward_message",
"app_id": "test_app_id",
"sender": {"id": user_id},
"recipient": {"id": oa_id},
"message": {
"msg_id": "msg_fwd001",
"text": "forwarded content",
"forwarded_from": {"id": "original_sender", "display_name": "Original User"},
},
"timestamp": "1715760000",
}
def make_oa_echo_webhook():
return {
"event_name": "oa_send_text",
"app_id": "test_app_id",
"sender": {"id": "oa_001"},
"recipient": {"id": "follower_001"},
"message": {"msg_id": "msg_echo", "text": "echo"},
"timestamp": "1715760000",
}
class TestWebhookSignature:
def test_valid_signature(self):
mac_key = "test-mac-key"
app_id = "test_app_id"
timestamp = "1715760000"
body = '{"event_name":"user_send_text"}'
import hashlib
raw = app_id + body + timestamp + mac_key
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert verify_zalo_oa_signature(body, expected, app_id, mac_key, timestamp) is True
def test_valid_signature_no_timestamp(self):
mac_key = "test-mac-key"
app_id = "test_app_id"
body = '{"event_name":"user_send_text"}'
import hashlib
raw = app_id + body + "" + mac_key
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert verify_zalo_oa_signature(body, expected, app_id, mac_key) is True
def test_invalid_signature(self):
mac_key = "test-mac-key"
assert verify_zalo_oa_signature("{}", "bad-signature", "app_id", mac_key) is False
def test_no_mac_key_skips_verification(self):
assert verify_zalo_oa_signature("{}", "any-sig", "app_id", "") is False
def test_empty_signature(self):
assert verify_zalo_oa_signature("{}", "", "app_id", "key") is False
class TestZaloOAEventNormalizerExtended:
def setup_method(self):
self.normalizer = ZaloOAEventNormalizer()
def test_normalize_link_message(self):
payload = make_link_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.TEXT
assert result.event_type == EventType.MESSAGE_RECEIVED
assert "https://example.com/article" in result.content
assert "Example Article" in result.content
assert "An interesting article about something" in result.content
def test_normalize_link_message_no_title(self):
payload = make_link_webhook()
payload["message"]["attachments"][0]["payload"] = {"url": "https://example.com"}
result = self.normalizer.normalize(payload)
assert "[Link] https://example.com" in result.content
assert "Title:" not in result.content
def test_normalize_business_card_message(self):
payload = make_business_card_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.CARD
assert result.event_type == EventType.MESSAGE_RECEIVED
assert "[Business Card] Nguyen Van A" in result.content
assert "0912345678" in result.content
def test_normalize_business_card_message_no_phone(self):
payload = make_business_card_webhook()
payload["message"]["attachments"][0]["payload"] = {"name": "Tran Thi B"}
result = self.normalizer.normalize(payload)
assert "[Business Card] Tran Thi B" == result.content
def test_normalize_business_card_with_contact_name_field(self):
payload = make_business_card_webhook()
payload["message"]["attachments"][0]["payload"] = {"contact_name": "Le Van C", "phone": "0987654321"}
result = self.normalizer.normalize(payload)
assert "[Business Card] Le Van C" in result.content
assert "0987654321" in result.content
def test_normalize_reaction_event(self):
payload = make_reaction_webhook()
result = self.normalizer.normalize(payload)
assert result.event_type == EventType.REACTION_ADDED
assert result.identity.channel_user_id == "follower_001"
def test_normalize_read_receipt_event(self):
payload = make_read_receipt_webhook()
result = self.normalizer.normalize(payload)
assert result.event_type == EventType.READ_RECEIPT
assert result.identity.channel_user_id == "follower_001"
assert result.metadata["zalo_event_name"] == "read_receipt"
def test_normalize_location_message(self):
payload = make_location_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.LOCATION
assert "10.8231" in result.content
assert "106.6297" in result.content
def test_normalize_video_message(self):
payload = make_video_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.VIDEO
assert len(result.attachments) == 1
assert result.attachments[0].type == "video"
assert result.attachments[0].url == "https://example.com/video.mp4"
assert result.attachments[0].filename == "video.mp4"
assert result.attachments[0].size_bytes == 2048
def test_normalize_audio_message(self):
payload = make_audio_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.AUDIO
assert len(result.attachments) == 1
assert result.attachments[0].type == "audio"
assert result.attachments[0].url == "https://example.com/audio.mp3"
assert result.attachments[0].filename == "audio.mp3"
assert result.attachments[0].size_bytes == 512
def test_normalize_forward_message(self):
payload = make_forward_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.TEXT
assert result.event_type == EventType.MESSAGE_RECEIVED
assert "[Forwarded from Original User]" in result.content
assert "forwarded content" in result.content
def test_normalize_sticker_with_id(self):
payload = make_sticker_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.STICKER
assert "Sticker: stk_123" in result.content
class TestZaloOAEventNormalizer:
def setup_method(self):
self.normalizer = ZaloOAEventNormalizer()
def test_normalize_text_message(self):
payload = make_text_webhook(text="xin chao")
result = self.normalizer.normalize(payload)
assert result.identity.channel_id == "zalo_oa"
assert result.identity.channel_type == ChannelType.ZALO_OA
assert result.identity.channel_user_id == "follower_001"
assert result.identity.channel_chat_id == "oa_001"
assert result.identity.channel_message_id == "msg_abc123"
assert result.content == "xin chao"
assert result.message_type == MessageType.TEXT
assert result.chat_type == ChatType.DIRECT
assert result.event_type == EventType.MESSAGE_RECEIVED
assert result.metadata["zalo_event_name"] == "user_send_text"
assert result.metadata["sender_name"] == "Test User"
def test_normalize_image_message(self):
payload = make_image_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.IMAGE
assert len(result.attachments) == 1
assert result.attachments[0].type == "image"
assert result.attachments[0].url == "https://example.com/img.jpg"
def test_normalize_file_message(self):
payload = make_file_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.FILE
assert len(result.attachments) == 1
assert result.attachments[0].type == "file"
assert result.attachments[0].filename == "doc.pdf"
assert result.attachments[0].size_bytes == 1024
def test_normalize_follow_event(self):
payload = make_follow_webhook()
result = self.normalizer.normalize(payload)
assert result.event_type == EventType.BOT_ADDED
assert "Follower" in result.content
def test_normalize_unfollow_event(self):
payload = make_unfollow_webhook()
result = self.normalizer.normalize(payload)
assert result.event_type == EventType.BOT_REMOVED
assert "unfollowed" in result.content
def test_normalize_sticker(self):
payload = make_sticker_webhook()
result = self.normalizer.normalize(payload)
assert result.message_type == MessageType.STICKER
class TestZaloOAMessageFormatter:
def setup_method(self):
self.formatter = ZaloOAMessageFormatter()
def _make_response(self, content, message_type=MessageType.TEXT, metadata=None):
identity = ChannelIdentity(
channel_id="zalo_oa",
channel_type=ChannelType.ZALO_OA,
channel_user_id="follower_001",
channel_chat_id="oa_001",
)
return ChannelResponse(
identity=identity,
content=content,
message_type=message_type,
metadata=metadata or {},
)
def test_format_text(self):
response = self._make_response("Hello follower")
result = self.formatter.format(response)
assert result["recipient"]["user_id"] == "follower_001"
assert result["message"]["text"] == "Hello follower"
def test_format_long_text_truncated(self):
long_text = "A" * 2500
response = self._make_response(long_text)
result = self.formatter.format(response)
assert len(result["message"]["text"]) <= 2000
assert result["message"]["text"].endswith("...")
def test_format_image(self):
response = self._make_response(
"",
message_type=MessageType.IMAGE,
metadata={"attachment_id": "att_img_001"},
)
result = self.formatter.format(response)
assert result["message"]["attachment"]["type"] == "template"
payload = result["message"]["attachment"]["payload"]
assert payload["template_type"] == "media"
assert payload["elements"][0]["media_type"] == "image"
def test_format_unsupported_type_fallback(self):
response = self._make_response("video content", message_type=MessageType.VIDEO)
result = self.formatter.format(response)
assert result["message"]["text"] == "video content"
def test_format_empty_content(self):
identity = ChannelIdentity(
channel_id="zalo_oa",
channel_type=ChannelType.ZALO_OA,
channel_user_id="follower_001",
channel_chat_id="oa_001",
)
response = ChannelResponse(identity=identity, content="")
result = self.formatter.format(response)
assert result["message"]["text"] == ""
def test_build_list(self):
elements = [{"title": "Item 1", "subtitle": "Desc 1", "image_url": "https://example.com/1.jpg"}]
buttons = [{"title": "Click", "type": "oa.open.url", "payload": {"url": "https://example.com"}}]
result = self.formatter.build_list("follower_001", elements, buttons)
assert result["recipient"]["user_id"] == "follower_001"
payload = result["message"]["attachment"]["payload"]
assert payload["template_type"] == "list"
assert len(payload["elements"]) == 1
assert payload["buttons"] is not None
def test_build_list_without_buttons(self):
elements = [{"title": "Item 1", "subtitle": "Desc 1"}]
result = self.formatter.build_list("follower_001", elements)
assert "buttons" not in result["message"]["attachment"]["payload"]
def test_build_list_missing_title(self):
elements = [{"image_url": "https://example.com/1.jpg"}]
result = self.formatter.build_list("follower_001", elements)
assert result["message"]["attachment"]["payload"]["template_type"] == "list"
def test_format_file_includes_filename(self):
response = self._make_response(
"",
message_type=MessageType.FILE,
metadata={"attachment_id": "att_file_001", "filename": "report.pdf"},
)
result = self.formatter.format(response)
element = result["message"]["attachment"]["payload"]["elements"][0]
assert element["media_type"] == "file"
assert element.get("name") == "report.pdf"
class TestZaloOAAdapter:
@pytest.fixture
def adapter(self):
config = {
"app_id": "test_app_id",
"secret_key": "test_secret_key",
"dm_policy": "open",
}
return ZaloOAAdapter(config=config)
def test_channel_id(self, adapter):
assert adapter.channel_id == "zalo_oa"
def test_channel_type(self, adapter):
assert adapter.channel_type == ChannelType.ZALO_OA
def test_capabilities(self, adapter):
assert adapter.supports_streaming is False
assert adapter.supports_markdown is False
assert adapter.text_chunk_limit == 2000
assert adapter.max_media_size_mb == 10
assert "off" in adapter.streaming_modes
assert adapter.webhook_path == "zalo_oa"
def test_initial_status(self, adapter):
assert adapter.status == ChannelStatus.DISCONNECTED
def test_normalize_text_message(self, adapter):
body = json.dumps(make_text_webhook(text="xin chao")).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert result.content == "xin chao"
assert result.identity.channel_user_id == "follower_001"
assert result.message_type == MessageType.TEXT
assert result.chat_type == ChatType.DIRECT
def test_normalize_oa_echo_skipped(self, adapter):
body = json.dumps(make_oa_echo_webhook()).encode("utf-8")
with pytest.raises(SkipMessageError):
adapter.normalize_inbound(body)
def test_format_outbound_text(self, adapter):
identity = ChannelIdentity(
channel_id="zalo_oa",
channel_type=ChannelType.ZALO_OA,
channel_user_id="follower_001",
channel_chat_id="oa_001",
)
response = ChannelResponse(identity=identity, content="hello")
result = adapter.format_outbound(response)
assert result["recipient"]["user_id"] == "follower_001"
assert result["message"]["text"] == "hello"
@pytest.mark.asyncio
async def test_send_not_connected(self, adapter):
identity = ChannelIdentity(
channel_id="zalo_oa",
channel_type=ChannelType.ZALO_OA,
channel_user_id="follower_001",
channel_chat_id="oa_001",
)
response = ChannelResponse(identity=identity, content="test")
result = await adapter.send(response)
assert result.success is False
assert "not connected" in result.error
@pytest.mark.asyncio
async def test_health_check_not_connected(self, adapter):
result = await adapter.health_check()
assert result.status == "unhealthy"
@pytest.mark.asyncio
async def test_verify_signature_no_mac_key(self, adapter):
result = await adapter.verify_webhook_signature({}, b"{}")
assert result is False
@pytest.mark.asyncio
async def test_pre_connect_missing_app_id(self):
adapter = ZaloOAAdapter(config={})
result = await adapter.pre_connect()
assert result["status"] == "error"
assert "App ID" in result["message"]
@pytest.mark.asyncio
async def test_pre_connect_missing_secret_key(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": ""})
result = await adapter.pre_connect()
assert result["status"] == "error"
@pytest.mark.asyncio
async def test_disconnect_when_disconnected(self, adapter):
await adapter.disconnect()
assert adapter.status == ChannelStatus.DISCONNECTED
@pytest.mark.asyncio
async def test_get_user_info_not_connected(self, adapter):
result = await adapter.get_user_info("follower_001")
assert result == {}
@pytest.mark.asyncio
async def test_download_media_not_connected(self, adapter):
with pytest.raises(Exception):
await adapter.download_media("https://example.com/test.jpg")
class TestSessionRouting:
def test_resolve_thread_key(self):
key = resolve_thread_key("main", "follower_001")
assert key == "agent:main:zalo_oa:default:direct:follower_001"
def test_resolve_thread_key_custom_agent(self):
key = resolve_thread_key("weather_agent", "follower_999")
assert key == "agent:weather_agent:zalo_oa:default:direct:follower_999"
def test_resolve_thread_key_custom_account(self):
key = resolve_thread_key("main", "follower_001", account_id="oa_prod")
assert key == "agent:main:zalo_oa:oa_prod:direct:follower_001"
class TestZaloOAAdapterConnect:
@pytest.fixture
def adapter(self):
config = {
"app_id": "test_app_id",
"secret_key": "test_secret_key",
"dm_policy": "open",
}
return ZaloOAAdapter(config=config)
@pytest.mark.asyncio
async def test_connect_missing_app_id(self):
adapter = ZaloOAAdapter(config={"secret_key": "test"})
with pytest.raises(Exception):
await adapter.connect()
@pytest.mark.asyncio
async def test_connect_missing_secret_key(self):
adapter = ZaloOAAdapter(config={"app_id": "test"})
with pytest.raises(Exception):
await adapter.connect()
class TestTokenExpiredError:
def test_token_expired_error_importable(self):
from yuxi.channels.exceptions import TokenExpiredError
err = TokenExpiredError()
assert err.retryable is True
assert "expired" in str(err).lower()
class TestNormalizeInboundErrorHandling:
def test_invalid_json_raises_message_format_error(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
with pytest.raises(Exception):
adapter.normalize_inbound(b"not-valid-json")
def test_oa_echo_skipped_after_valid_json(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
body = json.dumps(make_oa_echo_webhook()).encode("utf-8")
with pytest.raises(SkipMessageError):
adapter.normalize_inbound(body)
class TestFormatterConstructor:
def test_formatter_accepts_custom_max_length(self):
formatter = ZaloOAMessageFormatter(max_text_length=500)
assert formatter.max_text_length == 500
def test_formatter_truncates_with_custom_length(self):
formatter = ZaloOAMessageFormatter(max_text_length=10)
identity = ChannelIdentity(
channel_id="zalo_oa",
channel_type=ChannelType.ZALO_OA,
channel_user_id="follower_001",
channel_chat_id="oa_001",
)
response = ChannelResponse(identity=identity, content="Hello World this is long")
result = formatter.format(response)
assert len(result["message"]["text"]) <= 10
assert result["message"]["text"].endswith("...")
def test_formatter_default_length_is_2000(self):
formatter = ZaloOAMessageFormatter()
assert formatter.max_text_length == 2000
class TestAdapterMaxTextLength:
def test_adapter_passes_text_chunk_limit_to_formatter(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter._formatter.max_text_length == adapter.text_chunk_limit
class TestSendMedia:
@pytest.fixture
def adapter(self):
config = {
"app_id": "test_app_id",
"secret_key": "test_secret_key",
"dm_policy": "open",
}
return ZaloOAAdapter(config=config)
@pytest.mark.asyncio
async def test_send_media_not_connected(self, adapter):
result = await adapter.send_media("user_001", "image", b"\x89PNGtest")
assert result.success is False
assert "not connected" in result.error.lower()
@pytest.mark.asyncio
async def test_send_media_oversized_data(self, adapter):
from unittest.mock import MagicMock
adapter._status = ChannelStatus.CONNECTED
adapter._sender = MagicMock()
import random
big_data = bytes(random.getrandbits(8) for _ in range(11 * 1024 * 1024))
result = await adapter.send_media("user_001", "image", big_data)
assert result.success is False
assert "exceeds max" in result.error
@pytest.mark.asyncio
async def test_send_media_unsupported_data_type(self, adapter):
from unittest.mock import MagicMock
adapter._status = ChannelStatus.CONNECTED
adapter._sender = MagicMock()
result = await adapter.send_media("user_001", "image", 12345)
assert result.success is False
assert "unsupported data type" in result.error.lower()
class TestFormatterLocation:
def test_format_location_message(self):
formatter = ZaloOAMessageFormatter(max_text_length=2000)
identity = ChannelIdentity(
channel_id="zalo_oa",
channel_type=ChannelType.ZALO_OA,
channel_user_id="follower_001",
channel_chat_id="oa_001",
)
response = ChannelResponse(
identity=identity,
message_type=MessageType.LOCATION,
content="",
metadata={"lat": "10.8231", "lon": "106.6297"},
)
result = formatter.format(response)
payload = result["message"]["attachment"]["payload"]
elements = payload["elements"][0]
assert elements["media_type"] == "location"
assert elements["latitude"] == 10.8231
assert elements["longitude"] == 106.6297
class TestClientUploadSizeValidation:
def test_max_media_size_stored(self):
client = ZaloOAClient("app_id", "secret_key", max_media_size_mb=5)
assert client._max_media_size_mb == 5
def test_max_media_size_default(self):
client = ZaloOAClient("app_id", "secret_key")
assert client._max_media_size_mb == 10
def test_oversized_media_detected_before_upload(self):
client = ZaloOAClient("app_id", "secret_key", max_media_size_mb=1)
big_data = bytes(2 * 1024 * 1024)
size_mb = len(big_data) / (1024 * 1024)
assert size_mb > client._max_media_size_mb
assert size_mb == 2.0
class TestCapabilitiesUpdated:
def test_reply_is_false(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter.capabilities.reply is False
def test_unsend_is_true(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter.capabilities.unsend is True
def test_native_commands_is_true(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter.capabilities.native_commands is True
def test_chat_types_direct_only(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter.capabilities.chat_types == ["direct"]
def test_typing_is_true(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter.capabilities.typing is True
def test_meta_has_selection_label(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert "Official Account" in adapter.meta.selection_label
def test_meta_has_blurb(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert len(adapter.meta.blurb) > 0
def test_meta_has_order(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter.meta.order == 25
def test_meta_has_docs_path(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
assert adapter.meta.docs_path == "docs/channels/zalo-oa"
class TestAdapterNewEvents:
def test_adapter_normalize_link_message(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_link_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert "https://example.com/article" in result.content
assert "Example Article" in result.content
assert result.message_type == MessageType.TEXT
def test_adapter_normalize_business_card(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_business_card_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert "[Business Card] Nguyen Van A" in result.content
assert result.message_type == MessageType.CARD
def test_adapter_normalize_reaction(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_reaction_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert result.event_type == EventType.REACTION_ADDED
def test_adapter_normalize_read_receipt(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_read_receipt_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert result.event_type == EventType.READ_RECEIPT
def test_adapter_normalize_location(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_location_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert result.message_type == MessageType.LOCATION
assert "10.8231" in result.content
def test_adapter_normalize_video(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_video_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert result.message_type == MessageType.VIDEO
assert len(result.attachments) == 1
assert result.attachments[0].type == "video"
def test_adapter_normalize_audio(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_audio_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert result.message_type == MessageType.AUDIO
assert len(result.attachments) == 1
assert result.attachments[0].type == "audio"
def test_adapter_normalize_forward(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test", "dm_policy": "open"})
body = json.dumps(make_forward_webhook()).encode("utf-8")
result = adapter.normalize_inbound(body)
assert isinstance(result, ChannelMessage)
assert "[Forwarded from Original User]" in result.content
class TestSecurityWildcard:
def test_wildcard_allowlist_allows_all_in_allowlist_policy(self):
from yuxi.channels.adapters.zalo_oa.security import DMPolicy, check_dm_allowed
allowlist = {"*"}
assert check_dm_allowed("any_user_123", DMPolicy.ALLOWLIST, allowlist) is True
assert check_dm_allowed("another_user", DMPolicy.ALLOWLIST, allowlist) is True
def test_wildcard_allowlist_allows_all_in_pairing_policy(self):
from yuxi.channels.adapters.zalo_oa.security import DMPolicy, check_dm_allowed
allowlist = {"*"}
assert check_dm_allowed("any_user_123", DMPolicy.PAIRING, allowlist) is True
def test_load_allowlist_with_wildcard(self):
from yuxi.channels.adapters.zalo_oa.security import load_allowlist
config = {"allowFrom": ["*"]}
result = load_allowlist(config)
assert result == {"*"}
def test_load_allowlist_with_wildcard_among_others(self):
from yuxi.channels.adapters.zalo_oa.security import load_allowlist
config = {"allowFrom": ["user1", "*", "user2"]}
result = load_allowlist(config)
assert result == {"*"}
def test_security_warnings_for_wildcard(self):
from yuxi.channels.adapters.zalo_oa.security import collect_security_warnings
config = {"dm_policy": "allowlist", "allowFrom": ["*"]}
warnings = collect_security_warnings(config)
wildcard_warnings = [w for w in warnings if w["type"] == "allowlist_wildcard"]
assert len(wildcard_warnings) >= 1
def test_security_warnings_for_empty_allowlist(self):
from yuxi.channels.adapters.zalo_oa.security import collect_security_warnings
config = {"dm_policy": "allowlist", "allowFrom": []}
warnings = collect_security_warnings(config)
empty_warnings = [w for w in warnings if w["type"] == "empty_allowlist"]
assert len(empty_warnings) >= 1
def test_security_warnings_for_open_policy(self):
from yuxi.channels.adapters.zalo_oa.security import collect_security_warnings
config = {"dm_policy": "open"}
warnings = collect_security_warnings(config)
open_warnings = [w for w in warnings if w["type"] == "dm_policy_open"]
assert len(open_warnings) >= 1
class TestSessionRouter:
def test_session_router_ttl_expiry(self):
from yuxi.channels.adapters.zalo_oa.session import SessionRouter
router = SessionRouter(ttl_sec=0)
thread_key = router.resolve_thread_key("main", "user_001")
router.set_session(thread_key, {"key": "value"})
assert router.get_session(thread_key) is None
assert router.session_count == 0
def test_session_router_cleanup_expired(self):
from yuxi.channels.adapters.zalo_oa.session import SessionRouter
router = SessionRouter(ttl_sec=0)
for i in range(5):
key = router.resolve_thread_key("main", f"user_{i:03d}")
router.set_session(key, {"index": i})
removed = router.cleanup_expired()
assert removed == 5
def test_session_router_clear(self):
from yuxi.channels.adapters.zalo_oa.session import SessionRouter
router = SessionRouter(ttl_sec=999)
key = router.resolve_thread_key("main", "user_001")
router.set_session(key, {"data": "test"})
router.clear_session(key)
assert router.get_session(key) is None
class TestCircuitBreaker:
def test_circuit_breaker_threshold_count(self):
from yuxi.channels.adapters.zalo_oa.client import AUTH_FAILURE_CIRCUIT_BREAKER
assert AUTH_FAILURE_CIRCUIT_BREAKER == 10
def test_client_initial_auth_failure_count_zero(self):
client = ZaloOAClient("app_id", "secret_key")
assert client._auth_failure_count == 0
def test_client_circuit_breaker_initially_closed(self):
client = ZaloOAClient("app_id", "secret_key")
assert client._auth_circuit_open is False
class TestBroadcastState:
def test_broadcast_state_default_empty(self):
from yuxi.channels.adapters.zalo_oa.send import BroadcastState
state = BroadcastState(state_file="test_broadcast_state_tmp.json")
assert state.has_state is False
loaded = state.load()
assert loaded is None
def test_broadcast_state_save_and_load(self):
from yuxi.channels.adapters.zalo_oa.send import BroadcastState
state = BroadcastState(state_file="test_broadcast_state_tmp.json")
state.save("test_broadcast_id", 50, 100, "Hello broadcast")
assert state.has_state is True
loaded = state.load()
assert loaded["broadcast_id"] == "test_broadcast_id"
assert loaded["last_index"] == 50
assert loaded["total"] == 100
assert loaded["text"] == "Hello broadcast"
def test_broadcast_state_clear(self):
from yuxi.channels.adapters.zalo_oa.send import BroadcastState
state = BroadcastState(state_file="test_broadcast_state_tmp.json")
state.save("test_id", 10, 20, "test")
state.clear()
assert state.has_state is False
class TestUnsendAction:
def test_unsend_action_registered(self):
from yuxi.channels.adapters.zalo_oa.message_actions import SUPPORTED_ACTIONS, is_action_supported
assert "unsend" in SUPPORTED_ACTIONS
assert is_action_supported("unsend") is True
def test_unsend_action_description(self):
from yuxi.channels.adapters.zalo_oa.message_actions import describe_actions
actions = describe_actions()
unsend_actions = [a for a in actions if a["action"] == "unsend"]
assert len(unsend_actions) == 1
assert "message_id" in unsend_actions[0]["params"]
assert "user_id" in unsend_actions[0]["params"]
def test_unsend_action_handler_requires_client(self):
from yuxi.channels.adapters.zalo_oa.message_actions import build_action_handler
result = build_action_handler("unsend", {"message_id": "msg_001", "user_id": "user_001"}, None, None, client=None)
assert result is None
def test_unsend_action_handler_with_client(self):
from yuxi.channels.adapters.zalo_oa.message_actions import build_action_handler
result = build_action_handler(
"unsend",
{"message_id": "msg_001", "user_id": "user_001"},
None,
None,
client="mock_client",
)
assert result is not None
assert result["_unsend_message_id"] == "msg_001"
assert result["_unsend_user_id"] == "user_001"
class TestStatusIssuesEnhanced:
def test_status_issues_includes_follower_check(self):
from yuxi.channels.adapters.zalo_oa.status_issues import collect_status_issues
config = {"dm_policy": "open"}
issues = collect_status_issues(config, {"follower_count": 0})
follower_issues = [i for i in issues if i["type"] == "no_followers"]
assert len(follower_issues) >= 1
def test_status_issues_includes_security_warnings(self):
from yuxi.channels.adapters.zalo_oa.status_issues import collect_status_issues
config = {"dm_policy": "open"}
issues = collect_status_issues(config, {})
security_issues = [i for i in issues if i["type"].startswith("security_")]
assert len(security_issues) >= 1
class TestVoiceModule:
def test_voice_disabled_by_default(self):
from yuxi.channels.adapters.zalo_oa.voice import ZaloOAVoice
voice = ZaloOAVoice()
assert voice.enabled is False
def test_voice_enabled_with_api_url(self):
from yuxi.channels.adapters.zalo_oa.voice import ZaloOAVoice
voice = ZaloOAVoice(config={"voice_tts_enabled": True, "voice_tts_api_url": "https://api.example.com/tts"})
assert voice.enabled is True
def test_voice_disabled_without_api_url(self):
from yuxi.channels.adapters.zalo_oa.voice import ZaloOAVoice
voice = ZaloOAVoice(config={"voice_tts_enabled": True})
assert voice.enabled is False
class TestPollingModule:
def test_poller_initial_state(self):
from yuxi.channels.adapters.zalo_oa.polling import ZaloOAPoller
poller = ZaloOAPoller(client=None)
assert poller.is_running is False
assert poller.poll_count == 0
def test_poller_metrics(self):
from yuxi.channels.adapters.zalo_oa.polling import ZaloOAPoller
poller = ZaloOAPoller(client=None, config={"polling_interval_sec": 10, "polling_timeout_ms": 15000})
metrics = poller.get_poll_metrics()
assert metrics["interval_sec"] == 10
assert metrics["timeout_ms"] == 15000
assert metrics["running"] is False
class TestAdapterRecallMessage:
def test_recall_message_not_connected(self):
adapter = ZaloOAAdapter(config={"app_id": "test", "secret_key": "test"})
import asyncio
result = asyncio.run(adapter.recall_message("msg_id", "user_id"))
assert result is False
class TestConfigSchemaExtended:
def test_config_has_polling_fields(self):
from yuxi.channels.adapters.zalo_oa.config_schema import ZaloOAConfig
config = ZaloOAConfig()
assert config.polling_enabled is True
assert config.polling_timeout_ms == 30000
assert config.polling_interval_sec == 5
def test_config_has_voice_fields(self):
from yuxi.channels.adapters.zalo_oa.config_schema import ZaloOAConfig
config = ZaloOAConfig()
assert config.voice_tts_enabled is False
assert config.voice_tts_model == ""
def test_config_has_media_vision_extended_fields(self):
from yuxi.channels.adapters.zalo_oa.config_schema import ZaloOAConfig
config = ZaloOAConfig()
assert config.media_vision_api_url == ""
assert config.media_vision_api_key == ""
assert config.media_vision_max_tokens == 100
assert config.media_vision_timeout_sec == 15