from __future__ import annotations import asyncio import os import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.adapters.zalo_user.constants import ( ZALO_TEXT_LIMIT, ZALO_REACTION_EMOJIS, REACTION_ALIAS_MAP, TARGET_PREFIXES, DEFAULT_BRIDGE_URL, DEFAULT_MAX_SEND_PER_MINUTE, DEFAULT_CIRCUIT_BREAKER_THRESHOLD, DEFAULT_CIRCUIT_BREAKER_RECOVERY, ZaloCredentialStage, ) from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelResponse, ChannelType, ChatType, DeliveryResult, EventType, MessageType, HealthStatus, ChannelStatus, ) # ======================================================================== # BackoffManager # ======================================================================== class TestBackoffManager: def test_initial_state(self): from yuxi.channels.adapters.zalo_user.backoff import BackoffManager bm = BackoffManager() assert bm.attempts == 0 def test_should_reconnect_within_limit(self): from yuxi.channels.adapters.zalo_user.backoff import BackoffManager bm = BackoffManager() assert bm.should_reconnect(max_attempts=10) is True def test_should_reconnect_exceeds_limit(self): from yuxi.channels.adapters.zalo_user.backoff import BackoffManager bm = BackoffManager() bm._attempt = 10 assert bm.should_reconnect(max_attempts=10) is False def test_reset(self): from yuxi.channels.adapters.zalo_user.backoff import BackoffManager bm = BackoffManager() bm._attempt = 5 bm.reset() assert bm.attempts == 0 @pytest.mark.asyncio async def test_wait_increments_attempt(self): from yuxi.channels.adapters.zalo_user.backoff import BackoffManager bm = BackoffManager(base_delay=0.001, max_delay=0.01) await bm.wait() assert bm.attempts == 1 await bm.wait() assert bm.attempts == 2 @pytest.mark.asyncio async def test_wait_respects_max_delay(self): from yuxi.channels.adapters.zalo_user.backoff import BackoffManager bm = BackoffManager(base_delay=0.001, max_delay=0.005, multiplier=1000, jitter=0) await bm.wait() await bm.wait() def test_custom_parameters(self): from yuxi.channels.adapters.zalo_user.backoff import BackoffManager bm = BackoffManager(base_delay=5.0, max_delay=120.0, multiplier=3.0, jitter=0.2) assert bm._base_delay == 5.0 assert bm._max_delay == 120.0 # ======================================================================== # MessageDedup # ======================================================================== class TestMessageDedup: def test_initial_state(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup() assert len(md) == 0 def test_first_message_not_duplicate(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup() assert md.is_duplicate("msg_001") is False def test_check_and_mark_first_time(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup() assert md.check_and_mark("msg_001") is False def test_check_and_mark_duplicate(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup() md.mark_seen("msg_001") assert md.check_and_mark("msg_001") is True def test_is_duplicate_after_mark(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup() md.mark_seen("msg_001") assert md.is_duplicate("msg_001") is True def test_multiple_messages(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup() for i in range(10): assert md.check_and_mark(f"msg_{i:03d}") is False for i in range(10): assert md.check_and_mark(f"msg_{i:03d}") is True def test_clear(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup() md.mark_seen("msg_001") md.clear() assert len(md) == 0 def test_max_size_eviction(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup(max_size=5) for i in range(10): md.mark_seen(f"msg_{i:03d}") assert len(md) <= 5 def test_custom_ttl(self): from yuxi.channels.adapters.zalo_user.dedup import MessageDedup md = MessageDedup(ttl=1) md.mark_seen("old_msg") md._seen["old_msg"] = time.monotonic() - 999 assert len(md) == 0 # ======================================================================== # RateLimiter # ======================================================================== class TestRateLimiterComprehensive: def test_initial_state(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter rl = RateLimiter() assert rl.max_per_minute == DEFAULT_MAX_SEND_PER_MINUTE def test_custom_limit(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter rl = RateLimiter(max_per_minute=10) assert rl.max_per_minute == 10 @pytest.mark.asyncio async def test_check_and_wait_within_limit(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter rl = RateLimiter(max_per_minute=10) rl._send_times.clear() await rl.check_and_wait() assert len(rl._send_times) == 1 @pytest.mark.asyncio async def test_check_and_wait_appends_timestamp(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter rl = RateLimiter(max_per_minute=10) rl._send_times.clear() await rl.check_and_wait() assert len(rl._send_times) == 1 @pytest.mark.asyncio async def test_check_and_wait_below_limit(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter rl = RateLimiter(max_per_minute=5) now = time.monotonic() rl._send_times.extend([now - 1]) await rl.check_and_wait() assert len(rl._send_times) == 2 def test_cleanup_removes_expired(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter rl = RateLimiter(max_per_minute=10) now = time.monotonic() rl._send_times.extend([now - 120, now - 90, now - 61, now - 30, now - 5]) rl._cleanup() assert len(rl._send_times) == 2 assert rl._send_times[0] == now - 30 assert rl._send_times[1] == now - 5 def test_cleanup_empty(self): from yuxi.channels.adapters.zalo_user.rate_limiter import RateLimiter rl = RateLimiter() rl._cleanup() assert len(rl._send_times) == 0 # ======================================================================== # OutboundSequencer # ======================================================================== class TestOutboundSequencer: def test_initial_state(self): from yuxi.channels.adapters.zalo_user.send import OutboundSequencer seq = OutboundSequencer() assert seq._seq == 0 def test_next_increments(self): from yuxi.channels.adapters.zalo_user.send import OutboundSequencer seq = OutboundSequencer() assert seq.next() == 1 assert seq.next() == 2 def test_next_with_chat_id(self): from yuxi.channels.adapters.zalo_user.send import OutboundSequencer seq = OutboundSequencer() seq.next("chat_a") assert seq.last_for_chat("chat_a") == 1 seq.next("chat_b") assert seq.last_for_chat("chat_b") == 2 def test_last_for_chat_unknown_returns_zero(self): from yuxi.channels.adapters.zalo_user.send import OutboundSequencer seq = OutboundSequencer() assert seq.last_for_chat("unknown") == 0 def test_reset(self): from yuxi.channels.adapters.zalo_user.send import OutboundSequencer seq = OutboundSequencer() seq.next("chat_a") seq.next("chat_b") seq.reset() assert seq._seq == 0 assert seq.last_for_chat("chat_a") == 0 assert seq.last_for_chat("chat_b") == 0 # ======================================================================== # MessageIdTracker + message_sid functions # ======================================================================== class TestMessageSidFunctions: def test_resolve_message_ids_single(self): from yuxi.channels.adapters.zalo_user.message_sid import resolve_message_ids result = resolve_message_ids("msg_001") assert result == {"msg_id": "msg_001", "cli_msg_id": None} def test_resolve_message_ids_dual(self): from yuxi.channels.adapters.zalo_user.message_sid import resolve_message_ids result = resolve_message_ids("msg_001:cli_001") assert result == {"msg_id": "msg_001", "cli_msg_id": "cli_001"} def test_format_message_id_no_cli(self): from yuxi.channels.adapters.zalo_user.message_sid import format_message_id assert format_message_id("msg_001") == "msg_001" def test_format_message_id_with_cli(self): from yuxi.channels.adapters.zalo_user.message_sid import format_message_id assert format_message_id("msg_001", "cli_001") == "msg_001:cli_001" def test_extract_msg_id_no_cli(self): from yuxi.channels.adapters.zalo_user.message_sid import extract_msg_id result = extract_msg_id({"message_id": "msg_001"}) assert result == "msg_001" def test_extract_msg_id_with_cli(self): from yuxi.channels.adapters.zalo_user.message_sid import extract_msg_id result = extract_msg_id({"message_id": "msg_001", "cli_msg_id": "cli_001"}) assert result == "msg_001:cli_001" class TestMessageIdTracker: def test_initial_state(self): from yuxi.channels.adapters.zalo_user.message_sid import MessageIdTracker tracker = MessageIdTracker() assert len(tracker) == 0 def test_track_and_lookup(self): from yuxi.channels.adapters.zalo_user.message_sid import MessageIdTracker tracker = MessageIdTracker() tracker.track("msg_001", "conv_abc") assert tracker.lookup("msg_001") == "conv_abc" def test_lookup_unknown(self): from yuxi.channels.adapters.zalo_user.message_sid import MessageIdTracker tracker = MessageIdTracker() assert tracker.lookup("unknown") is None def test_resolve(self): from yuxi.channels.adapters.zalo_user.message_sid import MessageIdTracker tracker = MessageIdTracker() tracker.track("msg_001", "conv_abc") result = tracker.resolve("msg_001") assert result["msg_id"] == "msg_001" assert result["conversation_id"] == "conv_abc" def test_resolve_with_cli(self): from yuxi.channels.adapters.zalo_user.message_sid import MessageIdTracker tracker = MessageIdTracker() tracker.track("msg_001:cli_001", "conv_abc") result = tracker.resolve("msg_001:cli_001") assert result["msg_id"] == "msg_001" assert result["cli_msg_id"] == "cli_001" assert result["conversation_id"] == "conv_abc" def test_max_size_eviction(self): from yuxi.channels.adapters.zalo_user.message_sid import MessageIdTracker tracker = MessageIdTracker(max_size=5) for i in range(10): tracker.track(f"msg_{i:03d}", f"conv_{i:03d}") assert len(tracker) <= 5 def test_clear(self): from yuxi.channels.adapters.zalo_user.message_sid import MessageIdTracker tracker = MessageIdTracker() tracker.track("msg_001", "conv_abc") tracker.clear() assert len(tracker) == 0 # ======================================================================== # SendCache # ======================================================================== class TestSendCache: def test_first_send_not_cached(self): from yuxi.channels.adapters.zalo_user.send_cache import SendCache sc = SendCache() assert sc.check_and_set("chat_1", "hello", 1) is True def test_duplicate_send_cached(self): from yuxi.channels.adapters.zalo_user.send_cache import SendCache sc = SendCache() sc.check_and_set("chat_1", "hello", 1) assert sc.check_and_set("chat_1", "hello", 1) is False def test_different_seq_not_duplicate(self): from yuxi.channels.adapters.zalo_user.send_cache import SendCache sc = SendCache() sc.check_and_set("chat_1", "hello", 1) assert sc.check_and_set("chat_1", "hello", 2) is True def test_different_chat_not_duplicate(self): from yuxi.channels.adapters.zalo_user.send_cache import SendCache sc = SendCache() sc.check_and_set("chat_1", "hello", 1) assert sc.check_and_set("chat_2", "hello", 1) is True def test_clear(self): from yuxi.channels.adapters.zalo_user.send_cache import SendCache sc = SendCache() sc.check_and_set("chat_1", "hello") sc.clear() assert len(sc) == 0 def test_max_entries_eviction(self): from yuxi.channels.adapters.zalo_user.send_cache import SendCache sc = SendCache(max_entries=5) for i in range(10): sc.check_and_set(f"chat_{i}", f"msg_{i}") assert len(sc) <= 5 # ======================================================================== # allow_from # ======================================================================== class TestAllowFrom: def test_resolve_empty_entries(self): from yuxi.channels.adapters.zalo_user.allow_from import resolve_allow_from_entries result = resolve_allow_from_entries([], []) assert result == [] def test_resolve_zalo_prefix_numeric(self): from yuxi.channels.adapters.zalo_user.allow_from import resolve_allow_from_entries result = resolve_allow_from_entries(["zalo:12345"], []) assert result == ["12345"] def test_resolve_zalo_prefix_by_name(self): from yuxi.channels.adapters.zalo_user.allow_from import resolve_allow_from_entries friends = [ {"user_id": "111", "display_name": "Alice"}, {"user_id": "222", "display_name": "Bob"}, ] result = resolve_allow_from_entries(["zalo:alice"], friends) assert result == ["111"] def test_resolve_numeric_entry(self): from yuxi.channels.adapters.zalo_user.allow_from import resolve_allow_from_entries result = resolve_allow_from_entries(["12345"], []) assert result == ["12345"] def test_resolve_by_name_in_friends(self): from yuxi.channels.adapters.zalo_user.allow_from import resolve_allow_from_entries friends = [{"user_id": "333", "display_name": "Charlie"}] result = resolve_allow_from_entries(["charlie"], friends) assert result == ["333"] def test_resolve_empty_entry_skipped(self): from yuxi.channels.adapters.zalo_user.allow_from import resolve_allow_from_entries result = resolve_allow_from_entries(["", "zalo:12345", ""], []) assert result == ["12345"] def test_check_allow_from_empty_list(self): from yuxi.channels.adapters.zalo_user.allow_from import check_allow_from assert check_allow_from("12345", []) is True def test_check_allow_from_matched(self): from yuxi.channels.adapters.zalo_user.allow_from import check_allow_from assert check_allow_from("12345", ["zalo:12345"]) is True def test_check_allow_from_not_matched(self): from yuxi.channels.adapters.zalo_user.allow_from import check_allow_from assert check_allow_from("99999", ["zalo:12345"]) is False def test_normalize_zalo_prefix(self): from yuxi.channels.adapters.zalo_user.allow_from import normalize_allow_from_entry assert normalize_allow_from_entry("zalo:12345") == "zalo:12345" def test_normalize_numeric_adds_prefix(self): from yuxi.channels.adapters.zalo_user.allow_from import normalize_allow_from_entry assert normalize_allow_from_entry("12345") == "zalo:12345" def test_normalize_non_numeric(self): from yuxi.channels.adapters.zalo_user.allow_from import normalize_allow_from_entry assert normalize_allow_from_entry("alice") == "alice" # ======================================================================== # status_issues # ======================================================================== class TestStatusIssues: def test_all_normal(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="connected", adapter_status="connected", dm_policy="pairing", group_policy="allowlist", bridge_url="https://example.com", ) assert len(issues) == 1 assert issues[0]["level"] == "info" def test_not_authenticated(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="uninitialized", adapter_status="disconnected", dm_policy="pairing", group_policy="allowlist", ) error_levels = [i["level"] for i in issues if i["level"] == "error"] assert len(error_levels) >= 1 def test_qr_expired(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="qr_expired", adapter_status="connected", dm_policy="pairing", group_policy="allowlist", ) error_messages = [i["message"] for i in issues] assert any("QR" in m for m in error_messages) def test_qr_declined(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="qr_declined", adapter_status="connected", dm_policy="pairing", group_policy="allowlist", ) error_messages = [i["message"] for i in issues] assert any("拒绝" in m for m in error_messages) def test_dm_policy_open_warning(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="connected", adapter_status="connected", dm_policy="open", group_policy="allowlist", ) assert any(i["level"] == "warn" for i in issues) def test_group_policy_open_warning(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="connected", adapter_status="connected", dm_policy="pairing", group_policy="open", ) assert any(i["level"] == "warn" for i in issues) def test_non_https_bridge_warning(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="connected", adapter_status="connected", dm_policy="pairing", group_policy="allowlist", bridge_url="http://remote.example.com", ) assert any(i["level"] == "warn" for i in issues) def test_no_warning_for_localhost_http(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="connected", adapter_status="connected", dm_policy="pairing", group_policy="allowlist", bridge_url="http://localhost:5556", ) has_non_https_warn = any("HTTPS" in i.get("message", "") for i in issues if i["level"] == "warn") assert not has_non_https_warn def test_reconnect_attempts_warning(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="connected", adapter_status="connected", dm_policy="pairing", group_policy="allowlist", reconnect_attempts=20, ) assert any(i["level"] == "warn" for i in issues) def test_adapter_error_status(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_issues issues = collect_status_issues( credential_stage="connected", adapter_status="error", last_error="Connection refused", ) error_issues = [i for i in issues if i["level"] == "error"] assert len(error_issues) >= 1 def test_status_snapshot_fields(self): from yuxi.channels.adapters.zalo_user.status_issues import collect_status_snapshot_fields fields = collect_status_snapshot_fields({ "dm_policy": "allowlist", "group_policy": "open", "allow_from": ["a", "b"], "markdown": False, "auth_type": "cookie", }) assert fields["dm_policy"] == "allowlist" assert fields["group_policy"] == "open" assert fields["allow_from_count"] == 2 assert fields["markdown_enabled"] is False assert fields["auth_type"] == "cookie" # ======================================================================== # security_audit # ======================================================================== class TestSecurityAudit: def test_empty_config_group_policy_default_warning(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({}) assert len(findings) >= 1 def test_dm_policy_open_warning(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({"dm_policy": "open"}) assert len(findings) >= 1 assert any("DM policy" in f["message"] for f in findings) def test_group_policy_open_warning(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({"group_policy": "open"}) assert len(findings) >= 1 assert any("Group policy" in f["message"] for f in findings) def test_dangerously_allow_name_matching_error(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({"dangerously_allow_name_matching": True}) assert len(findings) >= 1 assert findings[0]["level"] == "error" def test_allowlist_no_entries_warning(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({"dm_policy": "allowlist", "allow_from": []}) assert len(findings) >= 1 assert any("allow_from" in f["message"] for f in findings) def test_group_allowlist_no_entries_warning(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({ "group_policy": "allowlist", "group_allow_from": [], "groups": {}, }) assert len(findings) >= 1 assert any("group_allow_from" in f["message"] for f in findings) def test_tool_deny_findings(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({ "groups": { "g_001": { "tools": { "deny": ["send_message"], }, }, }, }) assert len(findings) >= 1 assert any("send_message" in f["message"] for f in findings) def test_qr_login_default_poll_info(self): from yuxi.channels.adapters.zalo_user.security_audit import collect_security_audit_findings findings = collect_security_audit_findings({"auth_type": "qr"}) assert len(findings) >= 1 assert any("QR login" in f["message"] for f in findings) def test_security_audit_finding_to_dict(self): from yuxi.channels.adapters.zalo_user.security_audit import SecurityAuditFinding finding = SecurityAuditFinding("warn", "test message", "test detail") d = finding.to_dict() assert d["level"] == "warn" assert d["message"] == "test message" assert d["detail"] == "test detail" # ======================================================================== # probe # ======================================================================== class TestProbe: @pytest.mark.asyncio async def test_check_bridge_health_healthy(self): from yuxi.channels.adapters.zalo_user.probe import check_bridge_health class FakeBridge: base_url = "http://localhost:5556" async def check_health(self): return {"ok": True, "status": "running", "service": "zalo-bridge"} result = await check_bridge_health(FakeBridge()) assert result.status == "healthy" assert result.metadata["service"] == "zalo-bridge" @pytest.mark.asyncio async def test_check_bridge_health_degraded(self): from yuxi.channels.adapters.zalo_user.probe import check_bridge_health class FakeBridge: base_url = "http://localhost:5556" async def check_health(self): return {"ok": False, "message": "service degraded"} result = await check_bridge_health(FakeBridge()) assert result.status == "degraded" @pytest.mark.asyncio async def test_check_bridge_health_connect_error(self): from yuxi.channels.adapters.zalo_user.probe import check_bridge_health import httpx class FakeBridge: base_url = "http://localhost:5556" async def check_health(self): raise httpx.ConnectError("connection refused") result = await check_bridge_health(FakeBridge()) assert result.status == "unhealthy" @pytest.mark.asyncio async def test_check_bridge_health_generic_error(self): from yuxi.channels.adapters.zalo_user.probe import check_bridge_health class FakeBridge: base_url = "http://localhost:5556" async def check_health(self): raise RuntimeError("unknown error") result = await check_bridge_health(FakeBridge()) assert result.status == "unhealthy" # ======================================================================== # Session functions # ======================================================================== class TestSessionComprehensive: def test_resolve_chat_id_zalouser_prefix(self): from yuxi.channels.adapters.zalo_user.session import resolve_chat_id chat_id, chat_type = resolve_chat_id("zalouser:12345") assert chat_id == "12345" assert chat_type == "user" def test_resolve_chat_id_group_prefix(self): from yuxi.channels.adapters.zalo_user.session import resolve_chat_id chat_id, chat_type = resolve_chat_id("group:g_001") assert chat_id == "g_001" assert chat_type == "group" def test_resolve_chat_id_zalo_prefix(self): from yuxi.channels.adapters.zalo_user.session import resolve_chat_id chat_id, chat_type = resolve_chat_id("zalo:12345") assert chat_id == "12345" assert chat_type == "user" def test_resolve_chat_id_plain(self): from yuxi.channels.adapters.zalo_user.session import resolve_chat_id chat_id, chat_type = resolve_chat_id("plain_text") assert chat_id == "plain_text" assert chat_type is None def test_looks_like_id_digits(self): from yuxi.channels.adapters.zalo_user.session import looks_like_id assert looks_like_id("12345") is True def test_looks_like_id_with_prefix(self): from yuxi.channels.adapters.zalo_user.session import looks_like_id assert looks_like_id("zalo:12345") is True def test_looks_like_id_plain_text(self): from yuxi.channels.adapters.zalo_user.session import looks_like_id assert looks_like_id("hello") is False def test_looks_like_id_empty(self): from yuxi.channels.adapters.zalo_user.session import looks_like_id assert looks_like_id("") is False def test_parse_outbound_target_direct(self): from yuxi.channels.adapters.zalo_user.session import parse_outbound_target result = parse_outbound_target("zalouser:12345") assert result["thread_id"] == "12345" assert result["is_group"] is False assert result["chat_type"] == "user" def test_parse_outbound_target_group(self): from yuxi.channels.adapters.zalo_user.session import parse_outbound_target result = parse_outbound_target("group:g_001") assert result["thread_id"] == "g_001" assert result["is_group"] is True assert result["chat_type"] == "group" def test_parse_outbound_target_plain(self): from yuxi.channels.adapters.zalo_user.session import parse_outbound_target result = parse_outbound_target("hello") assert result["thread_id"] == "hello" assert result["is_group"] is False assert result["chat_type"] == "direct" def test_check_group_tool_policy_no_config(self): from yuxi.channels.adapters.zalo_user.session import check_group_tool_policy assert check_group_tool_policy("chat_1", "send_message", {}) is True def test_check_group_tool_policy_denied(self): from yuxi.channels.adapters.zalo_user.session import check_group_tool_policy config = { "groups": { "chat_1": {"tools": {"deny": ["send_message"]}}, }, } assert check_group_tool_policy("chat_1", "send_message", config) is False def test_check_group_tool_policy_allowed(self): from yuxi.channels.adapters.zalo_user.session import check_group_tool_policy config = { "groups": { "chat_1": {"tools": {"allow": ["search"]}}, }, } assert check_group_tool_policy("chat_1", "send_message", config) is False assert check_group_tool_policy("chat_1", "search", config) is True def test_resolve_agent_route_unknown_chat_type(self): from yuxi.channels.adapters.zalo_user.session import resolve_agent_route route = resolve_agent_route("zalo_user", "chat_1", "user_1", "unknown", {"default_agent_id": "default"}) assert "unknown" in route @pytest.mark.asyncio async def test_resolve_targets_numeric_direct(self): from yuxi.channels.adapters.zalo_user.session import resolve_targets results = await resolve_targets(["12345"], [], []) assert len(results) == 1 assert results[0]["resolved_type"] == "direct" assert results[0]["resolved_id"] == "12345" @pytest.mark.asyncio async def test_resolve_targets_by_name(self): from yuxi.channels.adapters.zalo_user.session import resolve_targets friends = [{"user_id": "111", "display_name": "Alice"}] results = await resolve_targets(["alice"], friends, []) assert len(results) == 1 assert results[0]["resolved_id"] == "111" @pytest.mark.asyncio async def test_resolve_targets_group_by_name(self): from yuxi.channels.adapters.zalo_user.session import resolve_targets groups = [{"group_id": "g_001", "name": "Test Group"}] results = await resolve_targets(["test group"], [], groups) assert len(results) == 1 assert results[0]["resolved_id"] == "g_001" @pytest.mark.asyncio async def test_resolve_targets_empty(self): from yuxi.channels.adapters.zalo_user.session import resolve_targets results = await resolve_targets([""], [], []) assert len(results) == 1 assert results[0]["resolved_id"] is None @pytest.mark.asyncio async def test_resolve_targets_not_found(self): from yuxi.channels.adapters.zalo_user.session import resolve_targets results = await resolve_targets(["nonexistent"], [], []) assert len(results) == 1 assert "error" in results[0] def test_check_dm_policy_unknown_policy(self): from yuxi.channels.adapters.zalo_user.session import check_dm_policy assert check_dm_policy("user_1", {"dm_policy": "unknown_policy"}, set()) is False def test_check_group_policy_unknown_policy(self): from yuxi.channels.adapters.zalo_user.session import check_group_policy assert check_group_policy("chat_1", "user_1", {"group_policy": "unknown_policy"}) is False def test_check_group_policy_allowlist_per_group(self): from yuxi.channels.adapters.zalo_user.session import check_group_policy config = { "group_policy": "allowlist", "groups": { "chat_1": {"allow_from": ["zalo:user_1"]}, }, } assert check_group_policy("chat_1", "user_1", config) is True assert check_group_policy("chat_1", "user_2", config) is False def test_check_mention_required_wildcard_config(self): from yuxi.channels.adapters.zalo_user.session import check_mention_required config = {"groups": {"*": {"require_mention": True}}} assert check_mention_required("chat_1", "hello", config, "Bot") is False assert check_mention_required("chat_1", "@Bot hello", config, "Bot") is True def test_check_mention_required_no_bot_names(self): from yuxi.channels.adapters.zalo_user.session import check_mention_required config = {"groups": {"chat_1": {"require_mention": True}}} assert check_mention_required("chat_1", "hello", config, "") is True def test_implicit_mention(self): from yuxi.channels.adapters.zalo_user.normalize import implicit_mention assert implicit_mention("hello Bot", ["Bot"]) is True assert implicit_mention("@Bot hello", ["Bot"]) is False def test_can_resolve_explicit_mention(self): from yuxi.channels.adapters.zalo_user.normalize import can_resolve_explicit_mention assert can_resolve_explicit_mention("@Bot help", ["Bot"]) is True assert can_resolve_explicit_mention("hello", ["Bot"]) is False # ======================================================================== # send.py functions # ======================================================================== class TestSendHelpers: @pytest.mark.asyncio async def test_send_typing_success(self): from yuxi.channels.adapters.zalo_user.send import send_typing class FakeBridge: async def send_typing(self, conversation_id): return {"ok": True} result = await send_typing(FakeBridge(), "chat_1") assert result.success is True assert result.metadata["action"] == "typing" @pytest.mark.asyncio async def test_send_typing_error(self): from yuxi.channels.adapters.zalo_user.send import send_typing class FakeBridge: async def send_typing(self, conversation_id): raise RuntimeError("fail") result = await send_typing(FakeBridge(), "chat_1") assert result.success is False @pytest.mark.asyncio async def test_send_delivered_event(self): from yuxi.channels.adapters.zalo_user.send import send_delivered_event class FakeBridge: async def send_delivered(self, conversation_id, msg_id): return {"ok": True} result = await send_delivered_event(FakeBridge(), "chat_1", "msg_1") assert result.success is True @pytest.mark.asyncio async def test_send_seen_event(self): from yuxi.channels.adapters.zalo_user.send import send_seen_event class FakeBridge: async def send_seen(self, conversation_id, msg_id): return {"ok": True} result = await send_seen_event(FakeBridge(), "chat_1", "msg_1") assert result.success is True @pytest.mark.asyncio async def test_send_link(self): from yuxi.channels.adapters.zalo_user.send import send_link class FakeBridge: async def send_message(self, payload): return DeliveryResult(success=True, message_id="msg_link") result = await send_link(FakeBridge(), "chat_1", "http://example.com", "caption") assert result.success is True @pytest.mark.asyncio async def test_send_link_no_caption(self): from yuxi.channels.adapters.zalo_user.send import send_link class FakeBridge: async def send_message(self, payload): return DeliveryResult(success=True) result = await send_link(FakeBridge(), "chat_1", "http://example.com") assert result.success is True @pytest.mark.asyncio async def test_send_audio(self): from yuxi.channels.adapters.zalo_user.send import send_audio class FakeBridge: async def send_message(self, payload): return DeliveryResult(success=True, message_id="audio_msg") result = await send_audio(FakeBridge(), "chat_1", "http://example.com/audio.mp3") assert result.success is True def test_derive_file_name_with_extension(self): from yuxi.channels.adapters.zalo_user.send import _derive_file_name name = _derive_file_name("http://example.com/music.mp3", "audio", "audio.mp3") assert name == "music.mp3" def test_derive_file_name_with_query(self): from yuxi.channels.adapters.zalo_user.send import _derive_file_name name = _derive_file_name("http://example.com/video.mp4?token=abc", "video", "video.mp4") assert name == "video.mp4" def test_derive_file_name_no_extension(self): from yuxi.channels.adapters.zalo_user.send import _derive_file_name name = _derive_file_name("http://example.com/file", "image", "image.jpg") assert name == "image.jpg" def test_build_send_payload_disable_notification(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="silent message", metadata={"disable_notification": True}, ) payload = _build_send_payload(response, {}) assert payload["disable_notification"] is True def test_build_send_payload_ttl(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="ttl test", metadata={"ttl": 3600}, ) payload = _build_send_payload(response, {}) assert payload["ttl"] == 3600 def test_build_send_payload_custom_text_styles(self): from yuxi.channels.adapters.zalo_user.send import _build_send_payload response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="styled", metadata={"custom_text_styles": [{"offset": 0, "length": 4, "style": "bold"}]}, ) payload = _build_send_payload(response, {}) assert payload["custom_text_styles"] == [{"offset": 0, "length": 4, "style": "bold"}] @pytest.mark.asyncio async def test_send_one_success_first_attempt(self): from yuxi.channels.adapters.zalo_user.send import _send_one class FakeBridge: async def send_message(self, payload): return DeliveryResult(success=True, message_id="msg_001") result = await _send_one(FakeBridge(), {}, {}) assert result.success is True assert result.message_id == "msg_001" @pytest.mark.asyncio async def test_send_one_non_retriable_error(self): from yuxi.channels.adapters.zalo_user.send import _send_one class FakeBridge: async def send_message(self, payload): return DeliveryResult(success=False, error="session_expired: invalid cookie") result = await _send_one(FakeBridge(), {}, {}) assert result.success is False assert "session_expired" in result.error @pytest.mark.asyncio async def test_send_one_retry_exhausted(self): from yuxi.channels.adapters.zalo_user.send import _send_one class FakeBridge: async def send_message(self, payload): raise RuntimeError("always fails") result = await _send_one( FakeBridge(), {}, {"retry": {"attempts": 2, "min_delay_ms": 1, "max_delay_ms": 10}}, ) assert result.success is False assert "always fails" in result.error # ======================================================================== # _env_fallback # ======================================================================== class TestEnvFallback: def test_env_fallback_from_config(self): from yuxi.channels.adapters.zalo_user.adapter import _env_fallback result = _env_fallback("TEST_KEY", {"test_key": "config_val"}, "test_key", "default_val") assert result == "config_val" def test_env_fallback_from_env(self): from yuxi.channels.adapters.zalo_user.adapter import _env_fallback os.environ["ZALOUSER_TEST_KEY"] = "env_val" try: result = _env_fallback("ZALOUSER_TEST_KEY", {}, "test_key", "default_val") assert result == "env_val" finally: del os.environ["ZALOUSER_TEST_KEY"] def test_env_fallback_default(self): from yuxi.channels.adapters.zalo_user.adapter import _env_fallback result = _env_fallback("NONEXISTENT_KEY", {}, "test_key", "default_val") assert result == "default_val" def test_env_fallback_none_config_val_uses_env(self): from yuxi.channels.adapters.zalo_user.adapter import _env_fallback os.environ["ZALOUSER_FB_KEY"] = "from_env" try: result = _env_fallback("ZALOUSER_FB_KEY", {"fb_key": None}, "fb_key", "default") assert result == "from_env" finally: del os.environ["ZALOUSER_FB_KEY"] def test_env_fallback_multiple_env_keys(self): from yuxi.channels.adapters.zalo_user.adapter import _env_fallback os.environ["SECOND_KEY"] = "second_val" try: result = _env_fallback(["FIRST_KEY", "SECOND_KEY"], {}, "key", "default") assert result == "second_val" finally: del os.environ["SECOND_KEY"] def test_env_fallback_empty_config(self): from yuxi.channels.adapters.zalo_user.adapter import _env_fallback os.environ["ZALOUSER_EMPTY_TEST"] = "from_env" try: result = _env_fallback("ZALOUSER_EMPTY_TEST", None, "test_key", "default") assert result == "from_env" finally: del os.environ["ZALOUSER_EMPTY_TEST"] # ======================================================================== # Adapter methods (format_outbound, get_status_snapshot, send with cb) # ======================================================================== @pytest.fixture def zalo_adapter(): from yuxi.channels.adapters.zalo_user.adapter import ZaloUserAdapter return ZaloUserAdapter({ "bridge_url": "http://localhost:5556", "auth_type": "qr", }) class TestAdapterFormatOutbound: def test_format_outbound_plain_text(self, zalo_adapter): response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="Hello World", ) payload = zalo_adapter.format_outbound(response) assert payload["text"] == "Hello World" assert payload["message_type"] == "text" def test_format_outbound_with_markdown(self, zalo_adapter): zalo_adapter._config["markdown"] = True response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="**bold** text", ) payload = zalo_adapter.format_outbound(response) assert "styled_paragraphs" in payload or payload["text"] == "**bold** text" def test_format_outbound_markdown_disabled(self, zalo_adapter): zalo_adapter._config["markdown"] = False response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="**bold** text", ) payload = zalo_adapter.format_outbound(response) assert "styled_paragraphs" not in payload class TestAdapterGetStatusSnapshot: def test_get_status_snapshot(self, zalo_adapter): snapshot = zalo_adapter.get_status_snapshot() assert snapshot.account_id == "" assert snapshot.name == "unknown" assert snapshot.dm_policy == "pairing" assert snapshot.group_policy == "allowlist" def test_get_status_snapshot_with_account(self, zalo_adapter): zalo_adapter._account_info = {"user_id": "123", "display_name": "TestUser"} zalo_adapter._credential_mgr.mark_connected({"user_id": "123"}) snapshot = zalo_adapter.get_status_snapshot() assert snapshot.account_id == "123" assert snapshot.name == "TestUser" assert snapshot.linked is True class TestAdapterSend: @pytest.mark.asyncio async def test_send_not_connected(self, zalo_adapter): response = ChannelResponse( identity=ChannelIdentity( channel_id="zalo_user", channel_type=ChannelType.ZALO_USER, channel_user_id="bot", channel_chat_id="conv_abc", ), message_type=MessageType.TEXT, content="test", ) result = await zalo_adapter.send(response) assert result.success is False assert "Not connected" in result.error class TestAdapterListMethods: @pytest.mark.asyncio async def test_list_friends_no_bridge(self, zalo_adapter): zalo_adapter._friends_cache = [{"id": "1", "name": "Alice"}] result = await zalo_adapter.list_friends() assert result == [{"id": "1", "name": "Alice"}] @pytest.mark.asyncio async def test_list_groups_no_bridge(self, zalo_adapter): zalo_adapter._group_cache = [{"group_id": "g_1", "name": "Test"}] result = await zalo_adapter.list_groups() assert result == [{"group_id": "g_1", "name": "Test"}] @pytest.mark.asyncio async def test_list_group_members_no_bridge(self, zalo_adapter): result = await zalo_adapter.list_group_members("g_001") assert result == [] @pytest.mark.asyncio async def test_get_account_info_no_bridge(self, zalo_adapter): result = await zalo_adapter.get_account_info() assert "display_name" in result assert "user_id" in result class TestAdapterProperties: def test_account_id(self, zalo_adapter): assert zalo_adapter.account_id == "" def test_account_name(self, zalo_adapter): assert zalo_adapter.account_name == "unknown" def test_config_get_set(self, zalo_adapter): assert zalo_adapter.config["bridge_url"] == "http://localhost:5556" zalo_adapter.config = {"bridge_url": "http://new:5556"} assert zalo_adapter.config["bridge_url"] == "http://new:5556" def test_dm_policy_default(self, zalo_adapter): assert zalo_adapter.dm_policy == "pairing" def test_group_policy_default(self, zalo_adapter): assert zalo_adapter.group_policy == "allowlist" @pytest.mark.asyncio async def test_logout(self, zalo_adapter): zalo_adapter._status = ChannelStatus.CONNECTED result = await zalo_adapter.logout() assert result["status"] == "logged_out" @pytest.mark.asyncio async def test_receive_empty(self, zalo_adapter): messages = [] async for msg in zalo_adapter.receive(): messages.append(msg) assert len(messages) == 0 def test_normalize_inbound_delegates(self, zalo_adapter): msg = zalo_adapter.normalize_inbound({ "from_id": "12345", "conversation_id": "conv_abc", "conversation_type": "direct", "timestamp": 1700000000000, }) assert msg.identity.channel_id == "zalo_user" assert msg.chat_type == ChatType.DIRECT class TestAdapterReloadConfig: @pytest.mark.asyncio async def test_reload_config_updates_fields(self, zalo_adapter): await zalo_adapter.reload_config({ "bridge_url": "http://localhost:5556", "dm_policy": "open", "history_limit": 200, }) assert zalo_adapter.dm_policy == "open" assert zalo_adapter._history_limit == 200 @pytest.mark.asyncio async def test_reload_config_changes_bridge_url_reconnects(self, zalo_adapter): zalo_adapter._status = ChannelStatus.CONNECTED await zalo_adapter.reload_config({ "bridge_url": "http://newhost:5556", "dm_policy": "pairing", }) assert zalo_adapter._resolve_bridge_url() == "http://newhost:5556" # ======================================================================== # Constants verification # ======================================================================== class TestConstants: def test_zalo_text_limit(self): assert ZALO_TEXT_LIMIT == 2000 def test_reaction_emojis_count(self): assert len(ZALO_REACTION_EMOJIS) == 6 def test_reaction_alias_map_count(self): assert len(REACTION_ALIAS_MAP) == 18 def test_target_prefixes(self): assert TARGET_PREFIXES["zalouser:"] == "user" assert TARGET_PREFIXES["group:"] == "group" assert TARGET_PREFIXES["g:"] == "group" def test_default_bridge_url(self): assert DEFAULT_BRIDGE_URL == "http://localhost:5556" def test_default_max_send_per_minute(self): assert DEFAULT_MAX_SEND_PER_MINUTE == 30 def test_circuit_breaker_defaults(self): assert DEFAULT_CIRCUIT_BREAKER_THRESHOLD == 5 assert DEFAULT_CIRCUIT_BREAKER_RECOVERY == 60 def test_credential_stages(self): assert ZaloCredentialStage.UNINITIALIZED == "uninitialized" assert ZaloCredentialStage.QR_PENDING == "qr_pending" assert ZaloCredentialStage.QR_SCANNED == "qr_scanned" assert ZaloCredentialStage.QR_DECLINED == "qr_declined" assert ZaloCredentialStage.QR_EXPIRED == "qr_expired" assert ZaloCredentialStage.CONNECTED == "connected" assert ZaloCredentialStage.LOGGED_OUT == "logged_out" assert ZaloCredentialStage.ERROR == "error" # ======================================================================== # normalize edge cases # ======================================================================== class TestNormalizeEdgeCases: def test_map_attachment_type_photo(self): from yuxi.channels.adapters.zalo_user.normalize import _map_attachment_type assert _map_attachment_type("photo") == "image" def test_map_attachment_type_voice(self): from yuxi.channels.adapters.zalo_user.normalize import _map_attachment_type assert _map_attachment_type("voice") == "audio" def test_map_attachment_type_document(self): from yuxi.channels.adapters.zalo_user.normalize import _map_attachment_type assert _map_attachment_type("document") == "file" def test_map_attachment_type_sticker(self): from yuxi.channels.adapters.zalo_user.normalize import _map_attachment_type assert _map_attachment_type("sticker") == "sticker" def test_map_attachment_type_unknown(self): from yuxi.channels.adapters.zalo_user.normalize import _map_attachment_type assert _map_attachment_type("unknown_type") == "file" def test_derive_filename_from_url(self): from yuxi.channels.adapters.zalo_user.normalize import _derive_filename_from_url name = _derive_filename_from_url("http://example.com/photo.jpg", "image") assert name == "photo.jpg" def test_derive_filename_from_url_no_path(self): from yuxi.channels.adapters.zalo_user.normalize import _derive_filename_from_url name = _derive_filename_from_url("", "image") assert name == "" def test_derive_filename_from_url_empty_path(self): from yuxi.channels.adapters.zalo_user.normalize import _derive_filename_from_url name = _derive_filename_from_url("http://example.com", "video") assert name == "video.mp4" def test_derive_filename_from_url_audio_fallback(self): from yuxi.channels.adapters.zalo_user.normalize import _derive_filename_from_url name = _derive_filename_from_url("http://example.com/", "audio") assert name == "audio.mp3" def test_normalize_inbound_with_caption(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", "message_id": "msg_001", "caption": "Check this photo", "conversation_type": "direct", "timestamp": 1700000000000, }) assert result.content == "Check this photo" def test_normalize_inbound_with_metadata_fields(self): from yuxi.channels.adapters.zalo_user.normalize import normalize_inbound result = normalize_inbound("zalo_user", { "from_id": "12345", "conversation_id": "conv_abc", "message_id": "msg_001", "conversation_type": "direct", "timestamp": 1700000000000, "uidFrom": "uid123", "idTo": "id456", "st": 1, "at": 0, "cmd": 0, "ts": 1700000000, "cli_msg_id": "cli_001", "msg_type": "text", "zalo_event": "message", "from_avatar": "http://avatar.url", }) assert result.metadata["uid_from"] == "uid123" assert result.metadata["id_to"] == "id456" assert result.metadata["cli_msg_id"] == "cli_001" assert result.metadata["msg_type"] == "text" assert result.metadata["sender_avatar_url"] == "http://avatar.url" assert result.metadata["zalo_event"] == "message" # ======================================================================== # BridgeClient static methods # ======================================================================== class TestBridgeClientStatic: def test_ws_url_from_http_self_listen_false(self): from yuxi.channels.adapters.zalo_user.bridge import _ws_url_from_http url = _ws_url_from_http("http://localhost:5556", self_listen=False) assert url == "ws://localhost:5556/messages/stream?self_listen=false" def test_ws_url_from_https_self_listen_false(self): from yuxi.channels.adapters.zalo_user.bridge import _ws_url_from_http url = _ws_url_from_http("https://bridge.example.com", self_listen=False) assert url == "wss://bridge.example.com/messages/stream?self_listen=false" def test_bridge_client_invalid_url(self): from yuxi.channels.adapters.zalo_user.bridge import BridgeClient with pytest.raises(ValueError, match="bridge_url must start with"): BridgeClient(bridge_url="localhost:5556") # ======================================================================== # Adapter send methods - remove_reaction, delete_message, edit_message (not connected) # ======================================================================== class TestAdapterSendMethods: @pytest.mark.asyncio async def test_remove_reaction_not_connected(self, zalo_adapter): result = await zalo_adapter.remove_reaction("chat_1", "msg_1") assert result.success is False @pytest.mark.asyncio async def test_send_reaction_delete_reaction(self, zalo_adapter): result = await zalo_adapter.send_reaction("chat_1", "msg_1", "") assert result.success is False # ======================================================================== # reaction edge cases # ======================================================================== class TestReactionEdgeCases: def test_is_valid_reaction_with_whitespace(self): from yuxi.channels.adapters.zalo_user.reaction import is_valid_reaction assert is_valid_reaction(" like ") is True def test_is_delete_reaction_single_space(self): from yuxi.channels.adapters.zalo_user.reaction import is_delete_reaction assert is_delete_reaction(" ") is True def test_normalize_reaction_icon_strips_spaces(self): from yuxi.channels.adapters.zalo_user.reaction import normalize_reaction_icon assert normalize_reaction_icon(" like ") == "\U0001f44d"