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