ForcePilot/backend/test/unit/channels/test_zalo_oa_adapter.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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