import asyncio import json from unittest.mock import AsyncMock, MagicMock import pytest from app.channels.commands import KNOWN_CHANNEL_COMMANDS from app.channels.feishu import FeishuChannel from app.channels.message_bus import InboundMessage, MessageBus def _run(coro): loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) finally: loop.close() def test_feishu_on_message_plain_text(): bus = MessageBus() config = {"app_id": "test", "app_secret": "test"} channel = FeishuChannel(bus, config) # Create mock event event = MagicMock() event.event.message.chat_id = "chat_1" event.event.message.message_id = "msg_1" event.event.message.root_id = None event.event.sender.sender_id.open_id = "user_1" # Plain text content content_dict = {"text": "Hello world"} event.event.message.content = json.dumps(content_dict) # Call _on_message channel._on_message(event) # Since main_loop isn't running in this synchronous test, we can't easily assert on bus, # but we can intercept _make_inbound to check the parsed text. with pytest.MonkeyPatch.context() as m: mock_make_inbound = MagicMock() m.setattr(channel, "_make_inbound", mock_make_inbound) channel._on_message(event) mock_make_inbound.assert_called_once() assert mock_make_inbound.call_args[1]["text"] == "Hello world" def test_feishu_on_message_rich_text(): bus = MessageBus() config = {"app_id": "test", "app_secret": "test"} channel = FeishuChannel(bus, config) # Create mock event event = MagicMock() event.event.message.chat_id = "chat_1" event.event.message.message_id = "msg_1" event.event.message.root_id = None event.event.sender.sender_id.open_id = "user_1" # Rich text content (topic group / post) content_dict = {"content": [[{"tag": "text", "text": "Paragraph 1, part 1."}, {"tag": "text", "text": "Paragraph 1, part 2."}], [{"tag": "at", "text": "@bot"}, {"tag": "text", "text": " Paragraph 2."}]]} event.event.message.content = json.dumps(content_dict) with pytest.MonkeyPatch.context() as m: mock_make_inbound = MagicMock() m.setattr(channel, "_make_inbound", mock_make_inbound) channel._on_message(event) mock_make_inbound.assert_called_once() parsed_text = mock_make_inbound.call_args[1]["text"] # Expected text: # Paragraph 1, part 1. Paragraph 1, part 2. # # @bot Paragraph 2. assert "Paragraph 1, part 1. Paragraph 1, part 2." in parsed_text assert "@bot Paragraph 2." in parsed_text assert "\n\n" in parsed_text def test_feishu_receive_file_replaces_placeholders_in_order(): async def go(): bus = MessageBus() channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test"}) msg = InboundMessage( channel_name="feishu", chat_id="chat_1", user_id="user_1", text="before [image] middle [file] after", thread_ts="msg_1", files=[{"image_key": "img_key"}, {"file_key": "file_key"}], ) channel._receive_single_file = AsyncMock(side_effect=["/mnt/user-data/uploads/a.png", "/mnt/user-data/uploads/b.pdf"]) result = await channel.receive_file(msg, "thread_1") assert result.text == "before /mnt/user-data/uploads/a.png middle /mnt/user-data/uploads/b.pdf after" _run(go()) def test_feishu_on_message_extracts_image_and_file_keys(): bus = MessageBus() channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test"}) event = MagicMock() event.event.message.chat_id = "chat_1" event.event.message.message_id = "msg_1" event.event.message.root_id = None event.event.sender.sender_id.open_id = "user_1" # Rich text with one image and one file element. event.event.message.content = json.dumps( { "content": [ [ {"tag": "text", "text": "See"}, {"tag": "img", "image_key": "img_123"}, {"tag": "file", "file_key": "file_456"}, ] ] } ) with pytest.MonkeyPatch.context() as m: mock_make_inbound = MagicMock() m.setattr(channel, "_make_inbound", mock_make_inbound) channel._on_message(event) mock_make_inbound.assert_called_once() files = mock_make_inbound.call_args[1]["files"] assert files == [{"image_key": "img_123"}, {"file_key": "file_456"}] assert "[image]" in mock_make_inbound.call_args[1]["text"] assert "[file]" in mock_make_inbound.call_args[1]["text"] @pytest.mark.parametrize("command", sorted(KNOWN_CHANNEL_COMMANDS)) def test_feishu_recognizes_all_known_slash_commands(command): """Every entry in KNOWN_CHANNEL_COMMANDS must be classified as a command.""" bus = MessageBus() config = {"app_id": "test", "app_secret": "test"} channel = FeishuChannel(bus, config) event = MagicMock() event.event.message.chat_id = "chat_1" event.event.message.message_id = "msg_1" event.event.message.root_id = None event.event.sender.sender_id.open_id = "user_1" event.event.message.content = json.dumps({"text": command}) with pytest.MonkeyPatch.context() as m: mock_make_inbound = MagicMock() m.setattr(channel, "_make_inbound", mock_make_inbound) channel._on_message(event) mock_make_inbound.assert_called_once() assert mock_make_inbound.call_args[1]["msg_type"].value == "command", f"{command!r} should be classified as COMMAND" @pytest.mark.parametrize( "text", [ "/unknown", "/mnt/user-data/outputs/prd/technical-design.md", "/etc/passwd", "/not-a-command at all", ], ) def test_feishu_treats_unknown_slash_text_as_chat(text): """Slash-prefixed text that is not a known command must be classified as CHAT.""" bus = MessageBus() config = {"app_id": "test", "app_secret": "test"} channel = FeishuChannel(bus, config) event = MagicMock() event.event.message.chat_id = "chat_1" event.event.message.message_id = "msg_1" event.event.message.root_id = None event.event.sender.sender_id.open_id = "user_1" event.event.message.content = json.dumps({"text": text}) with pytest.MonkeyPatch.context() as m: mock_make_inbound = MagicMock() m.setattr(channel, "_make_inbound", mock_make_inbound) channel._on_message(event) mock_make_inbound.assert_called_once() assert mock_make_inbound.call_args[1]["msg_type"].value == "chat", f"{text!r} should be classified as CHAT"