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

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