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