ForcePilot/backend/test/unit/channels/test_zalo_user_comprehensive.py

1470 lines
58 KiB
Python
Raw Normal View History

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"