"""Runtime-прогон всех core/media MCP-тулов против фейкового Telethon-клиента.
Дополнение к test_login_flow (там только логин): здесь проверяется, что каждый
тул строит валидные вызовы Telethon (TL-запросы/kwargs из реальной версии),
сериализаторы справляются с настоящими TL-объектами, а ошибки возвращаются
данными ({"error": …}), а не падают. По мотивам Production-бага: в `_tool`
стоял не существующий класс errors.EntityNotFoundError (AttributeError в
except-клаузе), а messages_history шёл offset_id=None — TL-сериализация не
принимает None для int.
"""
import asyncio
import base64
import sys
import types
from datetime import datetime, timezone
from pathlib import Path
import pytest
import telethon.errors
from telethon.tl.types import (
Chat, ChatPhotoEmpty, Dialog, Document, DocumentAttributeAudio,
DocumentAttributeFilename, InputPeerChat, InputPeerUser, Message,
MessageMediaDocument, MessageMediaPhoto, PeerChat, PeerUser,
PhoneCallProtocol, PhoneCallWaiting, Photo, PhotoSize, UpdatePhoneCall, User,
)
BACKEND = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND))
from app.config import get_settings
from app.db import get_db, init_db
from app.mcp import tools
from app.tg.calls import _calls
NOW = datetime.now(timezone.utc)
class DummyMCP:
"""Ловит тулерегистрацию register_tools: тулы по имени, без FastMCP."""
def __init__(self):
self.registered = {}
def tool(self):
def deco(fn):
self.registered[fn.__name__] = fn
return fn
return deco
_TOOLS_CACHE = {}
def get_tools(module=None):
"""register_tools → dict name → функция (кэш на модуль tools)."""
dm = DummyMCP()
_T = dm
module.register_tools(_T)
return _T.registered
class FakeManager:
def __init__(self, clients):
self.clients = clients # account_id → client
self.saved = []
async def get_client(self, account_id):
return self.clients[account_id]
async def save_session(self, account_id):
self.saved.append(account_id)
def pending_client(self, login_id):
return None
class FakeClient:
"""Подмножество Telethon-клиента, которого хватает тулам."""
def __init__(self, *, me=None, dialogs=None, by_id=None, contacts=None,
entities=None):
self.me = me or User(id=42, first_name="Тест", last_name="Юзер",
username="tester", phone="+79991234567")
self.dialogs = dialogs or []
self.by_id = by_id or {} # raw peer id (InputPeerChat.chat_id) → list[Message]
self.contacts = contacts or []
# маркированный dialog_id → InputPeer (как в пуле manager)
self.input_entities = entities or {777: InputPeerChat(chat_id=777)}
async def get_me(self):
return self.me
async def get_input_entity(self, peer):
ent = self.input_entities.get(peer)
if ent is None:
raise ValueError(f"Could not find the input entity for {peer}")
return ent
async def get_entity(self, entity):
return entity
def iter_dialogs(self, *, limit=100):
async def it():
for d in self.dialogs[:limit]:
yield d
return it()
@staticmethod
def _peer_key(entity):
return getattr(entity, "chat_id", getattr(entity, "user_id", 777))
async def get_messages(self, entity, *, limit=100, offset_id=0, min_id=0,
ids=None, search=None, from_user=None):
pool = self.by_id.get(self._peer_key(entity), [])
if ids:
found = {m.id: m for m in pool}
return [found.get(i) for i in ids]
if search is not None:
pool = [m for m in pool if search.lower() in (m.message or "").lower()]
return list(reversed(pool))[:limit] # новые первыми
async def send_message(self, entity, text, **kwargs):
return Message(id=max(m.id for m in self.by_id.get(777, [Message(id=0, peer_id=None, date=NOW)])) + 1,
peer_id=PeerChat(chat_id=777), date=NOW, message=text, out=True)
async def send_file(self, entity, *a, **kw):
mid = max(m.id for m in self.by_id.get(777, [Message(id=0, peer_id=None, date=NOW)])) + 1
doc = Document(id=1, access_hash=2, file_reference=b"x", date=NOW,
mime_type=kw.get("mime_type", "application/octet-stream"),
size=64, dc_id=1, attributes=kw.get("attributes", []))
return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message="",
media=MessageMediaDocument(document=doc), out=True)
async def edit_message(self, entity, mid, text):
return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message=text, out=True)
async def delete_messages(self, entity, ids, *, revoke=True):
self.by_id[777] = [m for m in self.by_id.get(777, []) if m.id not in ids]
return True
async def download_media(self, message, target):
return b"mediabytes"
def get_participants(self, entity, *, limit=100):
async def none_():
return []
return none_()
async def __call__(self, request):
kind = type(request).__name__
if kind == "GetFullChatRequest":
return types.SimpleNamespace(full_chat=None, users=self.contacts)
if kind == "GetContactsRequest":
return types.SimpleNamespace(users=self.contacts)
if kind == "SearchGlobalRequest":
request._bytes() # сериализация не должна падать (None-args регресс)
msgs = [m for pool in self.by_id.values() for m in pool]
return types.SimpleNamespace(messages=msgs)
if kind == "GetDhConfigRequest":
from telethon.tl.types import messages as tl_messages
return tl_messages.DhConfig(g=3, p=b"\xfd" * 256, version=1, random=[b"\x00"])
if kind == "RequestCallRequest":
proto = PhoneCallProtocol(min_layer=92, max_layer=127, udp_p2p=True,
udp_reflector=True, library_versions=["x"])
waiting = PhoneCallWaiting(id=555, access_hash=9, date=NOW,
admin_id=42, participant_id=7, protocol=proto)
return types.SimpleNamespace(updates=[UpdatePhoneCall(phone_call=waiting)])
if kind == "DiscardCallRequest":
request._bytes()
return types.SimpleNamespace()
if kind == "ConfirmCallRequest":
return types.SimpleNamespace()
raise AssertionError(f"fake client: unexpected request {kind}")
async def setup(tmp_path, monkeypatch, *, user="mcp"):
monkeypatch.setenv("TGCLIENT_DB_PATH", str(tmp_path / "t.db"))
monkeypatch.setenv("TGCLIENT_API_ID", "1")
monkeypatch.setenv("TGCLIENT_API_HASH", "hash")
monkeypatch.setenv("TGCLIENT_AUTH_CLIENT_ID", "")
get_settings.cache_clear()
await init_db()
conn = get_db()
await conn.execute(
"INSERT OR IGNORE INTO users (user_id, email, created_at, updated_at)"
" VALUES (?, 'test@example.com', ?, ?)", (user, NOW.isoformat(), NOW.isoformat()))
await conn.commit()
return conn
async def insert_account(conn, account_id=1, user="mcp", status="active"):
await conn.execute(
"INSERT OR REPLACE INTO accounts"
" (id, user_id, phone, label, tg_user_id, username, display_name,"
" session_data, status, created_at, updated_at, last_used_at)"
" VALUES (?, ?, ?, '', 42, 'tester', 'Тест', 'sess', ?, ?, ?, ?)",
(account_id, user, "+79991234567", status, NOW.isoformat(), NOW.isoformat(), None))
await conn.commit()
def make_manager(monkeypatch, client):
manager = FakeManager({1: client})
import app.main as appmain
import app.tg.manager as mgr
monkeypatch.setattr(appmain, "get_account_manager", lambda: manager, raising=False)
monkeypatch.setattr(mgr, "report", lambda *a, **k: None)
return manager
def voice_message(mid=10):
doc = Document(id=1, access_hash=2, file_reference=b"x", date=NOW,
mime_type="audio/ogg", size=1234, dc_id=1,
attributes=[DocumentAttributeAudio(duration=5, voice=True, waveform=b"\x82\x82"),
DocumentAttributeFilename("voice.ogg")])
return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message="голосовое",
media=MessageMediaDocument(document=doc), out=True)
def photo_message(mid=11, raw=778):
photo = Photo(id=1, access_hash=2, file_reference=b"", date=NOW,
sizes=[PhotoSize(type="x", w=10, h=10, size=555)], dc_id=1)
return Message(id=mid, peer_id=PeerChat(chat_id=raw), date=NOW, message="",
media=MessageMediaPhoto(photo=photo))
def text_message(mid=12, text="привет"):
return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message=text, out=True)
pytestmark = pytest.mark.asyncio
@pytest.mark.asyncio
async def test_core_tools_smoke(monkeypatch, tmp_path):
conn = await setup(tmp_path, monkeypatch)
await insert_account(conn)
g = Chat(id=777, title="Группа", photo=ChatPhotoEmpty(), participants_count=2,
date=NOW, version=0)
# iter_dialogs отдаёт custom Dialog (не TL) — как в реальном Telethon
raw_dlg = Dialog(peer=PeerChat(chat_id=777), top_message=12, read_inbox_max_id=12,
read_outbox_max_id=0, unread_count=1, unread_mentions_count=0,
unread_reactions_count=0, unread_poll_votes_count=0, draft=None,
notify_settings=None, folder_id=None)
from telethon.tl.custom.dialog import Dialog as CustomDialog
last = text_message(12)
dlg = CustomDialog(client=None, dialog=raw_dlg, entities={-777: g}, message=last)
client = FakeClient(
dialogs=[dlg], by_id={777: [voice_message(10), photo_message(11), text_message(12)]},
contacts=[User(id=9, first_name="Друг")],
entities={777: InputPeerChat(chat_id=777), -777: InputPeerChat(chat_id=777),
42: InputPeerUser(user_id=42, access_hash=1)})
T = get_tools(tools)
make_manager(monkeypatch, client)
out = await T["accounts_list"]()
assert out["accounts"][0]["status"] == "active" and out["accounts"][0]["id"] == 1
me = await T["me_get"](1)
assert me["account_id"] == 1 and me["is_bot"] is False
dl_out = await T["dialogs_list"](1, 10)
d = dl_out["dialogs"][0]
assert d["id"] == -777 and d["unread_count"] == 1 and d["last_message"]["id"] == 12
hist = await T["messages_history"](1, -777, 10, 0, 0)
assert [m["id"] for m in hist["messages"]] == [12, 11, 10]
read = await T["messages_read"](1, -777, 10)
assert read["media"]["voice"] is True and read["media"]["waveform"]
ph = await T["messages_read"](1, -777, 11)
assert ph["media"]["size"] == 555 and "voice" not in ph["media"]
sr = await T["messages_search"](1, -777, "привет", "", 10)
assert [m["id"] for m in sr["messages"]] == [12]
glob = await T["messages_search"](1, 0, "голосовое", "", 10)
assert glob["messages"]
ci = await T["chat_info"](1, -777)
assert ci["id"] == -777 and ci["type"] == "chat"
ct = await T["contacts_list"](1, 10)
assert ct["contacts"][0]["id"] == 9
# не-свой dialog_id → 404 данными
nf = await T["messages_history"](1, -888, 10, 0, 0)
assert nf["error"] == 404
@pytest.mark.asyncio
async def test_mutation_tools_smoke(monkeypatch, tmp_path):
conn = await setup(tmp_path, monkeypatch)
await insert_account(conn)
client = FakeClient(
by_id={777: [voice_message(10), text_message(12)]},
entities={-777: InputPeerChat(chat_id=777), 42: InputPeerUser(user_id=42, access_hash=1)})
T = get_tools(tools)
make_manager(monkeypatch, client)
sent = await T["message_send"](1, -777, "текст", 0, False)
assert sent["text"] == "текст" and sent["out"] is True
rep = await T["message_reply"](1, -777, 12, "ответ")
assert rep["text"] == "ответ"
ed = await T["message_edit"](1, -777, 12, "правка")
assert ed["text"] == "правка"
de = await T["message_delete"](1, -777, [10], True)
assert de == {"status": "ok", "deleted": [10], "revoke": True}
# пустой text / пустые ids → 422 данными
assert (await T["message_send"](1, -777, " ", 0, False))["error"] == 422
assert (await T["message_delete"](1, -777, [], True))["error"] == 422
# чужой dialog → 404 (не найден на этом аккаунте)
nf = await T["message_send"](1, -999, "x", 0, False)
assert nf["error"] == 404
@pytest.mark.asyncio
async def test_media_tools_smoke(monkeypatch, tmp_path):
conn = await setup(tmp_path, monkeypatch)
await insert_account(conn)
client = FakeClient(
by_id={777: [voice_message(10), text_message(12)], 778: [photo_message(11)]},
entities={-777: InputPeerChat(chat_id=777), -778: InputPeerChat(chat_id=778)})
T = get_tools(tools)
make_manager(monkeypatch, client)
dm = await T["download_media"](1, -777, 10, 0)
assert dm["is_voice"] is True and dm["file_name"] == "voice.ogg"
assert dm["data_base64"] == base64.b64encode(b"mediabytes").decode("ascii")
ph_dl = await T["download_media"](1, -778, 11, 0)
assert ph_dl["is_round"] is False and ph_dl["data_base64"]
# файл больше лимита → 413
small = await T["download_media"](1, -778, 11, 8)
assert small["error"] == 413
# сообщение без медиа → 404
nomed = await T["download_media"](1, -777, 12, 0)
assert nomed["error"] == 404
up = await T["upload_file"](1, -777, base64.b64encode(b"doc").decode(), "",
"doc.txt", "подпись", False)
assert "media" in up
voc = await T["upload_voice"](1, -777, base64.b64encode(b"ogg").decode(), "", 5, None)
assert voc["media"]["voice"] is True
rnd = await T["upload_round"](1, -777, base64.b64encode(b"mp4").decode(), "", 10, 512)
assert rnd["media"]["round"] is True
toolong = await T["upload_round"](1, -777, base64.b64encode(b"mp4").decode(), "", 61, 0)
assert toolong["error"] == 422
@pytest.mark.asyncio
async def test_call_tools_smoke(monkeypatch, tmp_path):
conn = await setup(tmp_path, monkeypatch)
await insert_account(conn)
_calls.clear()
client = FakeClient(
entities={42: InputPeerUser(user_id=42, access_hash=1),
-777: InputPeerChat(chat_id=777)})
T = get_tools(tools)
make_manager(monkeypatch, client)
out = await T["call_start"](1, 42, False)
assert out["call_id"] == 555 and out["state"] == "ringing"
st = await T["call_status"](555)
assert st["state"] == "ringing"
disc = await T["call_discard"](555)
assert disc["state"] == "cancelled"
_calls.clear()
# юзер не найден → 404 данными
nf = await T["call_start"](1, 999, False)
assert nf["error"] == 404
# группе звонить нельзя → 422
grp = await T["call_start"](1, -777, False)
assert grp["error"] == 422
# неизвестный статус → 404
unk = await T["call_status"](1)
assert unk["error"] == 404
@pytest.mark.asyncio
async def test_entity_not_found_boundary(monkeypatch, tmp_path):
"""«Диалог не найден» от Telethon (ValueError из кеша) → 404-данные."""
conn = await setup(tmp_path, monkeypatch)
await insert_account(conn)
client = FakeClient(entities={})
T = get_tools(tools)
make_manager(monkeypatch, client)
async def get_input_entity_raises(x):
raise ValueError("Could not find the input entity for PeerChat")
client.get_input_entity = get_input_entity_raises
out = await T["messages_history"](1, -888, 10, 0, 0)
assert out["error"] == 404