aiogram-testing 1.1.0__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiogram_testing-1.2.0/PKG-INFO +163 -0
- aiogram_testing-1.2.0/README.md +140 -0
- aiogram_testing-1.2.0/aiogram_tests/__init__.py +6 -0
- aiogram_testing-1.2.0/aiogram_tests/exceptions.py +6 -0
- aiogram_testing-1.2.0/aiogram_tests/handler/__init__.py +39 -0
- {aiogram_testing-1.1.0 → aiogram_testing-1.2.0}/aiogram_tests/handler/base.py +4 -7
- aiogram_testing-1.2.0/aiogram_tests/handler/handler.py +178 -0
- {aiogram_testing-1.1.0 → aiogram_testing-1.2.0}/aiogram_tests/mocked_bot.py +9 -1
- aiogram_testing-1.2.0/aiogram_tests/pytest_plugin.py +36 -0
- aiogram_testing-1.2.0/aiogram_tests/requester.py +130 -0
- aiogram_testing-1.2.0/aiogram_tests/tester.py +175 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/__init__.py +155 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/base.py +76 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/chats.py +109 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/entities.py +68 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/events.py +78 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/media.py +143 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/messages.py +329 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/misc.py +22 -0
- aiogram_testing-1.2.0/aiogram_tests/types/dataset/payments.py +64 -0
- {aiogram_testing-1.1.0 → aiogram_testing-1.2.0}/pyproject.toml +7 -3
- aiogram_testing-1.1.0/PKG-INFO +0 -86
- aiogram_testing-1.1.0/README.md +0 -64
- aiogram_testing-1.1.0/aiogram_tests/__init__.py +0 -4
- aiogram_testing-1.1.0/aiogram_tests/exceptions.py +0 -2
- aiogram_testing-1.1.0/aiogram_tests/handler/__init__.py +0 -6
- aiogram_testing-1.1.0/aiogram_tests/handler/handler.py +0 -86
- aiogram_testing-1.1.0/aiogram_tests/requester.py +0 -94
- aiogram_testing-1.1.0/aiogram_tests/types/dataset/__init__.py +0 -673
- aiogram_testing-1.1.0/aiogram_tests/types/dataset/base.py +0 -68
- {aiogram_testing-1.1.0 → aiogram_testing-1.2.0}/aiogram_tests/types/__init__.py +0 -0
- {aiogram_testing-1.1.0 → aiogram_testing-1.2.0}/aiogram_tests/utils.py +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiogram-testing
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: A library for testing your bots on aiogram
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: k0te1ch
|
|
7
|
+
Author-email: khvostov40@gmail.com
|
|
8
|
+
Requires-Python: >=3.12,<3.15
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Dist: aiogram (>=3.23,<4)
|
|
19
|
+
Project-URL: Homepage, https://github.com/k0te1ch/aiogram_tests
|
|
20
|
+
Project-URL: Repository, https://github.com/k0te1ch/aiogram_tests
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Aiogram Tests
|
|
24
|
+
|
|
25
|
+
***aiogram_tests*** is a testing library for bots written on [aiogram](https://github.com/aiogram/aiogram)
|
|
26
|
+
|
|
27
|
+
## 📦 Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install aiogram-testing
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The import name stays `aiogram_tests`:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from aiogram_tests import MockedRequester
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 📚 Simple examples
|
|
40
|
+
|
|
41
|
+
### Simple handler test
|
|
42
|
+
|
|
43
|
+
#### Simple bot
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from aiogram import Bot, Dispatcher, types
|
|
47
|
+
from aiogram.fsm.context import FSMContext
|
|
48
|
+
|
|
49
|
+
# Please, keep your bot tokens on environments, this code only example
|
|
50
|
+
bot = Bot('123456789:AABBCCDDEEFFaabbccddeeff-1234567890')
|
|
51
|
+
dp = Dispatcher()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dp.message()
|
|
55
|
+
async def echo(message: types.Message, state: FSMContext) -> None:
|
|
56
|
+
await message.answer(message.text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == '__main__':
|
|
60
|
+
dp.run_polling(bot)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
#### Test cases
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import pytest
|
|
69
|
+
|
|
70
|
+
from bot import echo
|
|
71
|
+
|
|
72
|
+
from aiogram_tests import MockedRequester
|
|
73
|
+
from aiogram_tests.handler import MessageHandler
|
|
74
|
+
from aiogram_tests.types.dataset import MESSAGE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_echo():
|
|
79
|
+
request = MockedRequester(MessageHandler(echo))
|
|
80
|
+
calls = await request.query(message=MESSAGE.as_object(text="Hello, Bot!"))
|
|
81
|
+
answer_message = calls.send_message.fetchone()
|
|
82
|
+
assert answer_message.text == "Hello, Bot!"
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Checking calls
|
|
87
|
+
|
|
88
|
+
`query()` returns the Bot API methods the handler called, as aiogram objects (`SendMessage`, `EditMessageText`, ...),
|
|
89
|
+
so fields such as `reply_markup` are typed models, not raw dicts.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from aiogram.methods import AnswerCallbackQuery, SendMessage
|
|
93
|
+
|
|
94
|
+
calls = await request.query(message=MESSAGE.as_object(text="Hello, Bot!"))
|
|
95
|
+
|
|
96
|
+
calls.send_message.fetchone() # last SendMessage, by snake_case name
|
|
97
|
+
calls.get(SendMessage) # all SendMessage calls, typed
|
|
98
|
+
calls.last(SendMessage) # last one or None
|
|
99
|
+
calls.assert_called(SendMessage, text="Hello, Bot!") # fails with the calls that were made
|
|
100
|
+
calls.assert_not_called(AnswerCallbackQuery)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Accessing a method that was not called (`calls.edit_message_text`) raises `MethodIsNotCalledError`, an
|
|
104
|
+
`AttributeError`, so `hasattr(calls, "edit_message_text")` works.
|
|
105
|
+
|
|
106
|
+
### Testing a whole router: `BotTester`
|
|
107
|
+
|
|
108
|
+
`MockedRequester` checks one handler function. `BotTester` runs updates through your real router or dispatcher, with
|
|
109
|
+
its filters, middlewares, FSM and injected dependencies, just like production:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from aiogram.methods import SendMessage
|
|
113
|
+
|
|
114
|
+
from mybot.handlers import router
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_survey(bot_tester): # fixture from the bundled pytest plugin
|
|
118
|
+
tester = bot_tester(router, db=fake_db) # keyword arguments reach handlers like dp.start_polling(**kwargs)
|
|
119
|
+
|
|
120
|
+
calls = await tester.send_message("/survey")
|
|
121
|
+
calls.assert_called(SendMessage, text="What is your name?")
|
|
122
|
+
|
|
123
|
+
await tester.send_message("Alice") # FSM state carries over between steps
|
|
124
|
+
calls = await tester.press("Python") # presses a button from the last inline keyboard
|
|
125
|
+
|
|
126
|
+
calls.assert_called(SendMessage, text="Nice to meet you, Alice!")
|
|
127
|
+
assert await tester.get_state() is None
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- `send_message(text, user=..., chat=...)`, `click(data_or_callback_data, message=..., user=...)`, `press(button_text)`
|
|
131
|
+
and `feed(event_or_update, event_type=...)` return the calls made while handling that update.
|
|
132
|
+
- `get_state(user)`, `set_state(state, user=..., **data)` and `state(user)` give access to the FSM.
|
|
133
|
+
- A module-level router is detached from the previous test's dispatcher, so each test gets a fresh one.
|
|
134
|
+
- A `Dispatcher` is used as is; its `workflow_data` and middlewares stay in place.
|
|
135
|
+
|
|
136
|
+
The plugin is registered automatically on install and provides the `bot_tester` (factory), `mocked_bot` and
|
|
137
|
+
`dataset` fixtures. See [examples/test_survey.py](examples/test_survey.py).
|
|
138
|
+
|
|
139
|
+
### Other update types
|
|
140
|
+
|
|
141
|
+
Each update type has its handler; pass the event positionally or by its update field name:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from aiogram_tests.handler import InlineQueryHandler, PreCheckoutQueryHandler, UpdateHandler
|
|
145
|
+
from aiogram_tests.types.dataset import INLINE_QUERY, PRE_CHECKOUT_QUERY
|
|
146
|
+
|
|
147
|
+
calls = await MockedRequester(InlineQueryHandler(search)).query(INLINE_QUERY.as_object(query="cats"))
|
|
148
|
+
calls = await MockedRequester(PreCheckoutQueryHandler(approve)).query(pre_checkout_query=PRE_CHECKOUT_QUERY.as_object())
|
|
149
|
+
|
|
150
|
+
# anything else the dispatcher knows, by name
|
|
151
|
+
calls = await MockedRequester(UpdateHandler(on_business, event="business_message")).query(message)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Available: `MessageHandler`, `EditedMessageHandler`, `ChannelPostHandler`, `EditedChannelPostHandler`,
|
|
155
|
+
`CallbackQueryHandler`, `InlineQueryHandler`, `ChosenInlineResultHandler`, `ShippingQueryHandler`,
|
|
156
|
+
`PreCheckoutQueryHandler`, `PollHandler`, `PollAnswerHandler`, `MyChatMemberHandler`, `ChatMemberHandler`,
|
|
157
|
+
`ChatJoinRequestHandler`, `MessageReactionHandler` and `UpdateHandler`.
|
|
158
|
+
|
|
159
|
+
Middlewares passed as `dp_middlewares` are registered on every event observer except `update` and `error`, whose
|
|
160
|
+
events are `Update` and `ErrorEvent` rather than messages or callbacks.
|
|
161
|
+
|
|
162
|
+
### [▶️ More](https://github.com/k0te1ch/aiogram_tests/tree/main/examples) examples
|
|
163
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Aiogram Tests
|
|
2
|
+
|
|
3
|
+
***aiogram_tests*** is a testing library for bots written on [aiogram](https://github.com/aiogram/aiogram)
|
|
4
|
+
|
|
5
|
+
## 📦 Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install aiogram-testing
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The import name stays `aiogram_tests`:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from aiogram_tests import MockedRequester
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 📚 Simple examples
|
|
18
|
+
|
|
19
|
+
### Simple handler test
|
|
20
|
+
|
|
21
|
+
#### Simple bot
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from aiogram import Bot, Dispatcher, types
|
|
25
|
+
from aiogram.fsm.context import FSMContext
|
|
26
|
+
|
|
27
|
+
# Please, keep your bot tokens on environments, this code only example
|
|
28
|
+
bot = Bot('123456789:AABBCCDDEEFFaabbccddeeff-1234567890')
|
|
29
|
+
dp = Dispatcher()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dp.message()
|
|
33
|
+
async def echo(message: types.Message, state: FSMContext) -> None:
|
|
34
|
+
await message.answer(message.text)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == '__main__':
|
|
38
|
+
dp.run_polling(bot)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
#### Test cases
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import pytest
|
|
47
|
+
|
|
48
|
+
from bot import echo
|
|
49
|
+
|
|
50
|
+
from aiogram_tests import MockedRequester
|
|
51
|
+
from aiogram_tests.handler import MessageHandler
|
|
52
|
+
from aiogram_tests.types.dataset import MESSAGE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_echo():
|
|
57
|
+
request = MockedRequester(MessageHandler(echo))
|
|
58
|
+
calls = await request.query(message=MESSAGE.as_object(text="Hello, Bot!"))
|
|
59
|
+
answer_message = calls.send_message.fetchone()
|
|
60
|
+
assert answer_message.text == "Hello, Bot!"
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Checking calls
|
|
65
|
+
|
|
66
|
+
`query()` returns the Bot API methods the handler called, as aiogram objects (`SendMessage`, `EditMessageText`, ...),
|
|
67
|
+
so fields such as `reply_markup` are typed models, not raw dicts.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from aiogram.methods import AnswerCallbackQuery, SendMessage
|
|
71
|
+
|
|
72
|
+
calls = await request.query(message=MESSAGE.as_object(text="Hello, Bot!"))
|
|
73
|
+
|
|
74
|
+
calls.send_message.fetchone() # last SendMessage, by snake_case name
|
|
75
|
+
calls.get(SendMessage) # all SendMessage calls, typed
|
|
76
|
+
calls.last(SendMessage) # last one or None
|
|
77
|
+
calls.assert_called(SendMessage, text="Hello, Bot!") # fails with the calls that were made
|
|
78
|
+
calls.assert_not_called(AnswerCallbackQuery)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Accessing a method that was not called (`calls.edit_message_text`) raises `MethodIsNotCalledError`, an
|
|
82
|
+
`AttributeError`, so `hasattr(calls, "edit_message_text")` works.
|
|
83
|
+
|
|
84
|
+
### Testing a whole router: `BotTester`
|
|
85
|
+
|
|
86
|
+
`MockedRequester` checks one handler function. `BotTester` runs updates through your real router or dispatcher, with
|
|
87
|
+
its filters, middlewares, FSM and injected dependencies, just like production:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from aiogram.methods import SendMessage
|
|
91
|
+
|
|
92
|
+
from mybot.handlers import router
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def test_survey(bot_tester): # fixture from the bundled pytest plugin
|
|
96
|
+
tester = bot_tester(router, db=fake_db) # keyword arguments reach handlers like dp.start_polling(**kwargs)
|
|
97
|
+
|
|
98
|
+
calls = await tester.send_message("/survey")
|
|
99
|
+
calls.assert_called(SendMessage, text="What is your name?")
|
|
100
|
+
|
|
101
|
+
await tester.send_message("Alice") # FSM state carries over between steps
|
|
102
|
+
calls = await tester.press("Python") # presses a button from the last inline keyboard
|
|
103
|
+
|
|
104
|
+
calls.assert_called(SendMessage, text="Nice to meet you, Alice!")
|
|
105
|
+
assert await tester.get_state() is None
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `send_message(text, user=..., chat=...)`, `click(data_or_callback_data, message=..., user=...)`, `press(button_text)`
|
|
109
|
+
and `feed(event_or_update, event_type=...)` return the calls made while handling that update.
|
|
110
|
+
- `get_state(user)`, `set_state(state, user=..., **data)` and `state(user)` give access to the FSM.
|
|
111
|
+
- A module-level router is detached from the previous test's dispatcher, so each test gets a fresh one.
|
|
112
|
+
- A `Dispatcher` is used as is; its `workflow_data` and middlewares stay in place.
|
|
113
|
+
|
|
114
|
+
The plugin is registered automatically on install and provides the `bot_tester` (factory), `mocked_bot` and
|
|
115
|
+
`dataset` fixtures. See [examples/test_survey.py](examples/test_survey.py).
|
|
116
|
+
|
|
117
|
+
### Other update types
|
|
118
|
+
|
|
119
|
+
Each update type has its handler; pass the event positionally or by its update field name:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from aiogram_tests.handler import InlineQueryHandler, PreCheckoutQueryHandler, UpdateHandler
|
|
123
|
+
from aiogram_tests.types.dataset import INLINE_QUERY, PRE_CHECKOUT_QUERY
|
|
124
|
+
|
|
125
|
+
calls = await MockedRequester(InlineQueryHandler(search)).query(INLINE_QUERY.as_object(query="cats"))
|
|
126
|
+
calls = await MockedRequester(PreCheckoutQueryHandler(approve)).query(pre_checkout_query=PRE_CHECKOUT_QUERY.as_object())
|
|
127
|
+
|
|
128
|
+
# anything else the dispatcher knows, by name
|
|
129
|
+
calls = await MockedRequester(UpdateHandler(on_business, event="business_message")).query(message)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Available: `MessageHandler`, `EditedMessageHandler`, `ChannelPostHandler`, `EditedChannelPostHandler`,
|
|
133
|
+
`CallbackQueryHandler`, `InlineQueryHandler`, `ChosenInlineResultHandler`, `ShippingQueryHandler`,
|
|
134
|
+
`PreCheckoutQueryHandler`, `PollHandler`, `PollAnswerHandler`, `MyChatMemberHandler`, `ChatMemberHandler`,
|
|
135
|
+
`ChatJoinRequestHandler`, `MessageReactionHandler` and `UpdateHandler`.
|
|
136
|
+
|
|
137
|
+
Middlewares passed as `dp_middlewares` are registered on every event observer except `update` and `error`, whose
|
|
138
|
+
events are `Update` and `ErrorEvent` rather than messages or callbacks.
|
|
139
|
+
|
|
140
|
+
### [▶️ More](https://github.com/k0te1ch/aiogram_tests/tree/main/examples) examples
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .base import RequestHandler
|
|
2
|
+
from .handler import CallbackQueryHandler
|
|
3
|
+
from .handler import ChannelPostHandler
|
|
4
|
+
from .handler import ChatJoinRequestHandler
|
|
5
|
+
from .handler import ChatMemberHandler
|
|
6
|
+
from .handler import ChosenInlineResultHandler
|
|
7
|
+
from .handler import EditedChannelPostHandler
|
|
8
|
+
from .handler import EditedMessageHandler
|
|
9
|
+
from .handler import InlineQueryHandler
|
|
10
|
+
from .handler import MessageHandler
|
|
11
|
+
from .handler import MessageReactionHandler
|
|
12
|
+
from .handler import MyChatMemberHandler
|
|
13
|
+
from .handler import PollAnswerHandler
|
|
14
|
+
from .handler import PollHandler
|
|
15
|
+
from .handler import PreCheckoutQueryHandler
|
|
16
|
+
from .handler import ShippingQueryHandler
|
|
17
|
+
from .handler import TelegramEventObserverHandler
|
|
18
|
+
from .handler import UpdateHandler
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CallbackQueryHandler",
|
|
22
|
+
"ChannelPostHandler",
|
|
23
|
+
"ChatJoinRequestHandler",
|
|
24
|
+
"ChatMemberHandler",
|
|
25
|
+
"ChosenInlineResultHandler",
|
|
26
|
+
"EditedChannelPostHandler",
|
|
27
|
+
"EditedMessageHandler",
|
|
28
|
+
"InlineQueryHandler",
|
|
29
|
+
"MessageHandler",
|
|
30
|
+
"MessageReactionHandler",
|
|
31
|
+
"MyChatMemberHandler",
|
|
32
|
+
"PollAnswerHandler",
|
|
33
|
+
"PollHandler",
|
|
34
|
+
"PreCheckoutQueryHandler",
|
|
35
|
+
"RequestHandler",
|
|
36
|
+
"ShippingQueryHandler",
|
|
37
|
+
"TelegramEventObserverHandler",
|
|
38
|
+
"UpdateHandler",
|
|
39
|
+
]
|
|
@@ -2,7 +2,6 @@ from collections.abc import Iterable
|
|
|
2
2
|
|
|
3
3
|
from aiogram import BaseMiddleware
|
|
4
4
|
from aiogram import Dispatcher
|
|
5
|
-
from aiogram.dispatcher.event.telegram import TelegramEventObserver
|
|
6
5
|
from aiogram.fsm.storage.memory import MemoryStorage
|
|
7
6
|
from aiogram.methods import TelegramMethod
|
|
8
7
|
from aiogram.methods.base import Response
|
|
@@ -39,14 +38,12 @@ class RequestHandler:
|
|
|
39
38
|
def _get_dispatcher_event_observers(self) -> list[str]:
|
|
40
39
|
"""
|
|
41
40
|
Returns a names for bot event observers, like message, callback_query etc.
|
|
42
|
-
"""
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
for
|
|
46
|
-
|
|
47
|
-
result.append(name)
|
|
42
|
+
``update`` and ``error`` are left out: their events are ``Update`` and ``ErrorEvent``, and a middleware
|
|
43
|
+
written for messages or callbacks breaks on them. Register such middlewares on ``dp`` directly.
|
|
44
|
+
"""
|
|
48
45
|
|
|
49
|
-
return
|
|
46
|
+
return [name for name in self.dp.observers if name not in ("update", "error")]
|
|
50
47
|
|
|
51
48
|
def _register_middlewares(self, event_observer: Iterable, middlewares: Iterable) -> None:
|
|
52
49
|
for eo_name in event_observer:
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from aiogram import types
|
|
7
|
+
from aiogram.dispatcher.middlewares.user_context import UserContextMiddleware
|
|
8
|
+
from aiogram.filters import Filter
|
|
9
|
+
from aiogram.filters import StateFilter
|
|
10
|
+
from aiogram.fsm.context import FSMContext
|
|
11
|
+
from aiogram.fsm.state import State
|
|
12
|
+
|
|
13
|
+
from aiogram_tests.types.dataset import CHAT
|
|
14
|
+
from aiogram_tests.types.dataset import USER
|
|
15
|
+
|
|
16
|
+
from .base import RequestHandler
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TelegramEventObserverHandler(RequestHandler):
|
|
20
|
+
"""
|
|
21
|
+
Registers ``callback`` on the dispatcher observer ``event`` (``message``, ``inline_query``, ...) and feeds it
|
|
22
|
+
updates of that type. Subclasses fix ``event``; ``UpdateHandler`` takes it as an argument.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
event: str | None = None
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
callback: Callable,
|
|
30
|
+
*filters: Filter,
|
|
31
|
+
state: State | str | None = None,
|
|
32
|
+
state_data: dict = None,
|
|
33
|
+
state_context: FSMContext | None = None,
|
|
34
|
+
dp_middlewares: Iterable = None,
|
|
35
|
+
exclude_observer_methods: Iterable = None,
|
|
36
|
+
**kwargs,
|
|
37
|
+
):
|
|
38
|
+
super().__init__(dp_middlewares, exclude_observer_methods, **kwargs)
|
|
39
|
+
|
|
40
|
+
if state_data is None:
|
|
41
|
+
state_data = {}
|
|
42
|
+
if not isinstance(state_data, dict):
|
|
43
|
+
raise ValueError("state_data is not a dict")
|
|
44
|
+
|
|
45
|
+
self._callback = callback
|
|
46
|
+
self._filters: list = list(filters)
|
|
47
|
+
self._state: State | str | None = state
|
|
48
|
+
self._state_data: dict = state_data
|
|
49
|
+
self._state_context: FSMContext | None = state_context
|
|
50
|
+
self._registered = False
|
|
51
|
+
self._update_ids = itertools.count(1)
|
|
52
|
+
|
|
53
|
+
if self._state:
|
|
54
|
+
self._filters.append(StateFilter(self._state))
|
|
55
|
+
|
|
56
|
+
async def __call__(self, *args, **kwargs):
|
|
57
|
+
if not self._registered:
|
|
58
|
+
self.register_handler()
|
|
59
|
+
self._registered = True
|
|
60
|
+
|
|
61
|
+
update = self.build_update(*args, **kwargs)
|
|
62
|
+
|
|
63
|
+
if self._state_context or self._state:
|
|
64
|
+
state = self._get_state_context(update)
|
|
65
|
+
await state.set_state(self._state)
|
|
66
|
+
await state.update_data(**self._state_data)
|
|
67
|
+
|
|
68
|
+
await self.dp.feed_update(self.bot, update)
|
|
69
|
+
|
|
70
|
+
def _get_state_context(self, update: types.Update) -> FSMContext:
|
|
71
|
+
"""
|
|
72
|
+
FSM context of the user and chat the update comes from, as the dispatcher resolves it
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
context = UserContextMiddleware.resolve_event_context(update)
|
|
76
|
+
state = self.dp.fsm.resolve_context(
|
|
77
|
+
bot=self.bot,
|
|
78
|
+
chat_id=context.chat_id or CHAT["id"],
|
|
79
|
+
user_id=context.user_id or USER["id"],
|
|
80
|
+
thread_id=context.thread_id,
|
|
81
|
+
business_connection_id=context.business_connection_id,
|
|
82
|
+
)
|
|
83
|
+
return state
|
|
84
|
+
|
|
85
|
+
def register_handler(self) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Register TelegramEventObserver in dispatcher
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
getattr(self.dp, self.event).register(self._callback, *self._filters)
|
|
91
|
+
|
|
92
|
+
def build_update(self, *args, **kwargs) -> types.Update:
|
|
93
|
+
"""
|
|
94
|
+
Wrap the event, passed positionally or by its update field name, into an update for the dispatcher
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
event = self._pick_event(args, kwargs)
|
|
98
|
+
return types.Update(update_id=next(self._update_ids), **{self.event: event})
|
|
99
|
+
|
|
100
|
+
def _pick_event(self, args: tuple, kwargs: dict) -> Any:
|
|
101
|
+
if args and not kwargs:
|
|
102
|
+
return args[0]
|
|
103
|
+
if not args and list(kwargs) == [self.event]:
|
|
104
|
+
return kwargs[self.event]
|
|
105
|
+
passed = [*(type(arg).__name__ for arg in args), *kwargs]
|
|
106
|
+
raise TypeError(f"{type(self).__name__} takes one '{self.event}' event, got {passed}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UpdateHandler(TelegramEventObserverHandler):
|
|
110
|
+
"""
|
|
111
|
+
Handler for any update type by its field name: ``UpdateHandler(callback, event="business_message")``
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, callback: Callable, *filters: Filter, event: str, **kwargs):
|
|
115
|
+
self.event = event
|
|
116
|
+
super().__init__(callback, *filters, **kwargs)
|
|
117
|
+
if event not in self.dp.observers or event in ("update", "error"):
|
|
118
|
+
raise ValueError(f"dispatcher has no '{event}' updates")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MessageHandler(TelegramEventObserverHandler):
|
|
122
|
+
event = "message"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class EditedMessageHandler(TelegramEventObserverHandler):
|
|
126
|
+
event = "edited_message"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ChannelPostHandler(TelegramEventObserverHandler):
|
|
130
|
+
event = "channel_post"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class EditedChannelPostHandler(TelegramEventObserverHandler):
|
|
134
|
+
event = "edited_channel_post"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class CallbackQueryHandler(TelegramEventObserverHandler):
|
|
138
|
+
event = "callback_query"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class InlineQueryHandler(TelegramEventObserverHandler):
|
|
142
|
+
event = "inline_query"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ChosenInlineResultHandler(TelegramEventObserverHandler):
|
|
146
|
+
event = "chosen_inline_result"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ShippingQueryHandler(TelegramEventObserverHandler):
|
|
150
|
+
event = "shipping_query"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PreCheckoutQueryHandler(TelegramEventObserverHandler):
|
|
154
|
+
event = "pre_checkout_query"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PollHandler(TelegramEventObserverHandler):
|
|
158
|
+
event = "poll"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PollAnswerHandler(TelegramEventObserverHandler):
|
|
162
|
+
event = "poll_answer"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class MyChatMemberHandler(TelegramEventObserverHandler):
|
|
166
|
+
event = "my_chat_member"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ChatMemberHandler(TelegramEventObserverHandler):
|
|
170
|
+
event = "chat_member"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ChatJoinRequestHandler(TelegramEventObserverHandler):
|
|
174
|
+
event = "chat_join_request"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MessageReactionHandler(TelegramEventObserverHandler):
|
|
178
|
+
event = "message_reaction"
|
|
@@ -11,6 +11,8 @@ from aiogram.types import ResponseParameters
|
|
|
11
11
|
from aiogram.types import User
|
|
12
12
|
from aiogram.types.base import UNSET_TYPE
|
|
13
13
|
|
|
14
|
+
from .exceptions import MockedResponseMissingError
|
|
15
|
+
|
|
14
16
|
DEFAULT_AUTO_MOCK_SUCCESS = True
|
|
15
17
|
|
|
16
18
|
|
|
@@ -19,6 +21,7 @@ class MockedSession(BaseSession):
|
|
|
19
21
|
super().__init__()
|
|
20
22
|
self.responses: deque[Response[TelegramType]] = deque()
|
|
21
23
|
self.requests: deque[Request] = deque()
|
|
24
|
+
self.methods: deque[TelegramMethod] = deque()
|
|
22
25
|
self.closed = True
|
|
23
26
|
|
|
24
27
|
def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]:
|
|
@@ -43,6 +46,12 @@ class MockedSession(BaseSession):
|
|
|
43
46
|
self.closed = False
|
|
44
47
|
request = Request(method=method.__api_method__, data=method.__dict__, files=None)
|
|
45
48
|
self.requests.append(request)
|
|
49
|
+
self.methods.append(method)
|
|
50
|
+
if not self.responses:
|
|
51
|
+
raise MockedResponseMissingError(
|
|
52
|
+
f"no mocked response for {method.__api_method__}: call "
|
|
53
|
+
f"add_result_for({type(method).__name__}, ...) first or enable auto_mock_success"
|
|
54
|
+
)
|
|
46
55
|
response: Response[TelegramType] = self.responses.pop()
|
|
47
56
|
self.check_response(
|
|
48
57
|
method=method,
|
|
@@ -61,7 +70,6 @@ class MockedSession(BaseSession):
|
|
|
61
70
|
class MockedBot(Bot):
|
|
62
71
|
def __init__(self, auto_mock_success: bool = DEFAULT_AUTO_MOCK_SUCCESS, **kwargs):
|
|
63
72
|
super().__init__(kwargs.pop("token", "42:TEST"), session=MockedSession(), **kwargs)
|
|
64
|
-
self.session = MockedSession()
|
|
65
73
|
self._me = User(
|
|
66
74
|
id=self.id,
|
|
67
75
|
is_bot=True,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""pytest fixtures, loaded automatically through the ``pytest11`` entry point"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from aiogram import Dispatcher
|
|
9
|
+
from aiogram import Router
|
|
10
|
+
|
|
11
|
+
from .mocked_bot import MockedBot
|
|
12
|
+
from .tester import BotTester
|
|
13
|
+
from .types import dataset as _dataset
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def mocked_bot() -> MockedBot:
|
|
18
|
+
return MockedBot()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def bot_tester(mocked_bot: MockedBot) -> Callable[..., BotTester]:
|
|
23
|
+
"""
|
|
24
|
+
Factory: ``bot_tester(router_or_dispatcher, **workflow_data)``; every tester in a test shares ``mocked_bot``
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def make(dispatcher: Dispatcher | Router, **kwargs: Any) -> BotTester:
|
|
28
|
+
kwargs.setdefault("bot", mocked_bot)
|
|
29
|
+
return BotTester(dispatcher, **kwargs)
|
|
30
|
+
|
|
31
|
+
return make
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def dataset() -> ModuleType:
|
|
36
|
+
return _dataset
|