415 lines
15 KiB
Python
415 lines
15 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.whatsapp.reactions.ack_reaction import AckReactionManager
|
||
|
|
from yuxi.channels.adapters.whatsapp.reactions.reaction_level import ReactionLevel, ReactionLevelController
|
||
|
|
from yuxi.channels.adapters.whatsapp.markdown import markdown_to_whatsapp, text_sanitizer
|
||
|
|
from yuxi.channels.adapters.whatsapp.structured_context import (
|
||
|
|
ContextSource,
|
||
|
|
ContextType,
|
||
|
|
StructuredContextEntry,
|
||
|
|
UntrustedStructuredContext,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestReactionLevel:
|
||
|
|
def test_enum_values(self):
|
||
|
|
assert ReactionLevel.OFF == "off"
|
||
|
|
assert ReactionLevel.MINIMAL == "minimal"
|
||
|
|
assert ReactionLevel.FULL == "full"
|
||
|
|
|
||
|
|
|
||
|
|
class TestReactionLevelController:
|
||
|
|
def test_default_constructor(self):
|
||
|
|
ctrl = ReactionLevelController({})
|
||
|
|
assert ctrl.level == ReactionLevel.MINIMAL
|
||
|
|
|
||
|
|
def test_off_level(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "off"})
|
||
|
|
assert ctrl.level == ReactionLevel.OFF
|
||
|
|
assert ctrl.can_send_reaction() is False
|
||
|
|
assert ctrl.can_send_reaction(is_ack=True) is False
|
||
|
|
|
||
|
|
def test_minimal_level(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "minimal"})
|
||
|
|
assert ctrl.level == ReactionLevel.MINIMAL
|
||
|
|
assert ctrl.can_send_reaction() is False
|
||
|
|
assert ctrl.can_send_reaction(is_ack=True) is True
|
||
|
|
|
||
|
|
def test_full_level(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "full"})
|
||
|
|
assert ctrl.level == ReactionLevel.FULL
|
||
|
|
assert ctrl.can_send_reaction() is True
|
||
|
|
assert ctrl.can_send_reaction(is_ack=True) is True
|
||
|
|
|
||
|
|
def test_invalid_level_defaults(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "invalid"})
|
||
|
|
assert ctrl.level == ReactionLevel.MINIMAL
|
||
|
|
|
||
|
|
def test_agent_reactions_disabled(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "full", "agentReactionsEnabled": False})
|
||
|
|
assert ctrl.agent_reactions_enabled is False
|
||
|
|
assert ctrl.can_agent_react() is False
|
||
|
|
|
||
|
|
def test_agent_reactions_off_level(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "off", "agentReactionsEnabled": True})
|
||
|
|
assert ctrl.can_agent_react() is False
|
||
|
|
|
||
|
|
def test_agent_reactions_enabled(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "full", "agentReactionsEnabled": True})
|
||
|
|
assert ctrl.can_agent_react() is True
|
||
|
|
|
||
|
|
def test_agent_reaction_guidance(self):
|
||
|
|
ctrl = ReactionLevelController({"agentReactionGuidance": "Use emoji"})
|
||
|
|
assert ctrl.get_agent_guidance() == "Use emoji"
|
||
|
|
|
||
|
|
def test_agent_reaction_guidance_empty(self):
|
||
|
|
ctrl = ReactionLevelController({})
|
||
|
|
assert ctrl.agent_reaction_guidance == ""
|
||
|
|
|
||
|
|
def test_agent_reaction_guidance_none(self):
|
||
|
|
ctrl = ReactionLevelController({})
|
||
|
|
assert ctrl.get_agent_guidance() is None
|
||
|
|
|
||
|
|
def test_properties_readonly(self):
|
||
|
|
ctrl = ReactionLevelController({"reactionLevel": "full"})
|
||
|
|
assert ctrl.level == ReactionLevel.FULL
|
||
|
|
assert ctrl.agent_reactions_enabled is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestAckReactionManager:
|
||
|
|
def test_default_constructor(self):
|
||
|
|
mgr = AckReactionManager({})
|
||
|
|
assert mgr.emoji == "\u2705"
|
||
|
|
assert mgr.should_ack_direct() is True
|
||
|
|
assert mgr.should_ack_group() is True
|
||
|
|
|
||
|
|
def test_custom_emoji(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"emoji": "👍"}})
|
||
|
|
assert mgr.emoji == "👍"
|
||
|
|
|
||
|
|
def test_direct_disabled(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"direct": False}})
|
||
|
|
assert mgr.should_ack_direct() is False
|
||
|
|
|
||
|
|
def test_group_off(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"group": "off"}})
|
||
|
|
assert mgr.should_ack_group() is False
|
||
|
|
|
||
|
|
def test_group_mentions_only(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"group": "mentions"}})
|
||
|
|
assert mgr.should_ack_group() is True
|
||
|
|
assert mgr.should_ack_group_mentions_only() is True
|
||
|
|
|
||
|
|
def test_can_ack_direct(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"cooldown": 0}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=False) is True
|
||
|
|
|
||
|
|
def test_can_ack_group_full(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"group": "full", "cooldown": 0}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=True) is True
|
||
|
|
|
||
|
|
def test_can_ack_group_mentions_only_mentioned(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"group": "mentions", "cooldown": 0}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=True, is_mentioned=True) is True
|
||
|
|
|
||
|
|
def test_can_ack_group_mentions_only_not_mentioned(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"group": "mentions", "cooldown": 0}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=True, is_mentioned=False) is False
|
||
|
|
|
||
|
|
def test_can_ack_group_off(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"group": "off"}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=True) is False
|
||
|
|
|
||
|
|
def test_can_ack_direct_disabled(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"direct": False, "cooldown": 0}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=False) is False
|
||
|
|
|
||
|
|
def test_cooldown_prevents_ack(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"cooldown": 60}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=False) is True
|
||
|
|
mgr.record_ack("chat1")
|
||
|
|
assert mgr.can_ack("chat1", is_group=False) is False
|
||
|
|
|
||
|
|
def test_different_chats_independent_cooldown(self):
|
||
|
|
mgr = AckReactionManager({"ackReaction": {"cooldown": 60}})
|
||
|
|
assert mgr.can_ack("chat1", is_group=False) is True
|
||
|
|
mgr.record_ack("chat1")
|
||
|
|
assert mgr.can_ack("chat2", is_group=False) is True
|
||
|
|
|
||
|
|
def test_record_ack(self):
|
||
|
|
mgr = AckReactionManager({})
|
||
|
|
mgr.record_ack("chat1")
|
||
|
|
|
||
|
|
|
||
|
|
class TestTextSanitizer:
|
||
|
|
def test_empty_string(self):
|
||
|
|
assert text_sanitizer("") == ""
|
||
|
|
|
||
|
|
def test_none_like(self):
|
||
|
|
assert text_sanitizer("") == ""
|
||
|
|
|
||
|
|
def test_nbsp_replacement(self):
|
||
|
|
assert text_sanitizer("hello world") == "hello world"
|
||
|
|
|
||
|
|
def test_allows_b_tag(self):
|
||
|
|
assert text_sanitizer("<b>bold</b>") == "<b>bold</b>"
|
||
|
|
|
||
|
|
def test_allows_i_tag(self):
|
||
|
|
assert text_sanitizer("<i>italic</i>") == "<i>italic</i>"
|
||
|
|
|
||
|
|
def test_allows_strong_tag(self):
|
||
|
|
assert text_sanitizer("<strong>bold</strong>") == "<strong>bold</strong>"
|
||
|
|
|
||
|
|
def test_allows_em_tag(self):
|
||
|
|
assert text_sanitizer("<em>em</em>") == "<em>em</em>"
|
||
|
|
|
||
|
|
def test_allows_u_tag(self):
|
||
|
|
assert text_sanitizer("<u>underline</u>") == "<u>underline</u>"
|
||
|
|
|
||
|
|
def test_allows_s_tag(self):
|
||
|
|
assert text_sanitizer("<s>strikethrough</s>") == "<s>strikethrough</s>"
|
||
|
|
|
||
|
|
def test_allows_code_tag(self):
|
||
|
|
assert text_sanitizer("<code>code</code>") == "<code>code</code>"
|
||
|
|
|
||
|
|
def test_allows_pre_tag(self):
|
||
|
|
assert text_sanitizer("<pre>pre</pre>") == "<pre>pre</pre>"
|
||
|
|
|
||
|
|
def test_allows_a_tag(self):
|
||
|
|
assert text_sanitizer('<a href="url">link</a>') == '<a href="url">link</a>'
|
||
|
|
|
||
|
|
def test_strips_script_tag(self):
|
||
|
|
result = text_sanitizer("<script>alert(1)</script>")
|
||
|
|
assert "<script>" not in result
|
||
|
|
assert "</script>" not in result
|
||
|
|
|
||
|
|
def test_strips_unknown_tags(self):
|
||
|
|
assert text_sanitizer("<div>text</div>") == "text"
|
||
|
|
assert text_sanitizer("<span>text</span>") == "text"
|
||
|
|
|
||
|
|
def test_strips_mixed_allowed_disallowed(self):
|
||
|
|
result = text_sanitizer("<b>safe</b><script>bad</script>")
|
||
|
|
assert "<b>safe</b>" in result
|
||
|
|
assert "<script>" not in result
|
||
|
|
|
||
|
|
def test_strips_text(self):
|
||
|
|
assert text_sanitizer(" hello ") == "hello"
|
||
|
|
|
||
|
|
def test_plain_text_unchanged(self):
|
||
|
|
assert text_sanitizer("Hello World") == "Hello World"
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownToWhatsapp:
|
||
|
|
def test_empty_string(self):
|
||
|
|
assert markdown_to_whatsapp("") == ""
|
||
|
|
|
||
|
|
def test_bold_conversion(self):
|
||
|
|
assert markdown_to_whatsapp("**bold**") == "*bold*"
|
||
|
|
|
||
|
|
def test_double_underscore_bold(self):
|
||
|
|
assert markdown_to_whatsapp("__bold__") == "*bold*"
|
||
|
|
|
||
|
|
def test_italic_conversion(self):
|
||
|
|
result = markdown_to_whatsapp("*italic*")
|
||
|
|
assert "italic" in result
|
||
|
|
|
||
|
|
def test_bold_italic_combo(self):
|
||
|
|
result = markdown_to_whatsapp("***bold italic***")
|
||
|
|
assert "bold italic" in result
|
||
|
|
|
||
|
|
def test_strikethrough(self):
|
||
|
|
result = markdown_to_whatsapp("~~strike~~")
|
||
|
|
assert "strike" in result
|
||
|
|
assert "~~" not in result
|
||
|
|
|
||
|
|
def test_inline_code(self):
|
||
|
|
result = markdown_to_whatsapp("`code`")
|
||
|
|
assert "```" in result
|
||
|
|
assert "code" in result
|
||
|
|
|
||
|
|
def test_code_block(self):
|
||
|
|
result = markdown_to_whatsapp("```python\nprint('hello')\n```")
|
||
|
|
assert "python" in result
|
||
|
|
assert "print" in result
|
||
|
|
|
||
|
|
def test_heading_conversion(self):
|
||
|
|
result = markdown_to_whatsapp("# Heading 1")
|
||
|
|
assert "Heading 1" in result
|
||
|
|
assert "# " not in result
|
||
|
|
|
||
|
|
def test_heading2_conversion(self):
|
||
|
|
result = markdown_to_whatsapp("## Heading 2")
|
||
|
|
assert "Heading 2" in result
|
||
|
|
assert "## " not in result
|
||
|
|
|
||
|
|
def test_heading3_conversion(self):
|
||
|
|
result = markdown_to_whatsapp("### Heading 3")
|
||
|
|
assert "Heading 3" in result
|
||
|
|
assert "### " not in result
|
||
|
|
|
||
|
|
def test_link_with_different_text(self):
|
||
|
|
result = markdown_to_whatsapp("[Click here](https://example.com)")
|
||
|
|
assert "Click here" in result
|
||
|
|
assert "example.com" in result
|
||
|
|
|
||
|
|
def test_link_same_text_url(self):
|
||
|
|
result = markdown_to_whatsapp("[https://example.com](https://example.com)")
|
||
|
|
assert result == "https://example.com"
|
||
|
|
|
||
|
|
def test_unordered_list(self):
|
||
|
|
result = markdown_to_whatsapp("- item1\n- item2")
|
||
|
|
assert "• item1" in result
|
||
|
|
assert "• item2" in result
|
||
|
|
|
||
|
|
def test_ordered_list(self):
|
||
|
|
result = markdown_to_whatsapp("1. item1\n2. item2")
|
||
|
|
assert "• item1" in result
|
||
|
|
assert "• item2" in result
|
||
|
|
|
||
|
|
def test_br_to_newline(self):
|
||
|
|
result = markdown_to_whatsapp("line1<br>line2")
|
||
|
|
assert "line1\nline2" in result
|
||
|
|
|
||
|
|
def test_excessive_newlines_collapsed(self):
|
||
|
|
result = markdown_to_whatsapp("a\n\n\n\nb")
|
||
|
|
assert result.count("\n\n") <= 1
|
||
|
|
|
||
|
|
def test_complex_markdown(self):
|
||
|
|
result = markdown_to_whatsapp("**bold** and *italic* with `code`")
|
||
|
|
assert "bold" in result
|
||
|
|
assert "italic" in result
|
||
|
|
assert "code" in result
|
||
|
|
|
||
|
|
def test_table_conversion(self):
|
||
|
|
md = "| Name | Age |\n|------|-----|\n| John | 30 |"
|
||
|
|
result = markdown_to_whatsapp(md)
|
||
|
|
assert "John" in result
|
||
|
|
|
||
|
|
def test_empty_link_text(self):
|
||
|
|
result = markdown_to_whatsapp("[](https://example.com)")
|
||
|
|
assert "example.com" in result
|
||
|
|
|
||
|
|
|
||
|
|
class TestStructuredContextEntry:
|
||
|
|
def test_create_entry(self):
|
||
|
|
entry = StructuredContextEntry(
|
||
|
|
label="test",
|
||
|
|
source=ContextSource.BUTTON,
|
||
|
|
type=ContextType.STRUCTURED_DATA,
|
||
|
|
data={"key": "value"},
|
||
|
|
)
|
||
|
|
assert entry.label == "test"
|
||
|
|
assert entry.source == ContextSource.BUTTON
|
||
|
|
assert entry.type == ContextType.STRUCTURED_DATA
|
||
|
|
assert entry.data["key"] == "value"
|
||
|
|
|
||
|
|
def test_default_values(self):
|
||
|
|
entry = StructuredContextEntry(label="default_test")
|
||
|
|
assert entry.source == ContextSource.UNKNOWN
|
||
|
|
assert entry.type == ContextType.STRUCTURED_DATA
|
||
|
|
assert entry.data == {}
|
||
|
|
|
||
|
|
|
||
|
|
class TestUntrustedStructuredContext:
|
||
|
|
def test_empty_context(self):
|
||
|
|
ctx = UntrustedStructuredContext()
|
||
|
|
assert ctx.is_empty() is True
|
||
|
|
assert len(ctx.entries) == 0
|
||
|
|
|
||
|
|
def test_add_entry(self):
|
||
|
|
ctx = UntrustedStructuredContext()
|
||
|
|
entry = StructuredContextEntry(label="test")
|
||
|
|
ctx.add_entry(entry)
|
||
|
|
assert ctx.is_empty() is False
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
|
||
|
|
def test_to_metadata(self):
|
||
|
|
ctx = UntrustedStructuredContext()
|
||
|
|
entry = StructuredContextEntry(
|
||
|
|
label="location",
|
||
|
|
source=ContextSource.LOCATION,
|
||
|
|
data={"latitude": 39.9, "longitude": 116.4},
|
||
|
|
)
|
||
|
|
ctx.add_entry(entry)
|
||
|
|
meta = ctx.to_metadata()
|
||
|
|
assert "structured_location" in meta
|
||
|
|
assert meta["structured_location"]["source"] == "location"
|
||
|
|
|
||
|
|
def test_get_by_label(self):
|
||
|
|
ctx = UntrustedStructuredContext()
|
||
|
|
entry = StructuredContextEntry(label="contact")
|
||
|
|
ctx.add_entry(entry)
|
||
|
|
assert ctx.get_by_label("contact") is not None
|
||
|
|
assert ctx.get_by_label("nonexistent") is None
|
||
|
|
|
||
|
|
def test_get_by_source(self):
|
||
|
|
ctx = UntrustedStructuredContext()
|
||
|
|
ctx.add_entry(StructuredContextEntry(label="btn1", source=ContextSource.BUTTON))
|
||
|
|
ctx.add_entry(StructuredContextEntry(label="btn2", source=ContextSource.BUTTON))
|
||
|
|
ctx.add_entry(StructuredContextEntry(label="loc1", source=ContextSource.LOCATION))
|
||
|
|
buttons = ctx.get_by_source(ContextSource.BUTTON)
|
||
|
|
assert len(buttons) == 2
|
||
|
|
locations = ctx.get_by_source(ContextSource.LOCATION)
|
||
|
|
assert len(locations) == 1
|
||
|
|
|
||
|
|
def test_from_contact(self):
|
||
|
|
ctx = UntrustedStructuredContext.from_contact("John", "vcard_data", [{"name": "Jane"}])
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
assert ctx.entries[0].label == "contact"
|
||
|
|
|
||
|
|
def test_from_location(self):
|
||
|
|
ctx = UntrustedStructuredContext.from_location(39.9, 116.4, "Beijing", "China")
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
assert ctx.entries[0].label == "location"
|
||
|
|
assert ctx.entries[0].data["latitude"] == 39.9
|
||
|
|
|
||
|
|
def test_from_reaction(self):
|
||
|
|
ctx = UntrustedStructuredContext.from_reaction("👍", "msg-001", "test@s.whatsapp.net")
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
assert ctx.entries[0].label == "reaction"
|
||
|
|
|
||
|
|
def test_from_poll(self):
|
||
|
|
ctx = UntrustedStructuredContext.from_poll("Vote", ["A", "B"], ["A"])
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
assert ctx.entries[0].label == "poll"
|
||
|
|
|
||
|
|
def test_from_button(self):
|
||
|
|
ctx = UntrustedStructuredContext.from_button("btn-1", "Approve")
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
assert ctx.entries[0].label == "button"
|
||
|
|
|
||
|
|
def test_from_forward(self):
|
||
|
|
ctx = UntrustedStructuredContext.from_forward(3, "newsletter@jid")
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
assert ctx.entries[0].label == "forward"
|
||
|
|
assert ctx.entries[0].data["is_forwarded"] is True
|
||
|
|
|
||
|
|
def test_from_mentions(self):
|
||
|
|
ctx = UntrustedStructuredContext.from_mentions(["jid1", "jid2"])
|
||
|
|
assert len(ctx.entries) == 1
|
||
|
|
assert ctx.entries[0].label == "mention"
|
||
|
|
assert len(ctx.entries[0].data["mentioned_jids"]) == 2
|
||
|
|
|
||
|
|
|
||
|
|
class TestContextSource:
|
||
|
|
def test_enum_values(self):
|
||
|
|
assert ContextSource.CONTACT == "contact"
|
||
|
|
assert ContextSource.LOCATION == "location"
|
||
|
|
assert ContextSource.REACTION == "reaction"
|
||
|
|
assert ContextSource.POLL == "poll"
|
||
|
|
assert ContextSource.BUTTON == "button"
|
||
|
|
assert ContextSource.FORWARD == "forward"
|
||
|
|
assert ContextSource.MENTION == "mention"
|
||
|
|
assert ContextSource.MEDIA == "media"
|
||
|
|
assert ContextSource.UNKNOWN == "unknown"
|
||
|
|
|
||
|
|
|
||
|
|
class TestContextType:
|
||
|
|
def test_enum_values(self):
|
||
|
|
assert ContextType.STRUCTURED_DATA == "structured_data"
|
||
|
|
assert ContextType.USER_GENERATED == "user_generated"
|
||
|
|
assert ContextType.SYSTEM == "system"
|