fix max-bot, add tests
This commit is contained in:
parent
7abbaf7e7a
commit
2ad1438e1c
17 changed files with 1621 additions and 494 deletions
1
tests/adapter/max/__init__.py
Normal file
1
tests/adapter/max/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# MAX adapter tests
|
||||
88
tests/adapter/max/test_agent_registry.py
Normal file
88
tests/adapter/max/test_agent_registry.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter.max.agent_registry import AgentRegistryError, load_agent_registry
|
||||
|
||||
|
||||
def test_load_agent_registry_reads_yaml(tmp_path: Path):
|
||||
path = tmp_path / "max.yaml"
|
||||
path.write_text(
|
||||
"agents:\n"
|
||||
" - id: agent-1\n"
|
||||
" label: One\n"
|
||||
" base_url: http://localhost:8000/a1/\n"
|
||||
" workspace_path: /agents/1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
reg = load_agent_registry(path)
|
||||
assert [a.agent_id for a in reg.agents] == ["agent-1"]
|
||||
a = reg.get("agent-1")
|
||||
assert a.label == "One"
|
||||
assert a.base_url == "http://localhost:8000/a1/"
|
||||
assert a.workspace_path == "/agents/1"
|
||||
|
||||
|
||||
def test_user_agents_resolve(tmp_path: Path):
|
||||
path = tmp_path / "max.yaml"
|
||||
path.write_text(
|
||||
"user_agents:\n"
|
||||
' "42": agent-1\n'
|
||||
"agents:\n"
|
||||
" - id: agent-1\n"
|
||||
" label: One\n"
|
||||
" - id: agent-2\n"
|
||||
" label: Two\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
reg = load_agent_registry(path)
|
||||
assert reg.resolve_agent_for_user("42").agent_id == "agent-1"
|
||||
assert reg.resolve_agent_for_user("42").source == "configured"
|
||||
assert reg.resolve_agent_for_user("999").agent_id == "agent-1"
|
||||
assert reg.resolve_agent_for_user("999").source == "default"
|
||||
|
||||
|
||||
def test_duplicate_ids_rejected(tmp_path: Path):
|
||||
path = tmp_path / "max.yaml"
|
||||
path.write_text(
|
||||
"agents:\n"
|
||||
" - id: a\n"
|
||||
" label: A\n"
|
||||
" - id: a\n"
|
||||
" label: B\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(AgentRegistryError, match="duplicate agent id"):
|
||||
load_agent_registry(path)
|
||||
|
||||
|
||||
def test_empty_agents_rejected(tmp_path: Path):
|
||||
path = tmp_path / "max.yaml"
|
||||
path.write_text("agents: []\n", encoding="utf-8")
|
||||
with pytest.raises(AgentRegistryError, match="non-empty"):
|
||||
load_agent_registry(path)
|
||||
|
||||
|
||||
def test_user_agents_must_be_strings(tmp_path: Path):
|
||||
path = tmp_path / "max.yaml"
|
||||
path.write_text(
|
||||
"user_agents:\n"
|
||||
" 42: agent-1\n"
|
||||
"agents:\n"
|
||||
" - id: agent-1\n"
|
||||
" label: One\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(AgentRegistryError, match="user_agents"):
|
||||
load_agent_registry(path)
|
||||
|
||||
|
||||
def test_unknown_agent_raises(tmp_path: Path):
|
||||
path = tmp_path / "max.yaml"
|
||||
path.write_text(
|
||||
"agents:\n - id: a\n label: A\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
reg = load_agent_registry(path)
|
||||
with pytest.raises(AgentRegistryError, match="unknown agent id"):
|
||||
reg.get("missing")
|
||||
90
tests/adapter/max/test_api_client.py
Normal file
90
tests/adapter/max/test_api_client.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from adapter.max.api_client import MaxApiError, MaxBotApi
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_updates_returns_marker_and_updates():
|
||||
api = MaxBotApi("token-x", base_url="http://max.test")
|
||||
try:
|
||||
api._client.request = AsyncMock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"updates": [{"update_type": "message_created", "timestamp": 1}],
|
||||
"marker": 7,
|
||||
},
|
||||
)
|
||||
)
|
||||
updates, marker = await api.get_updates(types=["message_created"])
|
||||
assert len(updates) == 1
|
||||
assert updates[0]["update_type"] == "message_created"
|
||||
assert marker == 7
|
||||
|
||||
_, kwargs = api._client.request.call_args
|
||||
assert kwargs["params"]["types"] == "message_created"
|
||||
finally:
|
||||
await api.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_updates_non_dict_body():
|
||||
api = MaxBotApi("token-x", base_url="http://max.test")
|
||||
try:
|
||||
api._client.request = AsyncMock(return_value=httpx.Response(200, text="oops"))
|
||||
updates, marker = await api.get_updates()
|
||||
assert updates == []
|
||||
assert marker is None
|
||||
finally:
|
||||
await api.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_error_raises_max_api_error():
|
||||
api = MaxBotApi("token-x", base_url="http://max.test")
|
||||
try:
|
||||
api._client.request = AsyncMock(
|
||||
return_value=httpx.Response(401, json={"code": "verify.token", "message": "bad"})
|
||||
)
|
||||
with pytest.raises(MaxApiError) as ei:
|
||||
await api.get_me()
|
||||
assert ei.value.status == 401
|
||||
assert "bad" in str(ei.value).lower() or ei.value.payload
|
||||
finally:
|
||||
await api.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_to_chat_posts_json_body():
|
||||
api = MaxBotApi("token-x", base_url="http://max.test")
|
||||
try:
|
||||
api._client.request = AsyncMock(return_value=httpx.Response(200, json={"message": {}}))
|
||||
|
||||
await api.send_message_to_chat(12345, text="hi", attachments=None, fmt=None)
|
||||
|
||||
args, kw = api._client.request.call_args
|
||||
assert args[0] == "POST"
|
||||
assert args[1] == "/messages"
|
||||
assert kw["params"]["chat_id"] == 12345
|
||||
assert kw["json"] == {"text": "hi"}
|
||||
finally:
|
||||
await api.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_uses_get():
|
||||
api = MaxBotApi("token-x", base_url="http://max.test")
|
||||
try:
|
||||
api._client.get = AsyncMock(return_value=httpx.Response(200, content=b"\xff\xd8"))
|
||||
|
||||
buf = await api.download_file("https://files.example/bin")
|
||||
|
||||
assert buf == b"\xff\xd8"
|
||||
api._client.get.assert_awaited_once()
|
||||
finally:
|
||||
await api.aclose()
|
||||
154
tests/adapter/max/test_converter.py
Normal file
154
tests/adapter/max/test_converter.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter.max.converter import (
|
||||
attachment_from_max_dict,
|
||||
collect_max_attachments,
|
||||
incoming_from_message_callback_payload,
|
||||
incoming_from_text_commands,
|
||||
)
|
||||
from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expect_type",
|
||||
[
|
||||
("Hello", IncomingMessage),
|
||||
(" plain ", IncomingMessage),
|
||||
],
|
||||
)
|
||||
def test_plain_text_to_message(text, expect_type):
|
||||
r = incoming_from_text_commands(
|
||||
text=text,
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc-1",
|
||||
attachments=[],
|
||||
)
|
||||
assert type(r) is expect_type
|
||||
assert r.text == text
|
||||
|
||||
|
||||
def test_slash_command_split():
|
||||
r = incoming_from_text_commands(
|
||||
text="/new title here",
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc-1",
|
||||
attachments=[],
|
||||
)
|
||||
assert isinstance(r, IncomingCommand)
|
||||
assert r.command == "new"
|
||||
assert r.args == ["title", "here"]
|
||||
|
||||
|
||||
def test_slash_command_no_args():
|
||||
r = incoming_from_text_commands(
|
||||
text="/help",
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc-1",
|
||||
attachments=[],
|
||||
)
|
||||
assert isinstance(r, IncomingCommand)
|
||||
assert r.command == "help"
|
||||
assert r.args == []
|
||||
|
||||
|
||||
def test_bang_prefix_is_plain_message_not_command():
|
||||
"""MAX: только / считается командой."""
|
||||
r = incoming_from_text_commands(
|
||||
text="!help",
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc-1",
|
||||
attachments=[],
|
||||
)
|
||||
assert isinstance(r, IncomingMessage)
|
||||
assert r.text == "!help"
|
||||
|
||||
|
||||
def test_yes_no_callbacks():
|
||||
yes = incoming_from_text_commands(
|
||||
text="/yes",
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc-1",
|
||||
attachments=[],
|
||||
)
|
||||
assert isinstance(yes, IncomingCallback)
|
||||
assert yes.action == "confirm"
|
||||
|
||||
no = incoming_from_text_commands(
|
||||
text="/NO",
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc-1",
|
||||
attachments=[],
|
||||
)
|
||||
assert isinstance(no, IncomingCallback)
|
||||
assert no.action == "cancel"
|
||||
|
||||
|
||||
def test_incoming_message_keeps_attachments():
|
||||
at = [Attachment(type="document", filename="a.txt")]
|
||||
r = incoming_from_text_commands(
|
||||
text="see file",
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc-1",
|
||||
attachments=at,
|
||||
)
|
||||
assert isinstance(r, IncomingMessage)
|
||||
assert r.attachments == at
|
||||
|
||||
|
||||
def test_message_callback_known_actions():
|
||||
c = incoming_from_message_callback_payload(
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc",
|
||||
payload_raw="confirm",
|
||||
callback_message_id="mid",
|
||||
)
|
||||
assert c is not None
|
||||
assert isinstance(c, IncomingCallback)
|
||||
assert c.action == "confirm"
|
||||
assert c.payload.get("message_id") == "mid"
|
||||
|
||||
|
||||
def test_message_callback_unknown_becomes_max_callback():
|
||||
c = incoming_from_message_callback_payload(
|
||||
max_user_id="10",
|
||||
platform_chat_id="pc",
|
||||
payload_raw="my_payload",
|
||||
callback_message_id=None,
|
||||
)
|
||||
assert c is not None
|
||||
assert c.action == "max_callback"
|
||||
assert c.payload["payload"] == "my_payload"
|
||||
|
||||
|
||||
def test_attachment_from_max_file():
|
||||
parsed = attachment_from_max_dict(
|
||||
{
|
||||
"type": "file",
|
||||
"payload": {
|
||||
"url": "https://cdn.example/f",
|
||||
"filename": "doc.pdf",
|
||||
"token": "tok",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert parsed is not None
|
||||
att, raw = parsed
|
||||
assert att.filename == "doc.pdf"
|
||||
assert att.type == "document"
|
||||
assert att.url == "https://cdn.example/f"
|
||||
assert raw.get("_download_token_hint") == "tok"
|
||||
|
||||
|
||||
def test_collect_max_attachments_skips_unknown():
|
||||
core, raw = collect_max_attachments(
|
||||
{
|
||||
"attachments": [
|
||||
{"type": "file", "payload": {"url": "u", "filename": "x.bin"}},
|
||||
{"type": "sticker", "payload": {}},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(core) == len(raw) == 1
|
||||
assert core[0].filename == "x.bin"
|
||||
98
tests/adapter/max/test_dispatcher_max.py
Normal file
98
tests/adapter/max/test_dispatcher_max.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter.max.handlers.chat import ChatHandler as MaxChatHandler
|
||||
from adapter.max.handlers.commands import register_max_handlers
|
||||
from adapter.max.store import ChatStore, RoomMeta
|
||||
from core.auth import AuthManager
|
||||
from core.chat import ChatManager
|
||||
from core.handler import EventDispatcher
|
||||
from core.handlers import register_all
|
||||
from core.protocol import IncomingCommand, OutgoingMessage
|
||||
from core.settings import SettingsManager
|
||||
from core.store import InMemoryStore
|
||||
from sdk.mock import MockPlatformClient
|
||||
from sdk.prototype_state import PrototypeStateStore
|
||||
|
||||
|
||||
def _build_dispatcher() -> tuple[
|
||||
EventDispatcher,
|
||||
ChatManager,
|
||||
AuthManager,
|
||||
ChatStore,
|
||||
str,
|
||||
]:
|
||||
store_mem = InMemoryStore()
|
||||
chat_store = ChatStore()
|
||||
chat_handler = MaxChatHandler(chat_store)
|
||||
prototype_state = PrototypeStateStore()
|
||||
platform = MockPlatformClient()
|
||||
chat_mgr = ChatManager(platform, store_mem)
|
||||
auth_mgr = AuthManager(platform, store_mem)
|
||||
settings_mgr = SettingsManager(platform, store_mem)
|
||||
dispatcher = EventDispatcher(
|
||||
platform=platform,
|
||||
chat_mgr=chat_mgr,
|
||||
auth_mgr=auth_mgr,
|
||||
settings_mgr=settings_mgr,
|
||||
)
|
||||
register_all(dispatcher)
|
||||
register_max_handlers(
|
||||
dispatcher,
|
||||
chat_store=chat_store,
|
||||
max_chat_handler=chat_handler,
|
||||
prototype_state=prototype_state,
|
||||
)
|
||||
|
||||
pid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
chat_store.add_room(
|
||||
RoomMeta(
|
||||
platform_chat_id=pid,
|
||||
max_chat_id="777",
|
||||
name="Чат",
|
||||
user_id="u1",
|
||||
agent_id="agent-0",
|
||||
workspace_path="/agents/0",
|
||||
)
|
||||
)
|
||||
return dispatcher, chat_mgr, auth_mgr, chat_store, pid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_new_is_single_dialog_hint():
|
||||
dispatcher, _chat_mgr, auth_mgr, _chat_store, pid = _build_dispatcher()
|
||||
await auth_mgr.confirm("u1")
|
||||
|
||||
out = await dispatcher.dispatch(
|
||||
IncomingCommand(user_id="u1", platform="max", chat_id=pid, command="new"),
|
||||
)
|
||||
assert len(out) == 1
|
||||
assert isinstance(out[0], OutgoingMessage)
|
||||
assert "один диалог" in out[0].text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_clear_rotates_platform_chat():
|
||||
dispatcher, chat_mgr, auth_mgr, chat_store, pid = _build_dispatcher()
|
||||
await auth_mgr.confirm("u1")
|
||||
await chat_mgr.get_or_create(
|
||||
user_id="u1",
|
||||
chat_id=pid,
|
||||
platform="max",
|
||||
surface_ref="777",
|
||||
name="Чат",
|
||||
)
|
||||
|
||||
out = await dispatcher.dispatch(
|
||||
IncomingCommand(user_id="u1", platform="max", chat_id=pid, command="clear"),
|
||||
)
|
||||
assert len(out) == 1
|
||||
msg = out[0]
|
||||
assert isinstance(msg, OutgoingMessage)
|
||||
assert msg.chat_id != pid
|
||||
assert "сброшен" in msg.text.lower()
|
||||
|
||||
room = chat_store.get_room_by_max_chat_id("777")
|
||||
assert room is not None
|
||||
assert room.platform_chat_id == msg.chat_id
|
||||
78
tests/adapter/max/test_store.py
Normal file
78
tests/adapter/max/test_store.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from adapter.max.handlers.attachments import AttachmentHandler
|
||||
from adapter.max.handlers.chat import ChatHandler as MaxChatHandler
|
||||
from adapter.max.store import ChatStore, RoomMeta
|
||||
|
||||
|
||||
def test_chat_store_room_roundtrip():
|
||||
store = ChatStore()
|
||||
r = RoomMeta(
|
||||
platform_chat_id="pid-1",
|
||||
max_chat_id="100",
|
||||
name="Main",
|
||||
user_id="42",
|
||||
agent_id="agent-0",
|
||||
workspace_path="/agents/0",
|
||||
)
|
||||
store.add_room(r)
|
||||
assert store.get_room_by_max_chat_id("100") is r
|
||||
assert store.get_room_by_platform_chat_id("pid-1") is r
|
||||
|
||||
|
||||
def test_staged_attachments():
|
||||
store = ChatStore()
|
||||
store.stage_attachment("100", ("rel/path.txt", "path.txt"))
|
||||
assert store.get_attachments("100")
|
||||
popped = store.pop_attachments("100")
|
||||
assert len(popped) == 1
|
||||
assert store.pop_attachments("100") == []
|
||||
|
||||
|
||||
def test_remove_room_clears_staging():
|
||||
store = ChatStore()
|
||||
store.stage_attachment("100", ("a", "a"))
|
||||
store.add_room(
|
||||
RoomMeta(
|
||||
platform_chat_id="x",
|
||||
max_chat_id="100",
|
||||
name="",
|
||||
user_id="u",
|
||||
agent_id="a",
|
||||
)
|
||||
)
|
||||
store.remove_room("100")
|
||||
assert store.get_room_by_max_chat_id("100") is None
|
||||
assert store.get_attachments("100") == []
|
||||
|
||||
|
||||
def test_chat_handler_clear_rotates_platform_id():
|
||||
store = ChatStore()
|
||||
h = MaxChatHandler(store)
|
||||
pid1 = str(uuid.uuid4())
|
||||
store.add_room(
|
||||
RoomMeta(
|
||||
platform_chat_id=pid1,
|
||||
max_chat_id="100",
|
||||
name="Tab",
|
||||
user_id="42",
|
||||
agent_id="agent-0",
|
||||
workspace_path="/agents/0",
|
||||
)
|
||||
)
|
||||
h.handle_clear("100")
|
||||
room = store.get_room_by_max_chat_id("100")
|
||||
assert room is not None
|
||||
assert room.platform_chat_id != pid1
|
||||
|
||||
|
||||
def test_attachment_handler_list_remove():
|
||||
store = ChatStore()
|
||||
h = AttachmentHandler(store)
|
||||
store.stage_attachment("100", ("a", "f1.bin"))
|
||||
assert "f1.bin" in h.handle_list("100")
|
||||
msg = h.handle_remove("100", "1")
|
||||
assert "Удалено" in msg or "удалено" in msg.lower()
|
||||
assert "пуста" in h.handle_list("100").lower() or "пусто" in h.handle_list("100").lower()
|
||||
Loading…
Add table
Add a link
Reference in a new issue