From 7842353500fd02f2ce6df8a10d9fba9bfa3af271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Bompard?= Date: Mon, 6 Nov 2023 15:32:52 +0100 Subject: [PATCH] Add a testing framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This changeset contains a set of Pytest fixtures and a mocked bot class to ease the writing of Maubot plugin unit tests. Signed-off-by: Aurélien Bompard --- maubot/testing/__init__.py | 17 +++++ maubot/testing/bot.py | 99 +++++++++++++++++++++++++++ maubot/testing/fixtures.py | 135 +++++++++++++++++++++++++++++++++++++ optional-requirements.txt | 4 ++ setup.py | 2 + 5 files changed, 257 insertions(+) create mode 100644 maubot/testing/__init__.py create mode 100644 maubot/testing/bot.py create mode 100644 maubot/testing/fixtures.py diff --git a/maubot/testing/__init__.py b/maubot/testing/__init__.py new file mode 100644 index 0000000..1fcdfc0 --- /dev/null +++ b/maubot/testing/__init__.py @@ -0,0 +1,17 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2023 Aurélien Bompard +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from .bot import TestBot, make_message # noqa: F401 +from .fixtures import * # noqa: F401,F403 diff --git a/maubot/testing/bot.py b/maubot/testing/bot.py new file mode 100644 index 0000000..d07ef2b --- /dev/null +++ b/maubot/testing/bot.py @@ -0,0 +1,99 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2023 Aurélien Bompard +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from dataclasses import dataclass +import asyncio +import time + +from maubot.matrix import MaubotMatrixClient, MaubotMessageEvent +from mautrix.api import HTTPAPI +from mautrix.types import ( + EventContent, + EventType, + MessageEvent, + MessageType, + RoomID, + TextMessageEventContent, +) + + +@dataclass +class MatrixEvent: + room_id: RoomID + event_type: EventType + content: EventContent + kwargs: dict + + +class TestBot: + """A mocked bot used for testing purposes. + + Send messages to the mock Matrix server with the ``send()`` method. + Look into the ``responded`` list to get what server has replied. + """ + + def __init__(self, mxid="@botname:example.com", mxurl="http://matrix.example.com"): + api = HTTPAPI(base_url=mxurl) + self.client = MaubotMatrixClient(api=api) + self.responded = [] + self.client.mxid = mxid + self.client.send_message_event = self._mock_send_message_event + + async def _mock_send_message_event(self, room_id, event_type, content, txn_id=None, **kwargs): + self.responded.append( + MatrixEvent(room_id=room_id, event_type=event_type, content=content, kwargs=kwargs) + ) + + async def dispatch(self, event_type: EventType, event): + tasks = self.client.dispatch_manual_event(event_type, event, force_synchronous=True) + return await asyncio.gather(*tasks) + + async def send( + self, + content, + html=None, + room_id="testroom", + msg_type=MessageType.TEXT, + sender="@dummy:example.com", + timestamp=None, + ): + event = make_message( + content, + html=html, + room_id=room_id, + msg_type=msg_type, + sender=sender, + timestamp=timestamp, + ) + await self.dispatch(EventType.ROOM_MESSAGE, MaubotMessageEvent(event, self.client)) + + +def make_message( + content, + html=None, + room_id="testroom", + msg_type=MessageType.TEXT, + sender="@dummy:example.com", + timestamp=None, +): + """Make a Matrix message event.""" + return MessageEvent( + type=EventType.ROOM_MESSAGE, + room_id=room_id, + event_id="test", + sender=sender, + timestamp=timestamp or int(time.time()), + content=TextMessageEventContent(msgtype=msg_type, body=content, formatted_body=html), + ) diff --git a/maubot/testing/fixtures.py b/maubot/testing/fixtures.py new file mode 100644 index 0000000..13b74e2 --- /dev/null +++ b/maubot/testing/fixtures.py @@ -0,0 +1,135 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2023 Aurélien Bompard +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from pathlib import Path +import asyncio +import logging + +from ruamel.yaml import YAML +import aiohttp +import pytest +import pytest_asyncio + +from maubot import Plugin +from maubot.loader import PluginMeta +from maubot.standalone.loader import FileSystemLoader +from mautrix.util.async_db import Database +from mautrix.util.config import BaseProxyConfig, RecursiveDict +from mautrix.util.logging import TraceLogger + +from .bot import TestBot + + +@pytest_asyncio.fixture +async def maubot_test_bot(): + return TestBot() + + +@pytest.fixture +def maubot_upgrade_table(): + return None + + +@pytest.fixture +def maubot_plugin_path(): + return Path(".") + + +@pytest.fixture +def maubot_plugin_meta(maubot_plugin_path): + yaml = YAML() + with open(maubot_plugin_path.joinpath("maubot.yaml")) as fh: + plugin_meta = PluginMeta.deserialize(yaml.load(fh.read())) + return plugin_meta + + +@pytest_asyncio.fixture +async def maubot_plugin_db(tmp_path, maubot_plugin_meta, maubot_upgrade_table): + if not maubot_plugin_meta.get("database", False): + return + db_path = tmp_path.joinpath("maubot-tests.db").as_posix() + db = Database.create( + f"sqlite:///{db_path}", + upgrade_table=maubot_upgrade_table, + log=logging.getLogger("db"), + ) + await db.start() + yield db + await db.stop() + + +@pytest.fixture +def maubot_plugin_class(): + return Plugin + + +@pytest.fixture +def maubot_plugin_config_class(): + return BaseProxyConfig + + +@pytest.fixture +def maubot_plugin_config_dict(): + return {} + + +@pytest.fixture +def maubot_plugin_config_overrides(): + return {} + + +@pytest.fixture +def maubot_plugin_config( + maubot_plugin_path, + maubot_plugin_config_class, + maubot_plugin_config_dict, + maubot_plugin_config_overrides, +): + yaml = YAML() + with open(maubot_plugin_path.joinpath("base-config.yaml")) as fh: + base_config = RecursiveDict(yaml.load(fh)) + maubot_plugin_config_dict.update(maubot_plugin_config_overrides) + return maubot_plugin_config_class( + load=lambda: maubot_plugin_config_dict, + load_base=lambda: base_config, + save=lambda c: None, + ) + + +@pytest_asyncio.fixture +async def maubot_plugin( + maubot_test_bot, + maubot_plugin_db, + maubot_plugin_class, + maubot_plugin_path, + maubot_plugin_config, + maubot_plugin_meta, +): + loader = FileSystemLoader(maubot_plugin_path, maubot_plugin_meta) + async with aiohttp.ClientSession() as http: + instance = maubot_plugin_class( + client=maubot_test_bot.client, + loop=asyncio.get_running_loop(), + http=http, + instance_id="tests", + log=TraceLogger("test"), + config=maubot_plugin_config, + database=maubot_plugin_db, + webapp=None, + webapp_url=None, + loader=loader, + ) + await instance.internal_start() + yield instance diff --git a/optional-requirements.txt b/optional-requirements.txt index 6d87db3..0e45b97 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -5,3 +5,7 @@ python-olm>=3,<4 pycryptodome>=3,<4 unpaddedbase64>=1,<3 + +#/testing +pytest +pytest-asyncio diff --git a/setup.py b/setup.py index 24f9e00..79a0c6c 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,8 @@ setuptools.setup( entry_points=""" [console_scripts] mbc=maubot.cli:app + [pytest11] + maubot=maubot.testing """, data_files=[ (".", ["maubot/example-config.yaml"]),