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
|