deer-flow/backend/tests/blocking_io/test_channel_outbound_files.py

193 lines
6.6 KiB
Python

"""Regression anchors for outbound IM attachment file IO.
Feishu, Telegram, and WeCom send attachments from async channel handlers. File
open/read/hash work must run off the event loop; otherwise a large outbound
artifact stalls every channel and Gateway coroutine on that worker.
"""
from __future__ import annotations
import asyncio
import base64
import builtins
import hashlib
import threading
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.telegram import TelegramChannel
from app.channels.wecom import WeComChannel
pytestmark = pytest.mark.asyncio
def _attachment(path: Path, *, is_image: bool) -> ResolvedAttachment:
return ResolvedAttachment(
virtual_path=f"/mnt/user-data/outputs/{path.name}",
actual_path=path,
filename=path.name,
mime_type="image/png" if is_image else "application/octet-stream",
size=path.stat().st_size,
is_image=is_image,
)
def _outbound(channel_name: str) -> OutboundMessage:
return OutboundMessage(
channel_name=channel_name,
chat_id="123",
thread_id="thread-1",
text="attachment",
)
def _builder(*, captured_file: dict[str, object] | None = None) -> MagicMock:
builder = MagicMock()
for method_name in ("request_body", "image_type", "file_type", "file_name"):
getattr(builder, method_name).return_value = builder
if captured_file is not None:
def _capture(file_obj):
captured_file["file"] = file_obj
return builder
builder.image.side_effect = _capture
builder.file.side_effect = _capture
builder.build.return_value = object()
return builder
async def test_feishu_outbound_uploads_do_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
image_path = tmp_path / "chart.png"
file_path = tmp_path / "report.pdf"
await asyncio.to_thread(image_path.write_bytes, b"image-bytes")
await asyncio.to_thread(file_path.write_bytes, b"file-bytes")
channel = FeishuChannel(MessageBus(), {})
channel._api_client = MagicMock()
real_open = builtins.open
opened_on_threads: list[int] = []
tracked_paths = {str(image_path), str(file_path)}
def _tracked_open(file, *args, **kwargs):
if str(file) in tracked_paths:
opened_on_threads.append(threading.get_ident())
return real_open(file, *args, **kwargs)
monkeypatch.setattr(builtins, "open", _tracked_open)
image_capture: dict[str, object] = {}
channel._CreateImageRequest = MagicMock()
channel._CreateImageRequest.builder.return_value = _builder()
channel._CreateImageRequestBody = MagicMock()
channel._CreateImageRequestBody.builder.return_value = _builder(captured_file=image_capture)
image_response = MagicMock()
image_response.success.return_value = True
image_response.data.image_key = "image-key"
image_worker_thread: int | None = None
def _create_image(_request):
nonlocal image_worker_thread
image_worker_thread = threading.get_ident()
file_obj = image_capture["file"]
assert not file_obj.closed
assert file_obj.read() == b"image-bytes"
return image_response
channel._api_client.im.v1.image.create.side_effect = _create_image
file_capture: dict[str, object] = {}
channel._CreateFileRequest = MagicMock()
channel._CreateFileRequest.builder.return_value = _builder()
channel._CreateFileRequestBody = MagicMock()
channel._CreateFileRequestBody.builder.return_value = _builder(captured_file=file_capture)
file_response = MagicMock()
file_response.success.return_value = True
file_response.data.file_key = "file-key"
file_worker_thread: int | None = None
def _create_file(_request):
nonlocal file_worker_thread
file_worker_thread = threading.get_ident()
file_obj = file_capture["file"]
assert not file_obj.closed
assert file_obj.read() == b"file-bytes"
return file_response
channel._api_client.im.v1.file.create.side_effect = _create_file
event_loop_thread = threading.get_ident()
assert await channel._upload_image(image_path) == "image-key"
assert await channel._upload_file(file_path, file_path.name) == "file-key"
assert len(opened_on_threads) == 2
assert all(thread_id != event_loop_thread for thread_id in opened_on_threads)
assert image_worker_thread != event_loop_thread
assert file_worker_thread != event_loop_thread
@pytest.mark.parametrize("is_image", [False, True])
async def test_telegram_outbound_upload_does_not_block_event_loop(tmp_path: Path, is_image: bool) -> None:
path = tmp_path / ("chart.png" if is_image else "report.bin")
payload = b"telegram-payload"
await asyncio.to_thread(path.write_bytes, payload)
sent_file = None
class _Bot:
async def send_document(self, **kwargs):
nonlocal sent_file
sent_file = kwargs["document"]
return SimpleNamespace(message_id=7)
async def send_photo(self, **kwargs):
nonlocal sent_file
sent_file = kwargs["photo"]
if hasattr(sent_file, "read"):
sent_file.read()
return SimpleNamespace(message_id=7)
channel = TelegramChannel(MessageBus(), {})
channel._application = SimpleNamespace(bot=_Bot())
assert await channel.send_file(_outbound("telegram"), _attachment(path, is_image=is_image))
assert sent_file.input_file_content == payload
assert sent_file.filename == path.name
async def test_wecom_outbound_upload_does_not_block_event_loop(tmp_path: Path) -> None:
path = tmp_path / "report.bin"
payload = b"x" * (512 * 1024 + 17)
await asyncio.to_thread(path.write_bytes, payload)
channel = WeComChannel(MessageBus(), {})
channel._ws_client = object()
channel._send_ws_upload_command = AsyncMock(
side_effect=[
{"body": {"upload_id": "upload-1"}},
{"body": {}},
{"body": {}},
{"body": {"media_id": "media-1"}},
]
)
result = await channel._upload_media_ws(
media_type="file",
filename=path.name,
path=str(path),
size=len(payload),
)
assert result == "media-1"
calls = channel._send_ws_upload_command.await_args_list
assert calls[0].args[1]["md5"] == hashlib.md5(payload).hexdigest()
encoded_chunks = [call.args[1]["base64_data"] for call in calls[1:-1]]
assert b"".join(base64.b64decode(chunk) for chunk in encoded_chunks) == payload