surfaces/tests/adapter/matrix/test_dispatcher.py
Mikhail Putilovskij 6ced154124 feat(matrix): land QA follow-ups and refresh docs
- harden Matrix onboarding/chat lifecycle after manual QA
- refresh README and Matrix docs to match current behavior
- add local ignores for runtime artifacts and include current planning/report docs

Closes #7
Closes #9
Closes #14
2026-04-05 19:08:58 +03:00

256 lines
9.1 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
from nio.api import RoomVisibility
from nio.responses import SyncResponse
from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
from sdk.mock import MockPlatformClient
async def test_matrix_dispatcher_registers_custom_handlers():
runtime = build_runtime(platform=MockPlatformClient())
current_chat_id = "C9"
start = IncomingCommand(user_id="u1", platform="matrix", chat_id=current_chat_id, command="start")
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Research"]
)
result = await runtime.dispatcher.dispatch(new)
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C1"]
assert [c.surface_ref for c in chats] == [current_chat_id]
new2 = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Ops"]
)
await runtime.dispatcher.dispatch(new2)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C1", "C2"]
skills = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings_skills"
)
result = await runtime.dispatcher.dispatch(skills)
assert any(isinstance(r, OutgoingMessage) and "!skill on/off" in r.text for r in result)
toggle = IncomingCallback(
user_id="u1",
platform="matrix",
chat_id="C1",
action="toggle_skill",
payload={"skill_index": 2},
)
result = await runtime.dispatcher.dispatch(toggle)
assert any(isinstance(r, OutgoingMessage) and "fetch-url" in r.text for r in result)
async def test_new_chat_creates_real_matrix_room_when_client_available():
client = SimpleNamespace(
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
)
runtime = build_runtime(platform=MockPlatformClient(), client=client)
await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="start")
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
user_id="u1",
platform="matrix",
chat_id="C3",
command="new",
args=["Research"],
)
result = await runtime.dispatcher.dispatch(new)
client.room_create.assert_awaited_once_with(
name="Research",
visibility=RoomVisibility.private,
is_direct=False,
invite=["u1"],
)
client.room_put_state.assert_awaited_once()
put_call = client.room_put_state.call_args
assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C7"]
assert [c.surface_ref for c in chats] == ["!r2:example"]
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
async def test_invite_event_creates_space_and_chat_room():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4})
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
join=AsyncMock(),
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
room_send=AsyncMock(),
)
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,
runtime.chat_mgr,
)
assert client.room_create.await_count == 2
first_call = client.room_create.call_args_list[0]
assert first_call.kwargs.get("space") is True or (
len(first_call.args) > 0 and first_call.kwargs.get("space") is True
)
assert first_call.kwargs.get("visibility") is RoomVisibility.private
assert first_call.kwargs.get("invite") == ["@alice:example.org"]
second_call = client.room_create.call_args_list[1]
assert second_call.kwargs.get("visibility") is RoomVisibility.private
assert second_call.kwargs.get("invite") == ["@alice:example.org"]
client.room_invite.assert_not_awaited()
client.room_put_state.assert_awaited_once()
put_state_call = client.room_put_state.call_args
assert put_state_call.kwargs.get("event_type") == "m.space.child" or put_state_call.args[1] == "m.space.child"
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta is not None
assert user_meta.get("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"] == "C4"
assert room_meta["space_id"] == "!space:example.org"
assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
assert user_meta.get("next_chat_index") == 5
client.room_send.assert_awaited_once()
async def test_invite_event_is_idempotent_per_user():
runtime = build_runtime(platform=MockPlatformClient())
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
join=AsyncMock(),
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
room_send=AsyncMock(),
)
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,
runtime.chat_mgr,
)
await handle_invite(
client,
room,
event,
runtime.platform,
runtime.store,
runtime.auth_mgr,
runtime.chat_mgr,
)
assert client.room_create.await_count == 2
async def test_bot_ignores_its_own_messages():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock()
room = SimpleNamespace(room_id="!dm:example.org")
event = SimpleNamespace(sender="@bot:example.org", body="hello")
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
bot._send_all.assert_not_awaited()
async def test_mat11_settings_returns_dashboard():
runtime = build_runtime(platform=MockPlatformClient())
current_chat_id = "C9"
start = IncomingCommand(user_id="u1", platform="matrix", chat_id=current_chat_id, command="start")
await runtime.dispatcher.dispatch(start)
settings_cmd = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, 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
assert "Безопасность" in text
assert "Активные чаты" in text
assert "Изменить" not in text
assert "!connectors" not in text
assert "!whoami" not in text
async def test_mat12_help_returns_command_reference():
runtime = build_runtime(platform=MockPlatformClient())
result = await runtime.dispatcher.dispatch(
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="help")
)
assert len(result) == 1
text = result[0].text
assert "!new" in text
assert "!rename" in text
assert "!archive" in text
assert "!settings" in text
assert "!yes" in text
async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync():
client = SimpleNamespace(
sync=AsyncMock(
return_value=SyncResponse(
next_batch="s123",
rooms={},
device_key_count={},
device_list=SimpleNamespace(changed=[], left=[]),
to_device_events=[],
presence_events=[],
account_data_events=[],
)
)
)
since = await prepare_live_sync(client)
client.sync.assert_awaited_once_with(timeout=0, full_state=True)
assert since == "s123"