From 3006020106804f3fd12002147c05f74757550364 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 27 Apr 2026 23:36:55 +0200 Subject: [PATCH] test(RED): 4 failing tests expose DM engine config bypass & spam bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests expose: 1. DM Engine ignores dm_reply.enabled config (checks nonexistent 'disable_ai_messaging') 2. Logs 'Successfully sent' without verifying actual Send button click 3. Generates garbage replies from 'No previous context' (story replies) 4. No max-iteration guard β€” sent 20 messages in test, 8 in production All 4 tests FAIL. Ready for GREEN phase. --- tests/e2e/test_e2e_dm_engine.py | 434 +++++++++++++++++++++++++++++++- 1 file changed, 424 insertions(+), 10 deletions(-) diff --git a/tests/e2e/test_e2e_dm_engine.py b/tests/e2e/test_e2e_dm_engine.py index 9e4e82e..77740ce 100644 --- a/tests/e2e/test_e2e_dm_engine.py +++ b/tests/e2e/test_e2e_dm_engine.py @@ -1,15 +1,429 @@ +""" +πŸ”΄ RED Phase β€” DM Engine Integrity Tests +========================================== + +These tests expose 4 critical production bugs discovered in run 0f1475ff: +1. DM Engine ignores `dm_reply.enabled: false` β€” fires DMs anyway +2. DM Engine logs "Successfully sent" without verifying actual send +3. DM Engine generates replies with "No previous context" β†’ garbage output +4. DM Engine lacks max-iteration guard β†’ sent 8 DMs in 2 minutes + +Each test MUST fail before any production code is touched (TDD RED). +""" + +import types +from unittest.mock import MagicMock, patch + import pytest -@pytest.mark.skip( - reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite." -) -def test_e2e_dm_full_flow_success_real(): - pass +# ═══════════════════════════════════════════════════════ +# Helpers β€” Minimal realistic mocks (no lying) +# ═══════════════════════════════════════════════════════ -@pytest.mark.skip( - reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite." -) -def test_e2e_dm_no_messages_real(): - pass +def _make_dm_inbox_xml(): + """Real-world DM inbox XML with unread thread markers.""" + return """ + + + + + + +""" + + +def _make_dm_thread_xml(last_message="Hey what's up?"): + """Real-world DM thread XML with message content.""" + return f""" + + + + + + + +""" + + +def _make_dm_thread_xml_no_context(): + """DM thread XML with a story reply β€” NO extractable text message.""" + return """ + + + + + + + +""" + + +def _make_configs(dm_reply_enabled=False): + """Create a realistic Config mock that mirrors get_plugin_config behavior.""" + configs = MagicMock() + configs.get_plugin_config.return_value = {"enabled": dm_reply_enabled} + configs.args = types.SimpleNamespace( + disable_ai_messaging=False, + ai_condenser_model="qwen3.5:latest", + ai_condenser_url="http://localhost:11434/api/generate", + ) + return configs + + +def _make_session_state(): + session = MagicMock() + session.totalMessages = 0 + session.check_limit.return_value = (False,) + return session + + +def _make_dopamine(boredom_sequence=None): + """Dopamine engine that exits after N iterations.""" + dopamine = MagicMock() + if boredom_sequence is None: + # Default: 3 iterations then session over + call_count = {"n": 0} + + def _is_over(): + call_count["n"] += 1 + return call_count["n"] > 3 + + dopamine.is_app_session_over.side_effect = _is_over + else: + dopamine.is_app_session_over.side_effect = boredom_sequence + + dopamine.boredom = 0.0 + dopamine.wants_to_change_feed.return_value = False + return dopamine + + +def _make_telepathic(unread_nodes=None, msg_nodes=None, input_nodes=None, send_nodes=None): + """Telepathic engine returning controlled semantic nodes.""" + telepathic = MagicMock() + + default_unread = [{"x": 500, "y": 300, "text": "johndoe", "skip": False}] + default_msg = [{"x": 500, "y": 600, "text": "Hey what's up?", "skip": False}] + default_input = [{"x": 500, "y": 900, "text": "Message…", "skip": False}] + default_send = [{"x": 800, "y": 900, "text": "", "desc": "Send", "skip": False}] + + def _extract(xml, intent, threshold=0.7): + if "unread" in intent.lower(): + return unread_nodes if unread_nodes is not None else default_unread + elif "last received" in intent.lower(): + return msg_nodes if msg_nodes is not None else default_msg + elif "input" in intent.lower(): + return input_nodes if input_nodes is not None else default_input + elif "send" in intent.lower(): + return send_nodes if send_nodes is not None else default_send + return [] + + telepathic._extract_semantic_nodes.side_effect = _extract + return telepathic + + +# ═══════════════════════════════════════════════════════ +# Test 1: DM Engine MUST respect dm_reply.enabled config +# ═══════════════════════════════════════════════════════ + + +class TestDMConfigGating: + """Verifies that dm_reply.enabled=false prevents ALL DM interactions.""" + + def test_dm_engine_blocks_when_dm_reply_disabled(self): + """BUG: dm_engine.py:96 checks 'disable_ai_messaging' (doesn't exist) + instead of dm_reply.enabled from config. This means DMs fire even when + config says enabled: false. + + EXPECTED: DM engine should refuse to send any messages when dm_reply + is disabled in the config. + """ + from GramAddict.core.dm_engine import _run_zero_latency_dm_loop + + device = MagicMock() + device.dump_hierarchy.return_value = _make_dm_inbox_xml() + + configs = _make_configs(dm_reply_enabled=False) + session_state = _make_session_state() + dopamine = _make_dopamine(boredom_sequence=[False, True]) + telepathic = _make_telepathic() + + cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()} + + with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \ + patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \ + patch("GramAddict.core.bot_flow._humanized_click"), \ + patch("GramAddict.core.bot_flow.sleep"): + _run_zero_latency_dm_loop( + device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack + ) + + # The LLM should NEVER be called when dm_reply is disabled + mock_llm.assert_not_called() + # Ghost typing should NEVER happen + mock_type.assert_not_called() + # No messages should be counted + assert session_state.totalMessages == 0, ( + f"DM Engine sent {session_state.totalMessages} messages with dm_reply DISABLED!" + ) + + +# ═══════════════════════════════════════════════════════ +# Test 2: DM Engine MUST verify send actually happened +# ═══════════════════════════════════════════════════════ + + +class TestDMSendVerification: + """Verifies that 'Successfully sent' is only logged when the message was actually sent.""" + + def test_dm_engine_rejects_click_on_wrong_element(self): + """BUG: dm_engine.py:138 logs success after clicking ANY element the + VLM returns β€” including 'Unflag', reaction containers, or input fields + themselves. There is ZERO structural verification. + + Evidence from logs: + - Clicked 'message_reactions_pill_container' β†’ logged success + - Clicked 'Unflag' button β†’ logged success + - Clicked 'row_thread_composer_edittext' β†’ logged success (clicked the INPUT not send!) + + EXPECTED: DM engine must verify the clicked element is actually + a "Send" button (desc='Send' or id contains 'send_button'). + """ + from GramAddict.core.dm_engine import _run_zero_latency_dm_loop + + device = MagicMock() + # First dump = inbox, second = thread, third = thread (for send button search) + device.dump_hierarchy.side_effect = [ + _make_dm_inbox_xml(), + _make_dm_thread_xml(), + _make_dm_thread_xml(), # after typing, re-dump for send button + _make_dm_inbox_xml(), # after pressing back + _make_dm_inbox_xml(), # no more unread + ] + + configs = _make_configs(dm_reply_enabled=True) + session_state = _make_session_state() + dopamine = _make_dopamine(boredom_sequence=[False, False, True]) + + # Telepathic returns WRONG element for "send button" β€” the reactions container + wrong_send_node = [{"x": 500, "y": 800, "text": "", "desc": "", "skip": False, + "original_attribs": {"resource-id": "com.instagram.android:id/message_reactions_pill_container"}}] + telepathic = _make_telepathic(send_nodes=wrong_send_node) + + cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()} + + with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey! Nice to meet you!"}), \ + patch("GramAddict.core.stealth_typing.ghost_type"), \ + patch("GramAddict.core.bot_flow._humanized_click"), \ + patch("GramAddict.core.bot_flow.sleep"): + _run_zero_latency_dm_loop( + device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack + ) + + # Should NOT count as a successful message + assert session_state.totalMessages == 0, ( + f"DM Engine counted {session_state.totalMessages} messages after clicking " + f"'message_reactions_pill_container' instead of the Send button!" + ) + + +# ═══════════════════════════════════════════════════════ +# Test 3: DM Engine MUST NOT reply to context-less threads +# ═══════════════════════════════════════════════════════ + + +class TestDMContextRequirement: + """Verifies that the DM engine refuses to generate replies without context.""" + + def test_dm_engine_skips_thread_with_no_extractable_message(self): + """BUG: dm_engine.py:89-93 sets context_text='No previous context' + when no message text is found (story replies, media-only threads). + Then proceeds to call the LLM with that string, producing garbage + like 'the to the'. + + Evidence from logs: + 7 out of 8 threads had 'Last received message context: No previous context' + All 7 were blindly replied to anyway. + + EXPECTED: When context_text is 'No previous context' or empty, + the DM engine must SKIP the thread entirely (press back, continue). + """ + from GramAddict.core.dm_engine import _run_zero_latency_dm_loop + + device = MagicMock() + device.dump_hierarchy.side_effect = [ + _make_dm_inbox_xml(), + _make_dm_thread_xml_no_context(), # Thread with no text + _make_dm_inbox_xml(), # Back to inbox + _make_dm_inbox_xml(), # No more unread (empty) + ] + + configs = _make_configs(dm_reply_enabled=True) + session_state = _make_session_state() + dopamine = _make_dopamine(boredom_sequence=[False, False, True]) + + # No extractable text from thread + no_text_msg_nodes = [{"x": 500, "y": 600, "text": "", "skip": False}] + telepathic = _make_telepathic(msg_nodes=no_text_msg_nodes) + + cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()} + + with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \ + patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \ + patch("GramAddict.core.bot_flow._humanized_click"), \ + patch("GramAddict.core.bot_flow.sleep"): + _run_zero_latency_dm_loop( + device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack + ) + + # LLM should NOT be called for a context-less thread + mock_llm.assert_not_called() + mock_type.assert_not_called() + assert session_state.totalMessages == 0, ( + f"DM Engine replied to {session_state.totalMessages} threads with NO message context!" + ) + + +# ═══════════════════════════════════════════════════════ +# Test 4: DM Engine MUST have max-iteration guard +# ═══════════════════════════════════════════════════════ + + +class TestDMIterationLimit: + """Verifies the DM engine doesn't spam infinite replies.""" + + def test_dm_engine_caps_replies_per_session(self): + """BUG: dm_engine.py:34 while loop only exits on session timeout or + boredom. With 'aggressive_growth' strategy, boredom increments are + tiny (5-15 per DM) and the engine sent 8 DMs in 2 minutes. + + EXPECTED: DM engine must have an explicit max_replies_per_inbox + cap (e.g., 3) to prevent spam behavior. After reaching the cap, + it should return 'BOREDOM_CHANGE_FEED'. + """ + from GramAddict.core.dm_engine import _run_zero_latency_dm_loop + + device = MagicMock() + # Infinite supply of "unread" threads + device.dump_hierarchy.return_value = _make_dm_inbox_xml() + + configs = _make_configs(dm_reply_enabled=True) + session_state = _make_session_state() + + # Dopamine never gets bored (simulates aggressive_growth with low boredom) + dopamine = MagicMock() + dopamine.is_app_session_over.return_value = False + dopamine.wants_to_change_feed.return_value = False + dopamine.boredom = 0.0 + + telepathic = _make_telepathic() + cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()} + + send_count = {"n": 0} + original_check_limit = session_state.check_limit + + def _counting_check(*args, **kwargs): + if send_count["n"] > 20: + pytest.fail( + f"DM Engine sent {send_count['n']} messages without hitting any cap! " + f"Expected a hard limit of <= 5 replies per inbox visit." + ) + return (False,) + + session_state.check_limit.side_effect = _counting_check + + with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey!"}), \ + patch("GramAddict.core.stealth_typing.ghost_type"), \ + patch("GramAddict.core.bot_flow._humanized_click"), \ + patch("GramAddict.core.bot_flow.sleep"): + + # Monkey-patch totalMessages tracking + original_total = 0 + + class CountingProxy: + def __init__(self): + self._val = 0 + + def __iadd__(self, other): + self._val += other + send_count["n"] = self._val + if self._val > 20: + pytest.fail( + f"DM Engine sent {self._val} messages! No iteration guard present." + ) + return self + + def __int__(self): + return self._val + + # Force the session to never hit limits (simulating the real scenario) + result = _run_zero_latency_dm_loop( + device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack + ) + + # The engine should have self-limited to at most 5 replies + assert session_state.totalMessages <= 5, ( + f"DM Engine sent {session_state.totalMessages} messages in one inbox visit. " + f"Expected hard cap of <= 5 to prevent spam." + ) + + +# ═══════════════════════════════════════════════════════ +# Test 5: Bot Flow MUST NOT route to DM Engine when disabled +# ═══════════════════════════════════════════════════════ + + +class TestBotFlowDMGating: + """Verifies that bot_flow.py never calls _run_zero_latency_dm_loop + when dm_reply is disabled β€” even if SocialReciprocity desire fires.""" + + def test_social_reciprocity_never_includes_message_inbox_when_disabled(self): + """The target_map for SocialReciprocity should NEVER contain + 'MessageInbox' when dm_reply.enabled is false. + + This is a defense-in-depth test: even if GrowthBrain randomly + selects SocialReciprocity 100% of the time, MessageInbox must + not appear as an option. + """ + configs = _make_configs(dm_reply_enabled=False) + + # Simulate bot_flow.py target_map construction (lines 460-468) + target_map = { + "DiscoverNewContent": ["ExploreFeed", "ReelsFeed"], + "NurtureCommunity": ["HomeFeed", "StoriesFeed"], + "SocialReciprocity": ["FollowingList"], + } + + dm_config = configs.get_plugin_config("dm_reply") + if dm_config.get("enabled", False): + target_map["SocialReciprocity"].append("MessageInbox") + + assert "MessageInbox" not in target_map["SocialReciprocity"], ( + "MessageInbox was added to SocialReciprocity targets despite dm_reply.enabled=false!" + ) + + def test_social_reciprocity_includes_message_inbox_when_enabled(self): + """Positive test: When dm_reply.enabled is true, MessageInbox + SHOULD be in the target map.""" + configs = _make_configs(dm_reply_enabled=True) + + target_map = { + "DiscoverNewContent": ["ExploreFeed", "ReelsFeed"], + "NurtureCommunity": ["HomeFeed", "StoriesFeed"], + "SocialReciprocity": ["FollowingList"], + } + + dm_config = configs.get_plugin_config("dm_reply") + if dm_config.get("enabled", False): + target_map["SocialReciprocity"].append("MessageInbox") + + assert "MessageInbox" in target_map["SocialReciprocity"], ( + "MessageInbox should be in SocialReciprocity when dm_reply is enabled!" + )