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重连相关测试
1470 lines
58 KiB
Python
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" |