1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
455 lines
16 KiB
Python
455 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.whatsapp.debounce import MessageDebouncer
|
|
from yuxi.channels.adapters.whatsapp.dedupe import ButtonDeduplicator, MessageDeduplicator, create_deduplicator
|
|
from yuxi.channels.adapters.whatsapp.echo_filter import EchoFilter
|
|
from yuxi.channels.adapters.whatsapp.inbound_cache import InboundMessageCache, _is_group_jid, _normalize_jid
|
|
from yuxi.channels.adapters.whatsapp.sent_message_cache import SentMessageCache
|
|
|
|
|
|
class TestMessageDebouncer:
|
|
def test_initial_state(self):
|
|
debouncer = MessageDebouncer(window_seconds=2.0, max_calls=3)
|
|
assert debouncer._window == 2.0
|
|
assert debouncer._max_calls == 3
|
|
assert len(debouncer._last_send) == 0
|
|
|
|
def test_should_throttle_first_call(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=3)
|
|
assert debouncer.should_throttle("jid1") is False
|
|
|
|
def test_should_throttle_within_limit(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=3)
|
|
assert debouncer.should_throttle("jid1") is False
|
|
assert debouncer.should_throttle("jid1") is False
|
|
assert debouncer.should_throttle("jid1") is False
|
|
|
|
def test_should_throttle_exceeds_limit(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=3)
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
assert debouncer.should_throttle("jid1") is True
|
|
|
|
def test_different_jids_independent(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=2)
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
assert debouncer.should_throttle("jid1") is True
|
|
assert debouncer.should_throttle("jid2") is False
|
|
|
|
def test_window_expires(self):
|
|
debouncer = MessageDebouncer(window_seconds=0.01, max_calls=2)
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
assert debouncer.should_throttle("jid1") is True
|
|
import time
|
|
time.sleep(0.02)
|
|
assert debouncer.should_throttle("jid1") is False
|
|
|
|
def test_record_send(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=2)
|
|
debouncer.record_send("jid1")
|
|
debouncer.record_send("jid1")
|
|
assert debouncer.should_throttle("jid1") is True
|
|
|
|
def test_window_remaining_no_calls(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=3)
|
|
assert debouncer.window_remaining("jid1") == 0.0
|
|
|
|
def test_window_remaining_partial(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=3)
|
|
debouncer.should_throttle("jid1")
|
|
assert debouncer.window_remaining("jid1") == 0.0
|
|
|
|
def test_window_remaining_full(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=3)
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
remaining = debouncer.window_remaining("jid1")
|
|
assert remaining > 0.0
|
|
|
|
def test_clear_specific_jid(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=2)
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
assert debouncer.should_throttle("jid1") is True
|
|
debouncer.clear("jid1")
|
|
assert debouncer.should_throttle("jid1") is False
|
|
|
|
def test_clear_all(self):
|
|
debouncer = MessageDebouncer(window_seconds=60.0, max_calls=2)
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid1")
|
|
debouncer.should_throttle("jid2")
|
|
debouncer.clear()
|
|
assert len(debouncer._last_send) == 0
|
|
|
|
def test_default_values(self):
|
|
debouncer = MessageDebouncer()
|
|
assert debouncer._window == 2.0
|
|
assert debouncer._max_calls == 3
|
|
|
|
|
|
class TestMessageDeduplicator:
|
|
def test_initial_state(self):
|
|
deduper = MessageDeduplicator(ttl_seconds=300, max_size=10000)
|
|
assert len(deduper._cache) == 0
|
|
assert deduper._ttl == 300
|
|
assert deduper._max_size == 10000
|
|
|
|
def test_is_duplicate_first_occurrence(self):
|
|
deduper = MessageDeduplicator()
|
|
assert deduper.is_duplicate("msg-001") is False
|
|
|
|
def test_is_duplicate_second_occurrence(self):
|
|
deduper = MessageDeduplicator()
|
|
assert deduper.is_duplicate("msg-001") is False
|
|
assert deduper.is_duplicate("msg-001") is True
|
|
|
|
def test_different_ids_not_duplicate(self):
|
|
deduper = MessageDeduplicator()
|
|
assert deduper.is_duplicate("msg-001") is False
|
|
assert deduper.is_duplicate("msg-002") is False
|
|
assert deduper.is_duplicate("msg-003") is False
|
|
|
|
def test_record(self):
|
|
deduper = MessageDeduplicator()
|
|
deduper.record("msg-001")
|
|
assert deduper.is_duplicate("msg-001") is True
|
|
|
|
def test_ttl_expiry(self):
|
|
deduper = MessageDeduplicator(ttl_seconds=0)
|
|
deduper.is_duplicate("msg-001")
|
|
time.sleep(0.01)
|
|
assert deduper.is_duplicate("msg-001") is False
|
|
|
|
def test_clear(self):
|
|
deduper = MessageDeduplicator()
|
|
deduper.is_duplicate("msg-001")
|
|
deduper.is_duplicate("msg-002")
|
|
deduper.clear()
|
|
assert deduper.is_duplicate("msg-001") is False
|
|
assert deduper.is_duplicate("msg-002") is False
|
|
|
|
def test_max_size_eviction(self):
|
|
deduper = MessageDeduplicator(max_size=3)
|
|
deduper.is_duplicate("msg-001")
|
|
deduper.is_duplicate("msg-002")
|
|
deduper.is_duplicate("msg-003")
|
|
deduper.is_duplicate("msg-004")
|
|
assert deduper.is_duplicate("msg-001") is False
|
|
|
|
def test_normalize_key_strips_whitespace(self):
|
|
deduper = MessageDeduplicator()
|
|
assert deduper.is_duplicate(" msg-001 ") is False
|
|
assert deduper.is_duplicate("msg-001") is True
|
|
|
|
def test_fingerprint_produces_hash(self):
|
|
payload = {
|
|
"key": {"id": "abc123", "remoteJid": "test@s.whatsapp.net"},
|
|
"messageTimestamp": 1700000000,
|
|
"pushName": "Tester",
|
|
}
|
|
fp = MessageDeduplicator._fingerprint(payload)
|
|
assert isinstance(fp, str)
|
|
assert len(fp) == 32
|
|
|
|
|
|
class TestButtonDeduplicator:
|
|
def test_initial_state(self):
|
|
deduper = ButtonDeduplicator(ttl_seconds=5.0, max_size=2000)
|
|
assert len(deduper._cache) == 0
|
|
|
|
def test_is_duplicate_first_occurrence(self):
|
|
deduper = ButtonDeduplicator()
|
|
assert deduper.is_duplicate("sender1", "btn-1") is False
|
|
|
|
def test_is_duplicate_same_button_same_sender(self):
|
|
deduper = ButtonDeduplicator()
|
|
assert deduper.is_duplicate("sender1", "btn-1") is False
|
|
assert deduper.is_duplicate("sender1", "btn-1") is True
|
|
|
|
def test_different_sender_not_duplicate(self):
|
|
deduper = ButtonDeduplicator()
|
|
assert deduper.is_duplicate("sender1", "btn-1") is False
|
|
assert deduper.is_duplicate("sender2", "btn-1") is False
|
|
|
|
def test_different_button_same_sender_not_duplicate(self):
|
|
deduper = ButtonDeduplicator()
|
|
assert deduper.is_duplicate("sender1", "btn-1") is False
|
|
assert deduper.is_duplicate("sender1", "btn-2") is False
|
|
|
|
def test_ttl_expiry(self):
|
|
deduper = ButtonDeduplicator(ttl_seconds=0.01)
|
|
deduper.is_duplicate("sender1", "btn-1")
|
|
time.sleep(0.02)
|
|
assert deduper.is_duplicate("sender1", "btn-1") is False
|
|
|
|
def test_max_size_eviction(self):
|
|
deduper = ButtonDeduplicator(max_size=2)
|
|
deduper.is_duplicate("s1", "b1")
|
|
deduper.is_duplicate("s2", "b2")
|
|
deduper.is_duplicate("s3", "b3")
|
|
assert deduper.is_duplicate("s1", "b1") is False
|
|
|
|
|
|
class TestCreateDeduplicator:
|
|
def test_factory_defaults(self):
|
|
deduper = create_deduplicator()
|
|
assert isinstance(deduper, MessageDeduplicator)
|
|
assert deduper._ttl == 300
|
|
assert deduper._max_size == 10000
|
|
|
|
def test_factory_custom(self):
|
|
deduper = create_deduplicator(ttl_seconds=60, max_size=100)
|
|
assert deduper._ttl == 60
|
|
assert deduper._max_size == 100
|
|
|
|
|
|
class TestEchoFilter:
|
|
def test_initial_state(self):
|
|
echo = EchoFilter(ttl_seconds=10.0, max_size=500)
|
|
assert len(echo._sent) == 0
|
|
|
|
def test_record_outbound_and_is_echo(self):
|
|
echo = EchoFilter()
|
|
echo.record_outbound("jid1", "Hello World")
|
|
assert echo.is_echo("jid1", "Hello World") is True
|
|
|
|
def test_different_text_not_echo(self):
|
|
echo = EchoFilter()
|
|
echo.record_outbound("jid1", "Hello")
|
|
assert echo.is_echo("jid1", "World") is False
|
|
|
|
def test_different_jid_not_echo(self):
|
|
echo = EchoFilter()
|
|
echo.record_outbound("jid1", "Hello")
|
|
assert echo.is_echo("jid2", "Hello") is False
|
|
|
|
def test_ttl_expiry(self):
|
|
echo = EchoFilter(ttl_seconds=0.01)
|
|
echo.record_outbound("jid1", "Hello")
|
|
time.sleep(0.02)
|
|
assert echo.is_echo("jid1", "Hello") is False
|
|
|
|
def test_text_truncation_to_120(self):
|
|
echo = EchoFilter()
|
|
long_text = "x" * 200
|
|
echo.record_outbound("jid1", long_text)
|
|
short_text = "x" * 200
|
|
assert echo.is_echo("jid1", short_text) is True
|
|
|
|
def test_make_key_prefix_match(self):
|
|
echo = EchoFilter()
|
|
echo.record_outbound("jid1", "Hello" * 30)
|
|
assert echo.is_echo("jid1", "Hello" * 30) is True
|
|
|
|
def test_clear(self):
|
|
echo = EchoFilter()
|
|
echo.record_outbound("jid1", "Hello")
|
|
echo.record_outbound("jid2", "World")
|
|
echo.clear()
|
|
assert echo.is_echo("jid1", "Hello") is False
|
|
assert echo.is_echo("jid2", "World") is False
|
|
|
|
def test_max_size_eviction(self):
|
|
echo = EchoFilter(max_size=2)
|
|
echo.record_outbound("j1", "t1")
|
|
echo.record_outbound("j2", "t2")
|
|
echo.record_outbound("j3", "t3")
|
|
assert echo.is_echo("j1", "t1") is False
|
|
|
|
|
|
class TestSentMessageCache:
|
|
def test_put_and_get(self):
|
|
cache = SentMessageCache(max_size=100, ttl_seconds=3600)
|
|
cache.put("msg-001", "jid1", {"foo": "bar"})
|
|
entry = cache.get("msg-001")
|
|
assert entry is not None
|
|
assert entry["jid"] == "jid1"
|
|
assert entry["metadata"]["foo"] == "bar"
|
|
|
|
def test_get_nonexistent(self):
|
|
cache = SentMessageCache()
|
|
assert cache.get("nonexistent") is None
|
|
|
|
def test_empty_msg_id_skipped(self):
|
|
cache = SentMessageCache()
|
|
cache.put("", "jid1")
|
|
assert len(cache) == 0
|
|
|
|
def test_get_jid(self):
|
|
cache = SentMessageCache()
|
|
cache.put("msg-001", "jid1")
|
|
assert cache.get_jid("msg-001") == "jid1"
|
|
assert cache.get_jid("msg-002") is None
|
|
|
|
def test_ttl_expiry(self):
|
|
cache = SentMessageCache(ttl_seconds=0)
|
|
cache.put("msg-001", "jid1")
|
|
time.sleep(0.01)
|
|
assert cache.get("msg-001") is None
|
|
|
|
def test_max_size_eviction(self):
|
|
cache = SentMessageCache(max_size=2)
|
|
cache.put("msg-1", "jid1")
|
|
cache.put("msg-2", "jid2")
|
|
cache.put("msg-3", "jid3")
|
|
assert cache.get("msg-1") is None
|
|
assert cache.get("msg-2") is not None
|
|
assert cache.get("msg-3") is not None
|
|
|
|
def test_clear(self):
|
|
cache = SentMessageCache()
|
|
cache.put("msg-001", "jid1")
|
|
cache.put("msg-002", "jid2")
|
|
cache.clear()
|
|
assert len(cache) == 0
|
|
|
|
def test_len(self):
|
|
cache = SentMessageCache()
|
|
assert len(cache) == 0
|
|
cache.put("msg-1", "jid1")
|
|
assert len(cache) == 1
|
|
cache.put("msg-2", "jid2")
|
|
assert len(cache) == 2
|
|
|
|
def test_default_values(self):
|
|
cache = SentMessageCache()
|
|
assert cache._max_size == 500
|
|
assert cache._ttl == 3600
|
|
|
|
|
|
class TestInboundMessageCache:
|
|
def test_put_and_get(self):
|
|
cache = InboundMessageCache(max_size=100, ttl_seconds=600)
|
|
cache.put("msg-001", "test@s.whatsapp.net", "Hello World")
|
|
entry = cache.get("msg-001")
|
|
assert entry is not None
|
|
assert entry["jid"] == "test@s.whatsapp.net"
|
|
assert entry["content"] == "Hello World"
|
|
|
|
def test_content_truncation(self):
|
|
cache = InboundMessageCache()
|
|
long_content = "x" * 1000
|
|
cache.put("msg-001", "jid1", long_content)
|
|
entry = cache.get("msg-001")
|
|
assert len(entry["content"]) == 500
|
|
|
|
def test_get_nonexistent(self):
|
|
cache = InboundMessageCache()
|
|
assert cache.get("nonexistent") is None
|
|
|
|
def test_empty_msg_id_skipped(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("", "jid1", "test")
|
|
assert len(cache) == 0
|
|
|
|
def test_ttl_expiry(self):
|
|
cache = InboundMessageCache(ttl_seconds=0)
|
|
cache.put("msg-001", "jid1", "test")
|
|
time.sleep(0.01)
|
|
assert cache.get("msg-001") is None
|
|
|
|
def test_max_size_eviction(self):
|
|
cache = InboundMessageCache(max_size=2)
|
|
cache.put("msg-1", "jid1", "t1")
|
|
cache.put("msg-2", "jid2", "t2")
|
|
cache.put("msg-3", "jid3", "t3")
|
|
assert cache.get("msg-1") is None
|
|
|
|
def test_clear(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "jid1", "test")
|
|
cache.put("msg-002", "jid2", "test")
|
|
cache.clear()
|
|
assert len(cache) == 0
|
|
|
|
def test_len(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-1", "jid1", "t1")
|
|
cache.put("msg-2", "jid2", "t2")
|
|
assert len(cache) == 2
|
|
|
|
def test_lookup_inbound_meta_exact_match(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "test@s.whatsapp.net", "hello")
|
|
result = cache.lookup_inbound_meta("test@s.whatsapp.net", "msg-001")
|
|
assert result is not None
|
|
assert result["jid"] == "test@s.whatsapp.net"
|
|
|
|
def test_lookup_inbound_meta_fuzzy_group(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "123456@g.us", "group msg")
|
|
result = cache.lookup_inbound_meta("999999@g.us")
|
|
assert result is not None
|
|
assert result["jid"] == "123456@g.us"
|
|
|
|
def test_lookup_inbound_meta_fuzzy_dm(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "11111@s.whatsapp.net", "dm msg")
|
|
result = cache.lookup_inbound_meta("22222@s.whatsapp.net")
|
|
assert result is not None
|
|
assert result["jid"] == "11111@s.whatsapp.net"
|
|
|
|
def test_lookup_inbound_meta_empty(self):
|
|
cache = InboundMessageCache()
|
|
assert cache.lookup_inbound_meta("test@s.whatsapp.net") is None
|
|
|
|
def test_lookup_inbound_meta_group_vs_dm_mismatch(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "123456@g.us", "group msg")
|
|
result = cache.lookup_inbound_meta("test@s.whatsapp.net")
|
|
assert result is None
|
|
|
|
def test_resolve_quoted_message_key_exact(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "test@s.whatsapp.net", "hello")
|
|
key = cache.resolve_quoted_message_key("test@s.whatsapp.net", "msg-001")
|
|
assert key is not None
|
|
assert key["remoteJid"] == "test@s.whatsapp.net"
|
|
assert key["id"] == "msg-001"
|
|
assert key["fromMe"] is False
|
|
|
|
def test_resolve_quoted_message_key_without_id(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "test@s.whatsapp.net", "hello")
|
|
key = cache.resolve_quoted_message_key("test@s.whatsapp.net")
|
|
assert key is not None
|
|
|
|
def test_resolve_quoted_message_key_none(self):
|
|
cache = InboundMessageCache()
|
|
assert cache.resolve_quoted_message_key("nonexistent", "nonexistent") is None
|
|
|
|
def test_metadata_handling(self):
|
|
cache = InboundMessageCache()
|
|
cache.put("msg-001", "jid1", "test", {"key": "value"})
|
|
entry = cache.get("msg-001")
|
|
assert entry["metadata"]["key"] == "value"
|
|
|
|
|
|
class TestHelperFunctions:
|
|
def test_normalize_jid_strips_domain(self):
|
|
assert _normalize_jid("8613800138000@s.whatsapp.net") == "8613800138000"
|
|
|
|
def test_normalize_jid_strips_plus(self):
|
|
assert _normalize_jid("+8613800138000@s.whatsapp.net") == "8613800138000"
|
|
|
|
def test_normalize_jid_strips_spaces(self):
|
|
assert _normalize_jid("+1 555 123 4567@s.whatsapp.net") == "15551234567"
|
|
|
|
def test_is_group_jid_true(self):
|
|
assert _is_group_jid("123456789@g.us") is True
|
|
|
|
def test_is_group_jid_false(self):
|
|
assert _is_group_jid("8613800138000@s.whatsapp.net") is False
|
|
|
|
def test_is_group_jid_broadcast(self):
|
|
assert _is_group_jid("status@broadcast") is False |