ForcePilot/backend/test/unit/channels/test_zalo_user_comprehensive.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

1470 lines
58 KiB
Python

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"