From 97a3dc35ea3965a392958f7c615b9dce97396523 Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Thu, 2 Apr 2026 23:03:17 +0300 Subject: [PATCH] test(01-04): add matrix space regression coverage - add MAT-01..MAT-07 and MAT-09..MAT-12 regression tests for matrix adapter - extend store and dispatcher coverage for pending confirmations and settings dashboard - verify matrix adapter suite and full pytest suite stay green --- tests/adapter/matrix/test_chat_space.py | 125 +++++++++++++++++++++ tests/adapter/matrix/test_confirm.py | 96 ++++++++++++++++ tests/adapter/matrix/test_dispatcher.py | 17 +++ tests/adapter/matrix/test_invite_space.py | 78 +++++++++++++ tests/adapter/matrix/test_send_outgoing.py | 52 +++++++++ tests/adapter/matrix/test_store.py | 14 +++ 6 files changed, 382 insertions(+) create mode 100644 tests/adapter/matrix/test_chat_space.py create mode 100644 tests/adapter/matrix/test_confirm.py create mode 100644 tests/adapter/matrix/test_invite_space.py create mode 100644 tests/adapter/matrix/test_send_outgoing.py diff --git a/tests/adapter/matrix/test_chat_space.py b/tests/adapter/matrix/test_chat_space.py new file mode 100644 index 0000000..f3a23f5 --- /dev/null +++ b/tests/adapter/matrix/test_chat_space.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from nio.responses import RoomCreateError + +from adapter.matrix.handlers.chat import make_handle_archive, make_handle_new_chat +from adapter.matrix.store import set_user_meta +from core.auth import AuthManager +from core.chat import ChatManager +from core.protocol import IncomingCommand, OutgoingMessage +from core.settings import SettingsManager +from core.store import InMemoryStore +from sdk.mock import MockPlatformClient + + +async def _setup(): + platform = MockPlatformClient() + store = InMemoryStore() + chat_mgr = ChatManager(platform, store) + auth_mgr = AuthManager(platform, store) + settings_mgr = SettingsManager(platform, store) + await auth_mgr.confirm("@alice:example.org") + return platform, store, chat_mgr, auth_mgr, settings_mgr + + +async def test_mat04_new_chat_calls_room_put_state_with_space_id(): + platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() + await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}) + + client = SimpleNamespace( + room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")), + room_put_state=AsyncMock(), + room_invite=AsyncMock(), + ) + handler = make_handle_new_chat(client, store) + event = IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="new", + args=["Test"], + ) + result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) + + client.room_put_state.assert_awaited_once() + kwargs = client.room_put_state.call_args.kwargs + assert kwargs.get("room_id") == "!space:ex" + assert kwargs.get("event_type") == "m.space.child" + assert kwargs.get("state_key") == "!newroom:ex" + assert any(isinstance(item, OutgoingMessage) and "Test" in item.text for item in result) + + +async def test_mat05_new_chat_without_space_id_returns_error(): + platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() + await set_user_meta(store, "@alice:example.org", {"next_chat_index": 1}) + + client = SimpleNamespace( + room_create=AsyncMock(), + room_put_state=AsyncMock(), + room_invite=AsyncMock(), + ) + handler = make_handle_new_chat(client, store) + event = IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="new", + ) + result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) + + assert len(result) == 1 + assert isinstance(result[0], OutgoingMessage) + assert "Space" in result[0].text or "ошибка" in result[0].text.lower() + client.room_create.assert_not_awaited() + + +async def test_mat10_archive_calls_chat_mgr_archive(): + platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() + + handler = make_handle_archive(None, store) + event = IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="archive", + ) + await chat_mgr.get_or_create( + user_id="@alice:example.org", + chat_id="C1", + platform="matrix", + surface_ref="!room:ex", + name="Test", + ) + + result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) + + assert len(result) == 1 + assert "архивирован" in result[0].text + + +async def test_mat12_room_create_error_returns_user_message(): + platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() + await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}) + + client = SimpleNamespace( + room_create=AsyncMock(return_value=RoomCreateError(message="rate limited", status_code="429")), + room_put_state=AsyncMock(), + room_invite=AsyncMock(), + ) + handler = make_handle_new_chat(client, store) + event = IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="new", + args=["Fail"], + ) + result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) + + assert len(result) == 1 + assert isinstance(result[0], OutgoingMessage) + assert "Не удалось" in result[0].text or "не удалось" in result[0].text + client.room_put_state.assert_not_awaited() diff --git a/tests/adapter/matrix/test_confirm.py b/tests/adapter/matrix/test_confirm.py new file mode 100644 index 0000000..219f5fe --- /dev/null +++ b/tests/adapter/matrix/test_confirm.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm +from adapter.matrix.store import get_pending_confirm, set_pending_confirm +from core.auth import AuthManager +from core.chat import ChatManager +from core.protocol import IncomingCallback, OutgoingMessage +from core.settings import SettingsManager +from core.store import InMemoryStore +from sdk.mock import MockPlatformClient + + +async def test_mat09_yes_reads_pending_confirm(): + store = InMemoryStore() + platform = MockPlatformClient() + chat_mgr = ChatManager(platform, store) + auth_mgr = AuthManager(platform, store) + settings_mgr = SettingsManager(platform, store) + + await set_pending_confirm( + store, + "C1", + { + "action_id": "delete_file", + "description": "Удалить файл config.yaml", + "payload": {}, + }, + ) + + handler = make_handle_confirm(store) + event = IncomingCallback( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + action="confirm", + payload={"source": "command", "command": "yes"}, + ) + result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) + + assert len(result) == 1 + assert isinstance(result[0], OutgoingMessage) + assert "Удалить файл config.yaml" in result[0].text + assert await get_pending_confirm(store, "C1") is None + + +async def test_no_clears_pending_confirm(): + store = InMemoryStore() + platform = MockPlatformClient() + chat_mgr = ChatManager(platform, store) + auth_mgr = AuthManager(platform, store) + settings_mgr = SettingsManager(platform, store) + + await set_pending_confirm( + store, + "C1", + { + "action_id": "delete_file", + "description": "Удалить файл", + "payload": {}, + }, + ) + + handler = make_handle_cancel(store) + event = IncomingCallback( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + action="cancel", + payload={"source": "command", "command": "no"}, + ) + result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) + + assert len(result) == 1 + assert "отменено" in result[0].text.lower() + assert await get_pending_confirm(store, "C1") is None + + +async def test_yes_without_pending_returns_no_pending(): + store = InMemoryStore() + platform = MockPlatformClient() + chat_mgr = ChatManager(platform, store) + auth_mgr = AuthManager(platform, store) + settings_mgr = SettingsManager(platform, store) + + handler = make_handle_confirm(store) + event = IncomingCallback( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + action="confirm", + payload={}, + ) + result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) + + assert len(result) == 1 + assert "Нет ожидающих" in result[0].text diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py index b08e5bb..b302f66 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -151,3 +151,20 @@ async def test_bot_ignores_its_own_messages(): runtime.dispatcher.dispatch.assert_not_awaited() bot._send_all.assert_not_awaited() + + +async def test_mat11_settings_returns_dashboard(): + runtime = build_runtime(platform=MockPlatformClient()) + + start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") + await runtime.dispatcher.dispatch(start) + + settings_cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="settings") + result = await runtime.dispatcher.dispatch(settings_cmd) + + assert len(result) >= 1 + text = result[0].text + assert "Скиллы" in text or "скиллы" in text.lower() + assert "Изменить" in text or "!skills" in text + assert "!connectors" not in text + assert "!whoami" not in text diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py new file mode 100644 index 0000000..5dbf289 --- /dev/null +++ b/tests/adapter/matrix/test_invite_space.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from adapter.matrix.bot import build_runtime +from adapter.matrix.handlers.auth import handle_invite +from adapter.matrix.store import get_room_meta, get_user_meta +from sdk.mock import MockPlatformClient + + +def _make_client(): + space_resp = SimpleNamespace(room_id="!space:example.org") + chat_resp = SimpleNamespace(room_id="!chat1:example.org") + return SimpleNamespace( + join=AsyncMock(), + room_create=AsyncMock(side_effect=[space_resp, chat_resp]), + room_put_state=AsyncMock(), + room_invite=AsyncMock(), + room_send=AsyncMock(), + ) + + +async def test_mat01_invite_creates_space_and_chat1(): + runtime = build_runtime(platform=MockPlatformClient()) + client = _make_client() + room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") + event = SimpleNamespace(sender="@alice:example.org", membership="invite") + + await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) + + first_call = client.room_create.call_args_list[0] + assert first_call.kwargs.get("space") is True + assert client.room_create.await_count == 2 + + client.room_put_state.assert_awaited_once() + kwargs = client.room_put_state.call_args.kwargs + assert kwargs.get("event_type") == "m.space.child" + assert kwargs.get("state_key") == "!chat1:example.org" + assert kwargs.get("room_id") == "!space:example.org" + + user_meta = await get_user_meta(runtime.store, "@alice:example.org") + assert user_meta is not None + assert user_meta["space_id"] == "!space:example.org" + + room_meta = await get_room_meta(runtime.store, "!chat1:example.org") + assert room_meta is not None + assert room_meta["chat_id"] == "C1" + assert room_meta["space_id"] == "!space:example.org" + + +async def test_mat02_invite_idempotent(): + runtime = build_runtime(platform=MockPlatformClient()) + client = _make_client() + room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") + event = SimpleNamespace(sender="@alice:example.org", membership="invite") + + await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) + await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) + + assert client.room_create.await_count == 2 + + +async def test_mat03_no_hardcoded_c1(): + runtime = build_runtime(platform=MockPlatformClient()) + client = _make_client() + room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") + event = SimpleNamespace(sender="@alice:example.org", membership="invite") + + await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) + + room_meta = await get_room_meta(runtime.store, "!chat1:example.org") + assert room_meta is not None + assert room_meta["chat_id"] == "C1" + + user_meta = await get_user_meta(runtime.store, "@alice:example.org") + assert user_meta is not None + assert user_meta["next_chat_index"] == 2 diff --git a/tests/adapter/matrix/test_send_outgoing.py b/tests/adapter/matrix/test_send_outgoing.py new file mode 100644 index 0000000..e0f3963 --- /dev/null +++ b/tests/adapter/matrix/test_send_outgoing.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from adapter.matrix.bot import send_outgoing +from adapter.matrix.store import get_pending_confirm +from core.protocol import OutgoingUI, UIButton +from core.store import InMemoryStore + + +async def test_mat06_outgoing_ui_renders_text_with_yes_no(): + client = SimpleNamespace(room_send=AsyncMock()) + store = InMemoryStore() + event = OutgoingUI( + chat_id="C1", + text="Удалить файл?", + buttons=[UIButton(label="Подтвердить", action="confirm")], + ) + + await send_outgoing(client, "!room:ex", event, store=store) + + client.room_send.assert_awaited_once() + body = client.room_send.call_args.args[2]["body"] + assert "Удалить файл?" in body + assert "!yes" in body + assert "!no" in body + assert "Подтвердить" in body + + +async def test_mat07_outgoing_ui_no_reaction_sent(): + client = SimpleNamespace(room_send=AsyncMock()) + store = InMemoryStore() + event = OutgoingUI( + chat_id="C1", + text="Confirm action?", + buttons=[UIButton(label="OK", action="confirm", payload={"id": 1})], + ) + + await send_outgoing(client, "!room:ex", event, store=store) + + assert client.room_send.await_count == 1 + assert client.room_send.call_args.args[1] == "m.room.message" + for call in client.room_send.call_args_list: + assert call.args[1] != "m.reaction" + + pending = await get_pending_confirm(store, "!room:ex") + assert pending == { + "action_id": "confirm", + "description": "Confirm action?", + "payload": {"id": 1}, + } diff --git a/tests/adapter/matrix/test_store.py b/tests/adapter/matrix/test_store.py index 034bbd2..35f8131 100644 --- a/tests/adapter/matrix/test_store.py +++ b/tests/adapter/matrix/test_store.py @@ -3,11 +3,14 @@ from __future__ import annotations import pytest from adapter.matrix.store import ( + clear_pending_confirm, + get_pending_confirm, get_room_meta, get_room_state, get_skills_message_id, get_user_meta, next_chat_id, + set_pending_confirm, set_room_meta, set_room_state, set_skills_message_id, @@ -70,3 +73,14 @@ async def test_next_chat_id_increments(store: InMemoryStore): async def test_skills_message_roundtrip(store: InMemoryStore): await set_skills_message_id(store, "!room", "$event") assert await get_skills_message_id(store, "!room") == "$event" + + +async def test_pending_confirm_roundtrip(store: InMemoryStore): + assert await get_pending_confirm(store, "!room:m.org") is None + + meta = {"action_id": "test", "description": "Do thing"} + await set_pending_confirm(store, "!room:m.org", meta) + assert await get_pending_confirm(store, "!room:m.org") == meta + + await clear_pending_confirm(store, "!room:m.org") + assert await get_pending_confirm(store, "!room:m.org") is None