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"
|