新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
952 lines
34 KiB
Python
952 lines
34 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": ""},
|
|
"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 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: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:direct:follower_999"
|
|
|
|
|
|
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 TestZaloOAAnalyzer:
|
|
@pytest.mark.asyncio
|
|
async def test_web_research_agent(self):
|
|
from yuxi.channels.adapters.zalo_oa.analysis.sub_agents import SubAgentWebResearch
|
|
|
|
agent = SubAgentWebResearch()
|
|
result = await agent.research()
|
|
assert result.agent_name == "WebResearchAgent"
|
|
assert result.category == "external_research"
|
|
assert len(result.findings) > 0
|
|
assert result.summary
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_functional_research_agent(self):
|
|
from yuxi.channels.adapters.zalo_oa.analysis.sub_agents import SubAgentFunctionalResearch
|
|
|
|
agent = SubAgentFunctionalResearch()
|
|
result = await agent.research()
|
|
assert result.agent_name == "FunctionalResearchAgent"
|
|
assert result.category == "code_functional_analysis"
|
|
assert len(result.findings) > 0
|
|
assert result.summary
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_issue_research_agent(self):
|
|
from yuxi.channels.adapters.zalo_oa.analysis.sub_agents import SubAgentIssueResearch
|
|
|
|
agent = SubAgentIssueResearch()
|
|
result = await agent.research()
|
|
assert result.agent_name == "IssueResearchAgent"
|
|
assert result.category == "code_quality_and_bugs"
|
|
assert len(result.findings) > 0
|
|
assert result.summary
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_analysis_orchestrator(self):
|
|
from yuxi.channels.adapters.zalo_oa.analysis.orchestrator import ZaloOAAnalyzer
|
|
|
|
analyzer = ZaloOAAnalyzer()
|
|
report = await analyzer.run_full_analysis()
|
|
assert report.title
|
|
assert report.module_path
|
|
assert report.summary
|
|
assert len(report.missing_features) >= 0
|
|
assert len(report.security_issues) > 0
|
|
assert len(report.fix_plan) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_generation(self):
|
|
from yuxi.channels.adapters.zalo_oa.analysis.report_generator import generate_gap_report
|
|
from yuxi.channels.adapters.zalo_oa.analysis.sub_agents import (
|
|
AnalysisResult,
|
|
SubAgentWebResearch,
|
|
)
|
|
|
|
agent = SubAgentWebResearch()
|
|
result = await agent.research()
|
|
report = generate_gap_report([result])
|
|
assert report.title
|
|
assert report.generated_at
|
|
assert report.summary
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_analyzer_print_report(self):
|
|
from yuxi.channels.adapters.zalo_oa.analysis.orchestrator import ZaloOAAnalyzer
|
|
from yuxi.channels.adapters.zalo_oa.analysis.sub_agents import SubAgentWebResearch
|
|
|
|
agent = SubAgentWebResearch()
|
|
result = await agent.research()
|
|
|
|
from yuxi.channels.adapters.zalo_oa.analysis.report_generator import generate_gap_report
|
|
|
|
report = generate_gap_report([result])
|
|
analyzer = ZaloOAAnalyzer()
|
|
analyzer.print_report(report)
|
|
|
|
|
|
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_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 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
|