from unittest.mock import AsyncMock, MagicMock, patch import pytest from messaging.handler import ClaudeMessageHandler from messaging.models import IncomingMessage from messaging.rendering.telegram_markdown import render_markdown_to_mdv2 from messaging.trees.data import MessageNode, MessageState def test_render_markdown_to_mdv2_empty_returns_empty(): assert render_markdown_to_mdv2("") == "" def test_render_markdown_to_mdv2_covers_common_structures(): md = ( "# Heading\n\n" "Text with *em* and **strong** and ~~strike~~ and `code`.\n\n" "- item1\n" "- item2\n\n" "3. third\n\n" "> quote\n\n" "[link](http://example.com/a\\)b)\n\n" "![alt](http://example.com/img.png)\n\n" "```python\nprint('x')\n```\n" ) out = render_markdown_to_mdv2(md) assert "*Heading*" in out assert "_em_" in out assert "*strong*" in out assert "~strike~" in out assert "`code`" in out assert "\\- item1" in out assert "3\\." in out assert "> quote" in out assert "[link]" in out assert "alt (http://example.com/img.png)" in out assert "```" in out def test_render_markdown_to_mdv2_renders_table_as_code_block(): md = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter.\n" out = render_markdown_to_mdv2(md) assert "```" in out assert "| a" in out assert "| b" in out assert "| ---" in out assert "After" in out def test_render_markdown_to_mdv2_table_without_blank_line_still_renders(): md = "Here's a table:\n| a | b |\n|---|---|\n| 1 | 2 |\n" out = render_markdown_to_mdv2(md) assert "Here's a table" in out assert "```" in out assert "| a" in out assert "| ---" in out def test_render_markdown_to_mdv2_table_escapes_backticks_and_backslashes_in_cells(): md = "| a | b |\n|---|---|\n| \\\\ | `` ` `` |\n" out = render_markdown_to_mdv2(md) assert "```" in out # In Telegram code blocks we escape backslashes and backticks. assert "\\\\" in out # rendered cell backslash becomes double-backslash assert "\\`" in out # rendered cell backtick is escaped def test_render_markdown_to_mdv2_table_inside_list_keeps_bullet_prefix(): md = "-\n | a | b |\n |---|---|\n | 1 | 2 |\n" out = render_markdown_to_mdv2(md) assert "```" in out assert out.lstrip().startswith("\\-") assert out.find("\\-") < out.find("```") def test_get_initial_status_branches(): platform = MagicMock() cli_manager = MagicMock() session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) with ( patch.object( handler.tree_queue, "is_node_tree_busy", MagicMock(return_value=True) ), patch.object(handler.tree_queue, "get_queue_size", MagicMock(return_value=2)), ): s1 = handler._get_initial_status(tree=object(), parent_node_id="p") assert "Queued" in s1 assert "position 3" in s1 or "position 3" in s1.replace("\\", "") with patch.object( handler.tree_queue, "is_node_tree_busy", MagicMock(return_value=False) ): s2 = handler._get_initial_status(tree=object(), parent_node_id="p") assert "Continuing" in s2 s3 = handler._get_initial_status(tree=None, parent_node_id=None) assert "Launching" in s3 @pytest.mark.asyncio async def test_update_queue_positions_handles_snapshot_error_and_skips_non_pending(): platform = MagicMock() platform.queue_edit_message = AsyncMock() platform.fire_and_forget = MagicMock( side_effect=lambda c: getattr(c, "close", lambda: None)() ) cli_manager = MagicMock() session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) # Snapshot error is swallowed. tree = MagicMock() tree.get_queue_snapshot = AsyncMock(side_effect=RuntimeError("boom")) await handler.update_queue_positions(tree) platform.fire_and_forget.assert_not_called() # Normal path: only PENDING nodes get an update. node_pending = MagicMock() node_pending.state = MessageState.PENDING node_pending.incoming.chat_id = "c" node_pending.status_message_id = "s" node_done = MagicMock() node_done.state = MessageState.COMPLETED tree.get_queue_snapshot = AsyncMock(return_value=["n1", "n2"]) tree.get_node = MagicMock(side_effect=[node_pending, node_done]) await handler.update_queue_positions(tree) assert platform.fire_and_forget.call_count == 1 @pytest.mark.asyncio async def test_process_node_session_limit_marks_error_and_updates_ui(): platform = MagicMock() platform.queue_edit_message = AsyncMock() platform.fire_and_forget = MagicMock( side_effect=lambda c: getattr(c, "close", lambda: None)() ) cli_manager = MagicMock() cli_manager.get_or_create_session = AsyncMock(side_effect=RuntimeError("limit")) cli_manager.get_stats.return_value = {"active_sessions": 0} session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) fake_tree = MagicMock() fake_tree.update_state = AsyncMock() with patch.object( handler.tree_queue, "get_tree_for_node", MagicMock(return_value=fake_tree) ): incoming = IncomingMessage( text="hi", chat_id="c", user_id="u", message_id="n1", platform="telegram", ) node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1") await handler._process_node("n1", node) assert platform.queue_edit_message.await_count >= 1 fake_tree.update_state.assert_awaited() @pytest.mark.asyncio async def test_stop_all_tasks_saves_tree_for_cancelled_nodes(): platform = MagicMock() platform.queue_edit_message = AsyncMock() platform.fire_and_forget = MagicMock( side_effect=lambda c: getattr(c, "close", lambda: None)() ) cli_manager = MagicMock() cli_manager.stop_all = AsyncMock() cli_manager.get_stats.return_value = {"active_sessions": 0} session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) incoming = IncomingMessage( text="hi", chat_id="c", user_id="u", message_id="n1", platform="telegram", ) node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1") tree = MagicMock() tree.root_id = "root" tree.to_dict = MagicMock(return_value={"root": "ok"}) with ( patch.object(handler.tree_queue, "cancel_all", AsyncMock(return_value=[node])), patch.object( handler.tree_queue, "get_tree_for_node", MagicMock(return_value=tree) ), ): count = await handler.stop_all_tasks() assert count == 1 cli_manager.stop_all.assert_awaited_once() session_store.save_tree.assert_called_once_with("root", {"root": "ok"}) @pytest.mark.asyncio async def test_handle_message_reply_with_tree_but_no_parent_treated_as_new(): platform = MagicMock() platform.queue_send_message = AsyncMock(return_value="status_1") platform.queue_edit_message = AsyncMock() cli_manager = MagicMock() cli_manager.get_stats.return_value = {"active_sessions": 0} session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) # Force "tree exists but parent can't be resolved" branch. mock_queue = MagicMock() mock_queue.get_tree_for_node.return_value = object() mock_queue.resolve_parent_node_id.return_value = None mock_queue.create_tree = AsyncMock( return_value=MagicMock(root_id="root", to_dict=MagicMock(return_value={"t": 1})) ) mock_queue.register_node = MagicMock() mock_queue.enqueue = AsyncMock(return_value=False) handler.replace_tree_queue(mock_queue) incoming = IncomingMessage( text="reply", chat_id="c", user_id="u", message_id="m1", platform="telegram", reply_to_message_id="some_reply", ) await handler.handle_message(incoming) mock_queue.create_tree.assert_awaited_once() @pytest.mark.asyncio async def test_update_ui_handles_transcript_render_exception(): """When transcript.render raises, update_ui catches and does not crash.""" platform = MagicMock() platform.queue_edit_message = AsyncMock() platform.fire_and_forget = MagicMock( side_effect=lambda c: getattr(c, "close", lambda: None)() ) cli_manager = MagicMock() session_store = MagicMock() async def _mock_start_task(*args, **kwargs): yield { "type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}, } yield {"type": "complete", "status": "success"} mock_session = MagicMock() mock_session.start_task = _mock_start_task cli_manager.get_or_create_session = AsyncMock( return_value=(mock_session, "s1", False) ) cli_manager.remove_session = AsyncMock() cli_manager.get_stats.return_value = {"active_sessions": 0} handler = ClaudeMessageHandler(platform, cli_manager, session_store) mock_queue = MagicMock() mock_queue.get_tree_for_node.return_value = None handler.replace_tree_queue(mock_queue) incoming = IncomingMessage( text="hi", chat_id="c", user_id="u", message_id="n1", platform="telegram", ) node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1") with patch.object(handler, "_create_transcript_and_render_ctx") as mock_create: transcript = MagicMock() transcript.render = MagicMock(side_effect=ValueError("render failed")) render_ctx = MagicMock() mock_create.return_value = (transcript, render_ctx) await handler._process_node("n1", node) assert transcript.render.call_count >= 1 @pytest.mark.asyncio async def test_handle_message_incoming_text_none_safe(): """handle_message does not crash when incoming.text is None (e.g. malformed adapter).""" platform = MagicMock() platform.queue_send_message = AsyncMock(return_value="status_1") platform.queue_edit_message = AsyncMock() cli_manager = MagicMock() cli_manager.get_stats.return_value = {"active_sessions": 0} session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) mock_queue = MagicMock() mock_queue.get_tree_for_node.return_value = None mock_queue.resolve_parent_node_id.return_value = None mock_queue.create_tree = AsyncMock( return_value=MagicMock(root_id="root", to_dict=MagicMock(return_value={"t": 1})) ) mock_queue.register_node = MagicMock() mock_queue.enqueue = AsyncMock(return_value=True) handler.replace_tree_queue(mock_queue) incoming = MagicMock() incoming.text = None incoming.chat_id = "c" incoming.user_id = "u" incoming.message_id = "m1" incoming.platform = "telegram" incoming.reply_to_message_id = None incoming.is_reply = MagicMock(return_value=False) await handler.handle_message(incoming) mock_queue.create_tree.assert_awaited_once() @pytest.mark.asyncio async def test_process_parsed_event_malformed_content_continues(): """Malformed/unknown parsed event does not crash _process_parsed_event.""" platform = MagicMock() platform.queue_edit_message = AsyncMock() cli_manager = MagicMock() session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) transcript = MagicMock() update_ui = AsyncMock() last_status, had = await handler._process_parsed_event( parsed={"type": "unknown_type"}, transcript=transcript, update_ui=update_ui, last_status=None, had_transcript_events=False, tree=None, node_id="n1", captured_session_id=None, ) assert last_status is None assert had is False @pytest.mark.asyncio async def test_handler_update_ui_edit_failure_does_not_crash(): """When queue_edit_message raises during streaming, _process_node continues and completes.""" platform = MagicMock() platform.queue_edit_message = AsyncMock( side_effect=RuntimeError("Telegram API error") ) platform.fire_and_forget = MagicMock( side_effect=lambda c: getattr(c, "close", lambda: None)() ) async def _mock_start_task(*args, **kwargs): yield { "type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}, } yield { "type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " world"}, } yield {"type": "complete", "status": "success"} mock_session = MagicMock() mock_session.start_task = _mock_start_task cli_manager = MagicMock() cli_manager.get_or_create_session = AsyncMock( return_value=(mock_session, "s1", False) ) cli_manager.remove_session = AsyncMock() cli_manager.get_stats.return_value = {"active_sessions": 0} session_store = MagicMock() handler = ClaudeMessageHandler(platform, cli_manager, session_store) mock_queue = MagicMock() mock_queue.get_tree_for_node.return_value = None handler.replace_tree_queue(mock_queue) incoming = IncomingMessage( text="hi", chat_id="c", user_id="u", message_id="n1", platform="telegram", ) node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1") await handler._process_node("n1", node) cli_manager.remove_session.assert_awaited_once()