from __future__ import annotations import json import time from unittest.mock import MagicMock, AsyncMock, patch import pytest from yuxi.channels.adapters.zalo_oa.commands import ( CommandRegistry, extract_command, get_command_list, get_command_registry, handle_command, register_command, ) from yuxi.channels.adapters.zalo_oa.dedup import EventDeduplicator, EventState from yuxi.channels.adapters.zalo_oa.security import ( DMPolicy, check_dm_allowed, collect_security_warnings, load_allowlist, normalize_allow_entry, resolve_dm_policy, ) from yuxi.channels.adapters.zalo_oa.chunking import chunk_text from yuxi.channels.adapters.zalo_oa.messaging import ( build_target_id, looks_like_user_id, normalize_messaging_target, ) from yuxi.channels.adapters.zalo_oa.cache import SentMessageCache from yuxi.channels.adapters.zalo_oa.pairing import ( PairingStore, build_pairing_message, build_pairing_notification, build_pairing_success_message, PAIRING_CODE_TTL_SEC, ) from yuxi.channels.adapters.zalo_oa.webhook_ratelimit import ( WebhookRateLimiter, build_rate_limit_key, resolve_client_ip, ) from yuxi.channels.adapters.zalo_oa.approval import ( build_approval_request, check_approval_required, normalize_approver_id, resolve_approvers, ) from yuxi.channels.adapters.zalo_oa.audit import AuditEventType, AuditLogger from yuxi.channels.adapters.zalo_oa.accounts import ( get_default_account_id, list_account_ids, resolve_account, ) from yuxi.channels.adapters.zalo_oa.webhook_anomaly import ( WebhookAnomalyTracker, ALERT_THRESHOLD_401, ALERT_THRESHOLD_400, ALERT_THRESHOLD_413, ALERT_THRESHOLD_429, ) from yuxi.channels.adapters.zalo_oa.message_actions import ( SUPPORTED_ACTIONS, build_action_handler, describe_actions, is_action_supported, ) from yuxi.channels.adapters.zalo_oa.signature import verify_zalo_oa_signature from yuxi.channels.adapters.zalo_oa.session import ( SessionRouter, resolve_thread_key, resolve_session_params, get_session_router, ) from yuxi.channels.adapters.zalo_oa.formatter import ZaloOAMessageFormatter from yuxi.channels.adapters.zalo_oa.outbound_media import ( OutboundMediaHost, cleanup_media_cache, get_media, resolve_attachment, store_media, ) from yuxi.channels.adapters.zalo_oa.config_schema import ZaloOAConfig, ZaloOAAccountConfig from yuxi.channels.adapters.zalo_oa.polling import ZaloOAPoller from yuxi.channels.adapters.zalo_oa.voice import ZaloOAVoice from yuxi.channels.adapters.zalo_oa.normalizer import SkipMessageError, ZaloOAEventNormalizer from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelResponse, ChannelType, ChatType, EventType, MessageType, ) # ============================================================================== # Commands # ============================================================================== class TestCommandRegistry: def setup_method(self): self.registry = CommandRegistry() def test_default_commands_registered(self): assert self.registry.is_registered("help") assert self.registry.is_registered("status") assert self.registry.is_registered("ping") def test_register_new_command(self): self.registry.register("echo", "Echo text back") assert self.registry.is_registered("echo") def test_unregister_command(self): self.registry.register("temp_cmd", "Temporary") self.registry.unregister("temp_cmd") assert not self.registry.is_registered("temp_cmd") def test_cannot_unregister_builtin_commands(self): self.registry.unregister("help") assert self.registry.is_registered("help") self.registry.unregister("status") assert self.registry.is_registered("status") self.registry.unregister("ping") assert self.registry.is_registered("ping") def test_get_list_returns_all_commands(self): cmd_list = self.registry.get_list() names = [c["command"] for c in cmd_list] assert "/help" in names assert "/status" in names assert "/ping" in names @pytest.mark.asyncio async def test_handle_unknown_command(self): result = await self.registry.handle("unknown", "", {}) assert result is None @pytest.mark.asyncio async def test_handle_help(self): result = await self.registry.handle("help", "", {}) assert result assert "Available commands:" in result @pytest.mark.asyncio async def test_handle_status(self): context = {"oa_name": "TestOA", "status": "connected", "dm_policy": "open"} result = await self.registry.handle("status", "", context) assert "TestOA" in result assert "connected" in result assert "open" in result @pytest.mark.asyncio async def test_handle_ping(self): result = await self.registry.handle("ping", "", {}) assert result == "pong" @pytest.mark.asyncio async def test_handle_with_extra_handlers(self): async def custom_handler(args, ctx): return f"custom:{args}" result = await self.registry.handle("custom_cmd", "hello", {}, {"custom_cmd": custom_handler}) assert result == "custom:hello" @pytest.mark.asyncio async def test_handle_with_args(self): self.registry.register("echo", "Echo text back") result = await self.registry.handle("echo", "hello world", {}) assert result == "Command /echo executed" def test_case_insensitive_lookup(self): self.registry.register("UpperCase", "Test") assert self.registry.is_registered("UpperCase") assert self.registry.is_registered("UPPERCASE") def test_case_insensitive_lookup_uppercase(self): self.registry.register("echo", "Echo text back") assert self.registry.is_registered("ECHO") assert self.registry.is_registered("Echo") class TestExtractCommand: def test_extract_slash_command(self): cmd, args = extract_command("/help") assert cmd == "help" assert args == "" def test_extract_slash_command_with_args(self): cmd, args = extract_command("/echo hello world") assert cmd == "echo" assert args == "hello world" def test_extract_exclamation_command(self): cmd, args = extract_command("!status") assert cmd == "status" assert args == "" def test_extract_exclamation_with_args(self): cmd, args = extract_command("!ping test arg") assert cmd == "ping" assert args == "test arg" def test_no_command_prefix(self): cmd, args = extract_command("hello world") assert cmd is None assert args == "hello world" def test_no_command_prefix_leading_space(self): cmd, args = extract_command(" hello") assert cmd is None assert args == "hello" def test_empty_string(self): cmd, args = extract_command("") assert cmd is None assert args == "" def test_only_slash(self): cmd, args = extract_command("/") assert cmd == "" assert args == "" def test_only_slash_with_space(self): cmd, args = extract_command("/ ") assert cmd == "" assert args == "" def test_command_case_insensitive(self): cmd, args = extract_command("/HELP") assert cmd == "help" class TestGlobalCommandFunctions: @pytest.mark.asyncio async def test_handle_command_global(self): context = {"oa_name": "TestOA"} result = await handle_command("ping", "", context) assert result == "pong" @pytest.mark.asyncio async def test_get_command_list_global(self): cmd_list = get_command_list() assert len(cmd_list) >= 3 def test_register_command_global(self): register_command("global_test", "Test global registration") registry = get_command_registry() assert registry.is_registered("global_test") registry.unregister("global_test") # ============================================================================== # Deduplication # ============================================================================== class TestEventDeduplicator: def setup_method(self): self.dedup = EventDeduplicator(window_ms=60000) def test_claim_first_time(self): payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} assert self.dedup.claim(payload) is True def test_claim_duplicate_within_window(self): payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m2"}} assert self.dedup.claim(payload) is True assert self.dedup.claim(payload) is False def test_different_senders_not_deduplicated(self): p1 = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} p2 = {"event_name": "user_send_text", "sender": {"id": "u2"}, "message": {"msg_id": "m1"}} assert self.dedup.claim(p1) is True assert self.dedup.claim(p2) is True def test_different_msg_ids_not_deduplicated(self): p1 = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} p2 = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m2"}} assert self.dedup.claim(p1) is True assert self.dedup.claim(p2) is True def test_commit_changes_state(self): payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} self.dedup.claim(payload) self.dedup.commit(payload) assert self.dedup.is_committed(payload) is True def test_is_committed_before_commit(self): payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} self.dedup.claim(payload) assert self.dedup.is_committed(payload) is False def test_release_removes_event(self): payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} self.dedup.claim(payload) self.dedup.release(payload) assert self.dedup.is_committed(payload) is False def test_claim_after_release(self): payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} assert self.dedup.claim(payload) is True self.dedup.release(payload) assert self.dedup.claim(payload) is True def test_window_expiry_allows_reclaim(self): dedup = EventDeduplicator(window_ms=1) payload = {"event_name": "user_send_text", "sender": {"id": "u1"}, "message": {"msg_id": "m1"}} assert dedup.claim(payload) is True time.sleep(0.002) assert dedup.claim(payload) is True def test_events_without_sender(self): payload = {"event_name": "test_event", "sender": {}, "message": {"msg_id": ""}} assert self.dedup.claim(payload) is True def test_events_without_message(self): payload = {"event_name": "test_event", "sender": {"id": "u1"}} assert self.dedup.claim(payload) is True # ============================================================================== # Security # ============================================================================== class TestDMPolicy: def test_open_policy_allows_all(self): assert check_dm_allowed("any_user", DMPolicy.OPEN, set()) is True def test_disabled_policy_denies_all(self): assert check_dm_allowed("any_user", DMPolicy.DISABLED, set()) is False def test_allowlist_policy_match(self): assert check_dm_allowed("user1", DMPolicy.ALLOWLIST, {"user1"}) is True def test_allowlist_policy_no_match(self): assert check_dm_allowed("user2", DMPolicy.ALLOWLIST, {"user1"}) is False def test_allowlist_policy_wildcard(self): assert check_dm_allowed("any_user", DMPolicy.ALLOWLIST, {"*"}) is True def test_pairing_policy_match(self): assert check_dm_allowed("user1", DMPolicy.PAIRING, {"user1"}) is True def test_pairing_policy_no_match(self): assert check_dm_allowed("user2", DMPolicy.PAIRING, {"user1"}) is False def test_pairing_policy_wildcard(self): assert check_dm_allowed("any_user", DMPolicy.PAIRING, {"*"}) is True def test_resolve_dm_policy_open(self): assert resolve_dm_policy({"dm_policy": "open"}) == DMPolicy.OPEN def test_resolve_dm_policy_pairing(self): assert resolve_dm_policy({"dm_policy": "pairing"}) == DMPolicy.PAIRING def test_resolve_dm_policy_allowlist(self): assert resolve_dm_policy({"dm_policy": "allowlist"}) == DMPolicy.ALLOWLIST def test_resolve_dm_policy_disabled(self): assert resolve_dm_policy({"dm_policy": "disabled"}) == DMPolicy.DISABLED def test_resolve_dm_policy_default(self): assert resolve_dm_policy({}) == DMPolicy.OPEN def test_resolve_dm_policy_unknown(self): assert resolve_dm_policy({"dm_policy": "unknown"}) == DMPolicy.OPEN def test_resolve_dm_policy_dmPolicy_fallback(self): assert resolve_dm_policy({"dmPolicy": "disabled"}) == DMPolicy.DISABLED class TestLoadAllowlist: def test_normal_allowlist(self): config = {"allowFrom": ["user1", "user2"]} result = load_allowlist(config) assert result == {"user1", "user2"} def test_empty_allowlist(self): result = load_allowlist({}) assert result == set() def test_wildcard_only(self): config = {"allowFrom": ["*"]} result = load_allowlist(config) assert result == {"*"} def test_wildcard_precedence(self): config = {"allowFrom": ["user1", "*", "user2"]} result = load_allowlist(config) assert result == {"*"} def test_prefix_normalization_zoa(self): config = {"allowFrom": ["zoa:user1"]} result = load_allowlist(config) assert "user1" in result def test_prefix_normalization_zalo_oa(self): config = {"allowFrom": ["zalo_oa:user2"]} result = load_allowlist(config) assert "user2" in result def test_non_list_allow_from(self): config = {"allowFrom": "not_a_list"} result = load_allowlist(config) assert result == set() class TestNormalizeAllowEntry: def test_zoa_prefix(self): assert normalize_allow_entry("zoa:user1") == "user1" def test_zalo_oa_prefix(self): assert normalize_allow_entry("zalo_oa:user1") == "user1" def test_no_prefix(self): assert normalize_allow_entry("user1") == "user1" def test_case_insensitive_prefix(self): assert normalize_allow_entry("ZOA:User1") == "User1" assert normalize_allow_entry("ZALO_OA:User1") == "User1" class TestCollectSecurityWarnings: def test_open_policy_warning(self): warnings = collect_security_warnings({"dm_policy": "open"}) assert any(w["type"] == "dm_policy_open" for w in warnings) def test_empty_allowlist_warning(self): warnings = collect_security_warnings({"dm_policy": "allowlist", "allowFrom": []}) assert any(w["type"] == "empty_allowlist" for w in warnings) def test_pairing_no_allowlist_info(self): warnings = collect_security_warnings({"dm_policy": "pairing", "allowFrom": []}) assert any(w["type"] == "pairing_no_preset" for w in warnings) def test_wildcard_info(self): warnings = collect_security_warnings({"dm_policy": "allowlist", "allowFrom": ["*"]}) assert any(w["type"] == "allowlist_wildcard" for w in warnings) def test_no_webhook_url_info(self): warnings = collect_security_warnings({"dm_policy": "open"}) assert any(w["type"] == "webhook_not_configured" for w in warnings) def test_no_mac_key_warning(self): warnings = collect_security_warnings({"dm_policy": "open"}) assert any(w["type"] == "webhook_mac_key_missing" for w in warnings) def test_with_webhook_and_mac_key_no_warnings(self): warnings = collect_security_warnings( {"dm_policy": "allowlist", "allowFrom": ["user1"], "webhook": {"url": "https://ex.com", "mac_key": "key1"}} ) assert not any(w["type"] in ("webhook_not_configured", "webhook_mac_key_missing") for w in warnings) # ============================================================================== # Chunking # ============================================================================== class TestChunkText: def test_short_text_no_chunking(self): result = chunk_text("hello", 100) assert result == ["hello"] def test_chunk_by_paragraph_boundary(self): text = "A" * 100 + "\n\n" + "B" * 100 result = chunk_text(text, 150) assert len(result) == 2 assert result[0].strip() == "A" * 100 assert result[1].strip() == "B" * 100 def test_chunk_by_newline_and_comma(self): text = "Hello, World. This is" + "X" * 300 result = chunk_text(text, 300) assert len(result) >= 1 def test_chunk_by_space(self): text = "A " * 200 + "B" * 200 result = chunk_text(text, 200) assert len(result) > 1 def test_chunk_hard_split(self): text = "X" * 500 result = chunk_text(text, 100) assert len(result) == 5 for chunk in result: assert len(chunk) <= 100 def test_empty_string(self): result = chunk_text("", 100) assert result == [""] def test_max_chars_equals_length(self): text = "hello world" result = chunk_text(text, len(text)) assert result == ["hello world"] def test_chunk_by_sentence_boundary(self): text = "Hello there. How are you? " + "X" * 200 result = chunk_text(text, 200) assert len(result) >= 1 def test_all_chunks_within_limit(self): text = "A" * 2500 result = chunk_text(text, 500) for chunk in result: assert len(chunk) <= 500 def test_multiple_paragraphs(self): paragraphs = ["P" * 100 for _ in range(5)] text = "\n\n".join(paragraphs) result = chunk_text(text, 250) assert len(result) >= 3 # ============================================================================== # Messaging # ============================================================================== class TestMessaging: def test_normalize_zalo_oa_prefix(self): assert normalize_messaging_target("zalo_oa:12345") == "12345" def test_normalize_zoa_prefix(self): assert normalize_messaging_target("zoa:12345") == "12345" def test_normalize_zl_prefix(self): assert normalize_messaging_target("zl:12345") == "12345" def test_normalize_case_insensitive(self): assert normalize_messaging_target("ZALO_OA:12345") == "12345" def test_no_prefix(self): assert normalize_messaging_target("12345") == "12345" def test_empty_string(self): assert normalize_messaging_target("") == "" def test_looks_like_user_id_numeric(self): assert looks_like_user_id("123456") is True def test_looks_like_user_id_zero(self): assert looks_like_user_id("0") is True def test_looks_like_user_id_not_numeric(self): assert looks_like_user_id("abc123") is False def test_looks_like_user_id_empty(self): assert looks_like_user_id("") is False def test_build_target_id(self): assert build_target_id("12345") == "zalo_oa:12345" # ============================================================================== # Sent Message Cache # ============================================================================== class TestSentMessageCache: def setup_method(self): self.cache = SentMessageCache(ttl_sec=3600) def test_put_and_get(self): self.cache.put("msg1", "user1", "hello") entry = self.cache.get("msg1") assert entry["message_id"] == "msg1" assert entry["recipient"] == "user1" assert entry["content"] == "hello" def test_get_nonexistent(self): assert self.cache.get("nonexistent") is None def test_remove(self): self.cache.put("msg1", "user1", "hello") self.cache.remove("msg1") assert self.cache.get("msg1") is None def test_ttl_expiry(self): cache = SentMessageCache(ttl_sec=0) cache.put("msg1", "user1", "hello") assert cache.get("msg1") is None def test_metadata_storage(self): self.cache.put("msg1", "user1", "hello", metadata={"key": "value"}) entry = self.cache.get("msg1") assert entry["metadata"] == {"key": "value"} def test_size_property(self): assert self.cache.size == 0 self.cache.put("msg1", "user1", "hello") assert self.cache.size == 1 def test_multiple_entries(self): for i in range(5): self.cache.put(f"msg{i}", f"user{i}", f"content{i}") assert self.cache.size == 5 def test_cleanup_on_put(self): cache = SentMessageCache(ttl_sec=0) cache.put("msg1", "user1", "hello") cache.put("msg2", "user2", "world") assert cache.size == 0 # ============================================================================== # Pairing # ============================================================================== class TestPairingStore: def setup_method(self): self.store = PairingStore() def test_generate_code_length(self): code = self.store.generate_code("user1") assert len(code) == 8 assert code == code.upper() def test_verify_correct_code(self): code = self.store.generate_code("user1") assert self.store.verify_code(code, "user1") is True def test_verify_wrong_user(self): code = self.store.generate_code("user1") assert self.store.verify_code(code, "user2") is False def test_verify_wrong_code(self): self.store.generate_code("user1") assert self.store.verify_code("WRONG001", "user1") is False def test_code_cannot_be_used_twice(self): code = self.store.generate_code("user1") assert self.store.verify_code(code, "user1") is True assert self.store.verify_code(code, "user1") is False def test_expired_code(self): code = self.store.generate_code("user1") self.store._codes[code]["created_at"] = time.time() - PAIRING_CODE_TTL_SEC - 1 assert self.store.verify_code(code, "user1") is False def test_cleanup_expired(self): code1 = self.store.generate_code("user1") code2 = self.store.generate_code("user2") self.store._codes[code2]["created_at"] = time.time() - PAIRING_CODE_TTL_SEC - 1 self.store.cleanup_expired() assert code2 not in self.store._codes assert code1 in self.store._codes def test_generate_multiple_codes(self): codes = [self.store.generate_code(f"user{i}") for i in range(5)] assert len(set(codes)) == 5 class TestPairingMessages: def test_build_pairing_message(self): msg = build_pairing_message("ABC12345", "TestOA") assert "Hello from TestOA" in msg assert "ABC12345" in msg assert "expires" in msg def test_build_pairing_message_no_oa_name(self): msg = build_pairing_message("ABC12345") assert "Welcome" in msg assert "ABC12345" in msg def test_build_pairing_notification(self): msg = build_pairing_notification("user1", "TestOA") assert "user1" in msg assert "TestOA" in msg def test_build_pairing_notification_no_name(self): msg = build_pairing_notification("user1", "") assert "user1" in msg def test_build_pairing_success(self): msg = build_pairing_success_message("TestOA") assert "Pairing successful" in msg assert "TestOA" in msg # ============================================================================== # Webhook Rate Limiter # ============================================================================== class TestWebhookRateLimiter: def setup_method(self): self.limiter = WebhookRateLimiter(window_ms=60000, max_requests=5) def test_initial_allow(self): for i in range(5): assert self.limiter.is_allowed(f"key_{i}") is True def test_rate_limit_exceeded(self): key = "test_key" for _ in range(5): assert self.limiter.is_allowed(key) is True assert self.limiter.is_allowed(key) is False def test_different_keys_independent(self): for _ in range(5): assert self.limiter.is_allowed("key_a") is True assert self.limiter.is_allowed("key_b") is True def test_cleanup_removes_empty_buckets(self): assert self.limiter.is_allowed("temp_key") is True self.limiter.cleanup() def test_window_expiry(self): limiter = WebhookRateLimiter(window_ms=1, max_requests=3) key = "fast_key" for _ in range(3): assert limiter.is_allowed(key) is True assert limiter.is_allowed(key) is False time.sleep(0.002) assert limiter.is_allowed(key) is True def test_large_max_requests(self): limiter = WebhookRateLimiter(max_requests=1000) for i in range(500): assert limiter.is_allowed(f"key_{i}") is True class TestRateLimitKeyAndIP: def test_build_key_with_ip(self): key = build_rate_limit_key("user1", "192.168.1.1") assert key == "user1:192.168.1.1" def test_build_key_without_ip(self): key = build_rate_limit_key("user1") assert key == "user1" def test_build_key_empty_ip(self): key = build_rate_limit_key("user1", "") assert key == "user1" def test_resolve_client_ip_none_headers(self): assert resolve_client_ip(None) == "" def test_resolve_client_ip_x_forwarded_for(self): headers = {"x-forwarded-for": "10.0.0.1, 10.0.0.2"} assert resolve_client_ip(headers) == "10.0.0.1" def test_resolve_client_ip_x_real_ip(self): headers = {"x-real-ip": "10.0.0.5"} assert resolve_client_ip(headers) == "10.0.0.5" def test_resolve_client_ip_no_headers(self): headers = {} assert resolve_client_ip(headers) == "" def test_resolve_client_ip_with_trusted_proxies(self): headers = {"x-forwarded-for": "10.0.0.1, 10.0.0.2"} result = resolve_client_ip(headers, ["10.0.0.1"]) assert result == "10.0.0.2" def test_resolve_client_ip_all_trusted(self): headers = {"x-forwarded-for": "10.0.0.1"} result = resolve_client_ip(headers, ["10.0.0.1"]) assert result == "10.0.0.1" def test_resolve_client_ip_empty_forwarded(self): headers = {"x-forwarded-for": ""} assert resolve_client_ip(headers) == "" # ============================================================================== # Approval # ============================================================================== class TestApproval: def test_check_approval_required_disabled(self): config = {"requireExecApproval": False} assert check_approval_required("send", config) is False def test_check_approval_required_enabled(self): config = {"requireExecApproval": True} assert check_approval_required("send", config) is True def test_check_approval_required_exempt(self): config = {"requireExecApproval": True, "approvalExemptActions": ["send"]} assert check_approval_required("send", config) is False def test_check_approval_required_not_exempt(self): config = {"requireExecApproval": True, "approvalExemptActions": ["ping"]} assert check_approval_required("send", config) is True def test_build_approval_request(self): result = build_approval_request("send", "user1", {"key": "val"}) assert result["action"] == "send" assert result["requester_id"] == "user1" assert result["status"] == "pending" assert result["params"] == {"key": "val"} def test_build_approval_request_no_params(self): result = build_approval_request("send", "user1") assert result["params"] == {} def test_resolve_approvers_empty(self): result = resolve_approvers({}) assert result == [] def test_resolve_approvers_from_config(self): config = {"approvers": ["user1", "zoa:user2"]} result = resolve_approvers(config) assert result == ["user1", "user2"] def test_resolve_approvers_from_allowlist(self): config = {"allowFrom": ["user1", "zalo_oa:user2"]} result = resolve_approvers(config) assert result == ["user1", "user2"] def test_resolve_approvers_with_exec_name(self): config = {"execApprovers": ["user1", "user2"]} result = resolve_approvers(config) assert "user1" in result def test_normalize_approver_id_zoa(self): assert normalize_approver_id("zoa:user1") == "user1" def test_normalize_approver_id_zalo_oa(self): assert normalize_approver_id("zalo_oa:user1") == "user1" def test_normalize_approver_id_no_prefix(self): assert normalize_approver_id("user1") == "user1" # ============================================================================== # Audit Logger # ============================================================================== class TestAuditLogger: def setup_method(self): self.audit = AuditLogger(enabled=True, log_level="info") def test_record_event(self): self.audit.record(AuditEventType.MESSAGE_SENT, {"recipient": "user1"}) assert self.audit.total_entries == 1 def test_record_string_event_type(self): self.audit.record("custom.event", {"key": "val"}) assert self.audit.total_entries == 1 def test_disabled_logger_ignores_records(self): audit = AuditLogger(enabled=False) audit.record(AuditEventType.MESSAGE_SENT) assert audit.total_entries == 0 def test_get_entries_all(self): self.audit.record(AuditEventType.MESSAGE_SENT) self.audit.record(AuditEventType.MESSAGE_RECEIVED) entries = self.audit.get_entries() assert len(entries) == 2 def test_get_entries_filtered(self): self.audit.record(AuditEventType.MESSAGE_SENT) self.audit.record(AuditEventType.TOKEN_REFRESHED) entries = self.audit.get_entries(event_type=AuditEventType.MESSAGE_SENT) assert len(entries) == 1 assert entries[0]["event_type"] == AuditEventType.MESSAGE_SENT def test_get_summary(self): self.audit.record(AuditEventType.MESSAGE_SENT) self.audit.record(AuditEventType.MESSAGE_SENT) self.audit.record(AuditEventType.AUTH_FAILURE) summary = self.audit.get_summary() assert summary["total_entries"] == 3 assert summary["event_counts"]["message.sent"] == 2 assert summary["event_counts"]["auth.failure"] == 1 def test_max_entries_limit(self): audit = AuditLogger(max_entries=5) for i in range(10): audit.record(AuditEventType.MESSAGE_SENT, {"index": i}) assert audit.total_entries == 5 def test_entry_has_timestamp(self): self.audit.record(AuditEventType.MESSAGE_SENT) entries = self.audit.get_entries() assert "timestamp" in entries[0] def test_all_event_types_defined(self): assert AuditEventType.MESSAGE_SENT == "message.sent" assert AuditEventType.MESSAGE_RECEIVED == "message.received" assert AuditEventType.MESSAGE_FAILED == "message.failed" assert AuditEventType.MESSAGE_RECALLED == "message.recalled" assert AuditEventType.TOKEN_REFRESHED == "token.refreshed" assert AuditEventType.TOKEN_REFRESH_FAILED == "token.refresh_failed" assert AuditEventType.AUTH_FAILURE == "auth.failure" assert AuditEventType.WEBOOK_RECEIVED == "webhook.received" assert AuditEventType.WEBOOK_RATE_LIMITED == "webhook.rate_limited" assert AuditEventType.PAIRING_CREATED == "pairing.created" assert AuditEventType.PAIRING_VERIFIED == "pairing.verified" assert AuditEventType.CONFIG_CHANGED == "config.changed" assert AuditEventType.CONNECTION_STATE == "connection.state" # ============================================================================== # Accounts # ============================================================================== class TestAccounts: def test_list_account_ids_no_accounts(self): result = list_account_ids({"app_id": "test"}) assert result == ["default"] def test_list_account_ids_empty_accounts(self): result = list_account_ids({"accounts": {}}) assert result == ["default"] def test_list_account_ids_with_accounts(self): config = {"accounts": {"acct1": {}, "acct2": {}}} result = list_account_ids(config) assert sorted(result) == ["acct1", "acct2"] def test_list_account_ids_non_dict(self): result = list_account_ids({"accounts": "invalid"}) assert result == ["default"] def test_get_default_account_id_no_default(self): result = get_default_account_id({"app_id": "test"}) assert result == "default" def test_get_default_account_id_with_default(self): config = {"defaultAccount": "prod", "accounts": {"prod": {"app_id": "x"}, "staging": {"app_id": "y"}}} result = get_default_account_id(config) assert result == "prod" def test_get_default_account_id_default_account_key(self): config = {"default_account": "staging", "accounts": {"prod": {}, "staging": {}}} result = get_default_account_id(config) assert result == "staging" def test_resolve_account_no_accounts(self): config = {"app_id": "test", "secret_key": "key"} result = resolve_account(config) assert result["app_id"] == "test" def test_resolve_account_with_accounts(self): config = { "dm_policy": "open", "accounts": {"acct1": {"app_id": "id1", "secret_key": "key1", "name": "Account 1"}}, } result = resolve_account(config) assert result["app_id"] == "id1" assert result["name"] == "Account 1" def test_resolve_account_with_default_account(self): config = { "defaultAccount": "prod", "accounts": { "prod": {"app_id": "prod_id", "name": "Production"}, "staging": {"app_id": "staging_id", "name": "Staging"}, }, } result = resolve_account(config) assert result["app_id"] == "prod_id" def test_resolve_account_specific_id(self): config = { "accounts": { "prod": {"app_id": "prod_id", "name": "Production"}, "staging": {"app_id": "staging_id", "name": "Staging"}, }, } result = resolve_account(config, account_id="staging") assert result["app_id"] == "staging_id" def test_resolve_account_top_level_merge(self): config = {"dm_policy": "disabled", "webhook": {"url": "https://ex.com"}, "accounts": {"acct1": {"app_id": "id1"}}} result = resolve_account(config) assert result["dm_policy"] == "disabled" assert result["app_id"] == "id1" # ============================================================================== # Webhook Anomaly Tracker # ============================================================================== class TestWebhookAnomalyTracker: def setup_method(self): self.tracker = WebhookAnomalyTracker() def test_record_anomaly(self): self.tracker.record(400, "10.0.0.1", "/webhook", "Bad request") assert self.tracker.total_anomalies == 1 def test_total_anomalies_count(self): for i in range(5): self.tracker.record(400, f"10.0.0.{i}") assert self.tracker.total_anomalies == 5 def test_get_anomalies(self): self.tracker.record(401, "10.0.0.1") self.tracker.record(413, "10.0.0.2") anomalies = self.tracker.get_anomalies() assert len(anomalies) == 2 def test_get_alerts_initially_empty(self): alerts = self.tracker.get_alerts() assert isinstance(alerts, list) def test_alert_thresholds_defined(self): assert ALERT_THRESHOLD_401 == 10 assert ALERT_THRESHOLD_400 == 20 assert ALERT_THRESHOLD_413 == 5 assert ALERT_THRESHOLD_429 == 10 def test_record_with_minimal_args(self): self.tracker.record(200) assert self.tracker.total_anomalies == 1 # ============================================================================== # Message Actions # ============================================================================== class TestMessageActions: def test_supported_actions_list(self): assert "send" in SUPPORTED_ACTIONS assert "sticker" in SUPPORTED_ACTIONS assert "list" in SUPPORTED_ACTIONS assert "broadcast" in SUPPORTED_ACTIONS assert "unsend" in SUPPORTED_ACTIONS def test_all_actions_supported(self): for action in SUPPORTED_ACTIONS: assert is_action_supported(action) is True def test_unsupported_action(self): assert is_action_supported("nonexistent") is False def test_describe_actions_returns_all(self): actions = describe_actions() action_names = [a["action"] for a in actions] for supported in SUPPORTED_ACTIONS: assert supported in action_names def test_build_action_handler_send(self): formatter = MagicMock() formatter._build_text.return_value = {"text": "hello"} result = build_action_handler("send", {"recipient_id": "user1", "content": "hello"}, formatter) formatter._build_text.assert_called_once_with("user1", "hello") def test_build_action_handler_sticker(self): formatter = MagicMock() formatter._build_sticker.return_value = {"sticker": "123"} result = build_action_handler("sticker", {"recipient_id": "user1", "sticker_id": "123"}, formatter) formatter._build_sticker.assert_called_once() def test_build_action_handler_list(self): formatter = MagicMock() formatter.build_list.return_value = {"list": "test"} result = build_action_handler( "list", {"recipient_id": "user1", "elements": [{"title": "Item"}], "buttons": None}, formatter ) formatter.build_list.assert_called_once() def test_build_action_handler_broadcast(self): result = build_action_handler("broadcast", {"content": "Hello all"}, MagicMock()) assert result is None def test_build_action_handler_unsend_no_client(self): result = build_action_handler("unsend", {"message_id": "m1", "user_id": "u1"}, None, None, client=None) assert result is None def test_build_action_handler_unsend_with_client(self): result = build_action_handler("unsend", {"message_id": "m1", "user_id": "u1"}, None, None, client="mock") assert result["_unsend_message_id"] == "m1" assert result["_unsend_user_id"] == "u1" def test_build_action_handler_unknown(self): result = build_action_handler("unknown", {}, MagicMock()) assert result is None # ============================================================================== # Session Router (Extended) # ============================================================================== class TestSessionRouterExtended: def setup_method(self): self.router = SessionRouter(ttl_sec=3600) def test_resolve_bind_key(self): key = self.router.resolve_bind_key("agent1") assert key == "agent:agent1:zalo_oa:default" def test_resolve_bind_key_custom_account(self): key = self.router.resolve_bind_key("agent1", "prod") assert key == "agent:agent1:zalo_oa:prod" def test_bind_and_check(self): self.router.bind_agent_account("agent1", "default") assert self.router.is_bound("agent1", "default") is True def test_unbind(self): self.router.bind_agent_account("agent1", "default") self.router.unbind_agent_account("agent1", "default") assert self.router.is_bound("agent1", "default") is False def test_binding_ttl_expiry(self): router = SessionRouter(ttl_sec=3600) router.bind_agent_account("agent1", "default") for bind_key, entry in list(router._bindings.items()): entry["created_at"] = time.time() - 99999 assert router.is_bound("agent1", "default") is False def test_session_set_and_get(self): thread_key = self.router.resolve_thread_key("agent1", "user1") self.router.set_session(thread_key, {"data": "test_value"}) session = self.router.get_session(thread_key) assert session == {"data": "test_value"} def test_session_clear(self): thread_key = self.router.resolve_thread_key("agent1", "user1") self.router.set_session(thread_key, {"data": "test"}) self.router.clear_session(thread_key) assert self.router.get_session(thread_key) is None def test_get_bound_sessions(self): self.router.bind_agent_account("agent1", "default") thread_key = self.router.resolve_thread_key("agent1", "user1") self.router.set_session(thread_key, {"follower_id": "user1"}) sessions = self.router.get_bound_sessions("agent1", "default") assert len(sessions) >= 1 assert sessions[0]["follower_id"] == "user1" def test_get_bound_sessions_no_bound(self): sessions = self.router.get_bound_sessions("agent1", "default") assert sessions == [] def test_cleanup_expired_count(self): thread_key = self.router.resolve_thread_key("agent1", "expired_user") self.router.set_session(thread_key, {"data": "test"}) self.router._sessions[thread_key]["created_at"] = time.time() - 99999 removed = self.router.cleanup_expired() assert removed >= 1 def test_binding_count(self): assert self.router.binding_count == 0 self.router.bind_agent_account("agent1", "default") assert self.router.binding_count == 1 def test_get_binding(self): self.router.bind_agent_account("agent1", "default", {"extra": "info"}) binding = self.router.get_binding("agent1", "default") assert binding["agent_id"] == "agent1" assert binding["data"] == {"extra": "info"} def test_get_binding_expired(self): self.router.bind_agent_account("agent1", "default") for bind_key, entry in list(self.router._bindings.items()): entry["created_at"] = time.time() - 99999 assert self.router.get_binding("agent1", "default") is None def test_resolve_session_params_default(self): params = self.router.resolve_session_params("user1") assert params["chat_type"] == ChatType.DIRECT assert params["channel_chat_id"] == "user1" def test_resolve_session_params_full(self): params = self.router.resolve_session_params("user1", sender_name="John", avatar="url", dm_policy="open", account_id="prod") assert params["sender_name"] == "John" assert params["avatar"] == "url" assert params["dm_policy"] == "open" assert params["account_id"] == "prod" class TestGlobalSessionFunctions: def test_resolve_session_params_global(self): params = resolve_session_params("user1", sender_name="Test") assert params["chat_type"] == ChatType.DIRECT def test_get_session_router(self): router = get_session_router() assert isinstance(router, SessionRouter) # ============================================================================== # Formatter (Extended) # ============================================================================== class TestFormatterExtended: 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_build_button_url(self): button = self.formatter.build_button_url("Open", "https://example.com") assert button["title"] == "Open" assert button["type"] == "oa.open.url" assert button["payload"]["url"] == "https://example.com" def test_build_button_query(self): button = self.formatter.build_button_query("Query", "search_term") assert button["title"] == "Query" assert button["type"] == "oa.query.show" assert button["payload"] == "search_term" def test_build_button_hide(self): button = self.formatter.build_button_hide() assert button["type"] == "oa.query.hide" def test_build_button_hide_custom_title(self): button = self.formatter.build_button_hide("Đóng") assert button["title"] == "Đóng" def test_build_inline_keyboard(self): buttons = [{"title": "Click", "type": "oa.open.url", "payload": {"url": "https://ex.com"}}] result = self.formatter.build_inline_keyboard("user1", "Choose:", buttons) assert result["recipient"]["user_id"] == "user1" assert result["message"]["text"] == "Choose:" assert result["message"]["attachment"]["payload"]["template_type"] == "button" def test_build_image_template_basic(self): result = self.formatter.build_image_template("user1", "att_001") assert result["recipient"]["user_id"] == "user1" assert result["message"]["attachment"]["payload"]["elements"][0]["attachment_id"] == "att_001" def test_build_image_template_with_title_subtitle(self): result = self.formatter.build_image_template("user1", "att_001", title="My Image", subtitle="Beautiful") elem = result["message"]["attachment"]["payload"]["elements"][0] assert elem["title"] == "My Image" assert elem["subtitle"] == "Beautiful" def test_build_image_template_with_buttons(self): btn = [{"title": "View", "type": "oa.open.url", "payload": {"url": "https://ex.com"}}] result = self.formatter.build_image_template("user1", "att_001", buttons=btn) assert "buttons" in result["message"]["attachment"]["payload"] def test_build_text_template_basic(self): result = self.formatter.build_text_template("user1", "Hello") elem = result["message"]["attachment"]["payload"]["elements"][0] assert elem["title"] == "Hello" def test_build_text_template_full(self): btn = [self.formatter.build_button_url("Open", "https://ex.com")] result = self.formatter.build_text_template("user1", "Title", subtitle="Sub", description="Desc", buttons=btn) elem = result["message"]["attachment"]["payload"]["elements"][0] assert elem["title"] == "Title" assert elem["subtitle"] == "Sub" assert elem["description"] == "Desc" def test_format_sticker(self): response = self._make_response("", message_type=MessageType.STICKER, metadata={"sticker_id": "stk_001"}) result = self.formatter.format(response) elem = result["message"]["attachment"]["payload"]["elements"][0] assert elem["media_type"] == "sticker" assert elem["attachment_id"] == "stk_001" def test_format_location_via_format(self): response = self._make_response("", message_type=MessageType.LOCATION, metadata={"lat": "10.0", "lon": "106.0"}) result = self.formatter.format(response) elem = result["message"]["attachment"]["payload"]["elements"][0] assert elem["media_type"] == "location" assert elem["latitude"] == 10.0 assert elem["longitude"] == 106.0 # ============================================================================== # Outbound Media Host # ============================================================================== class TestOutboundMediaHost: def setup_method(self): self.host = OutboundMediaHost(ttl_sec=300, max_entries=10) def test_store_and_get(self): ref_id = self.host.store("att_001", "image") entry = self.host.get(ref_id) assert entry["attachment_id"] == "att_001" assert entry["media_type"] == "image" def test_get_nonexistent(self): assert self.host.get("nonexistent") is None def test_ttl_expiry(self): host = OutboundMediaHost(ttl_sec=0) ref_id = host.store("att_001", "image") assert host.get(ref_id) is None def test_resolve_attachment_id(self): ref_id = self.host.store("att_001", "image") assert self.host.resolve_attachment_id(ref_id) == "att_001" def test_resolve_attachment_id_nonexistent(self): assert self.host.resolve_attachment_id("nonexistent") is None def test_remove(self): ref_id = self.host.store("att_001", "image") self.host.remove(ref_id) assert self.host.get(ref_id) is None def test_active_count(self): assert self.host.active_count == 0 self.host.store("att_001", "image") assert self.host.active_count == 1 def test_max_entries_eviction(self): host = OutboundMediaHost(max_entries=3) for i in range(5): host.store(f"att_{i}", "image") assert host.active_count <= 3 def test_cleanup_expired(self): host = OutboundMediaHost(ttl_sec=0) for i in range(3): host.store(f"att_{i}", "image") removed = host.cleanup_expired() assert removed == 3 def test_store_with_metadata(self): ref_id = self.host.store("att_001", "image", {"filename": "test.jpg"}) entry = self.host.get(ref_id) assert entry["metadata"]["filename"] == "test.jpg" class TestGlobalOutboundMediaFunctions: def test_store_get_cycle(self): ref_id = store_media("att_global", "image") entry = get_media(ref_id) assert entry["attachment_id"] == "att_global" def test_resolve_attachment_func(self): ref_id = store_media("att_global", "image") assert resolve_attachment(ref_id) == "att_global" def test_cleanup_media_cache(self): store_media("att_temp", "image") removed = cleanup_media_cache() assert isinstance(removed, int) # ============================================================================== # Config Schema # ============================================================================== class TestConfigSchema: def test_zalo_oa_config_defaults(self): config = ZaloOAConfig() assert config.enabled is True assert config.dm_policy == "open" assert config.polling_enabled is True assert config.voice_tts_enabled is False def test_zalo_oa_account_config_defaults(self): config = ZaloOAAccountConfig() assert config.app_id == "" assert config.secret_key == "" assert config.enabled is True assert config.dm_policy == "open" assert config.polling_enabled is True assert config.voice_tts_enabled is False assert config.heartbeat_interval_sec == 30 def test_to_flat_config_no_accounts(self): config = ZaloOAConfig(dm_policy="allowlist", enabled=False) flat = config.to_flat_config() assert flat["dm_policy"] == "allowlist" assert flat["enabled"] is False def test_to_flat_config_with_accounts(self): config = ZaloOAConfig( dm_policy="allowlist", accounts={"prod": ZaloOAAccountConfig(app_id="app_id_1", secret_key="secret_1", dm_policy="allowlist", name="Production")}, ) flat = config.to_flat_config() assert flat["app_id"] == "app_id_1" assert flat["dm_policy"] == "allowlist" def test_config_field_limits(self): config = ZaloOAConfig( rate_limit_window_ms=60000, rate_limit_max_requests=60, health_check_ttl_sec=120, dedup_window_ms=500000, ) assert config.rate_limit_window_ms == 60000 assert config.rate_limit_max_requests == 60 assert config.health_check_ttl_sec == 120 assert config.dedup_window_ms == 500000 # ============================================================================== # Polling # ============================================================================== class TestPollingExtended: def test_poller_initial_metrics(self): poller = ZaloOAPoller(client=None, config={"polling_interval_sec": 10, "polling_timeout_ms": 5000}) metrics = poller.get_poll_metrics() assert metrics["running"] is False assert metrics["poll_count"] == 0 assert metrics["error_count"] == 0 assert metrics["interval_sec"] == 10 assert metrics["timeout_ms"] == 5000 def test_poller_with_callback(self): cb = MagicMock() poller = ZaloOAPoller(client=None, message_callback=cb) assert poller._message_callback == cb def test_poller_last_poll_at_initial(self): poller = ZaloOAPoller(client=None) metrics = poller.get_poll_metrics() assert metrics["last_poll_at"] is None def test_poller_follower_delta_initial(self): poller = ZaloOAPoller(client=None) metrics = poller.get_poll_metrics() assert metrics["follower_delta_total"] == 0 # ============================================================================== # Voice # ============================================================================== class TestVoiceExtended: def test_voice_defaults(self): voice = ZaloOAVoice() assert voice.enabled is False assert voice.synthesis_target == "voice-note" def test_voice_enabled_with_api_url(self): voice = ZaloOAVoice(config={"voice_tts_enabled": True, "voice_tts_api_url": "https://api.tts.com"}) assert voice.enabled is True def test_voice_disabled_without_api_url_but_enabled(self): voice = ZaloOAVoice(config={"voice_tts_enabled": True}) assert voice.enabled is False def test_voice_no_config(self): voice = ZaloOAVoice() assert voice.enabled is False @pytest.mark.asyncio async def test_synthesize_and_send_not_enabled(self): voice = ZaloOAVoice() result = await voice.synthesize_and_send("hello", "user1", None) assert result.success is False assert "not configured" in result.error def test_voice_custom_timeout(self): voice = ZaloOAVoice(config={"voice_tts_enabled": True, "voice_tts_api_url": "https://api.com", "voice_tts_timeout_sec": 60}) assert voice.enabled is True # ============================================================================== # Normalizer - Additional Edge Cases # ============================================================================== class TestNormalizerEdgeCases: def setup_method(self): self.normalizer = ZaloOAEventNormalizer() def test_user_submit_form(self): payload = { "event_name": "user_submit_form", "sender": {"id": "u1"}, "recipient": {"id": "oa1"}, "message": {"text": "Form submitted with data"}, "timestamp": "1", } result = self.normalizer.normalize(payload) assert result.event_type == EventType.CARD_ACTION assert "Form submitted" in result.content def test_user_click_button(self): payload = { "event_name": "user_click_button", "sender": {"id": "u1"}, "recipient": {"id": "oa1"}, "message": {"payload": {"label": "Click me"}}, "timestamp": "1", } result = self.normalizer.normalize(payload) assert result.event_type == EventType.CARD_ACTION assert result.content == "Click me" def test_user_send_gif(self): payload = { "event_name": "user_send_gif", "sender": {"id": "u1"}, "recipient": {"id": "oa1"}, "message": {"msg_id": "gif1", "attachments": [{"type": "image", "payload": {"url": "https://ex.com/gif.gif", "id": "g1"}}]}, "timestamp": "1", } result = self.normalizer.normalize(payload) assert result.message_type == MessageType.IMAGE def test_text_with_mentions(self): payload = { "event_name": "user_send_text", "sender": {"id": "u1"}, "recipient": {"id": "oa1"}, "message": {"msg_id": "m1", "text": "@user1 @user2 hello there"}, "timestamp": "1", } result = self.normalizer.normalize(payload) assert "[mentions:" in result.content assert "user1" in result.content assert "user2" in result.content def test_unknown_event_name_defaults_to_text(self): payload = { "event_name": "user_send_unknown", "sender": {"id": "u1"}, "recipient": {"id": "oa1"}, "message": {"msg_id": "m1", "text": "unknown type"}, "timestamp": "1", } result = self.normalizer.normalize(payload) assert result.message_type == MessageType.TEXT assert result.event_type == EventType.MESSAGE_RECEIVED def test_missing_fields_handled_gracefully(self): payload = {"event_name": "follow"} result = self.normalizer.normalize(payload) assert result.content == "Follower followed OA" assert result.identity.channel_user_id == "unknown" assert result.identity.channel_chat_id == "" class TestSkipMessageError: def test_can_be_raised_and_caught(self): try: raise SkipMessageError("test skip") except SkipMessageError as e: assert str(e) == "test skip"