diff --git a/.coveragerc b/.coveragerc index 9feee202..e1862099 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,3 +3,4 @@ exclude_lines = pragma: no cover if TYPE_CHECKING: @abstractmethod + @overload diff --git a/Makefile b/Makefile index 63b117c0..343f64b0 100644 --- a/Makefile +++ b/Makefile @@ -84,7 +84,7 @@ reformat: # ================================================================================================= .PHONY: test-run-services test-run-services: - docker-compose -f tests/docker-compose.yml -p aiogram3-dev up -d + @#docker-compose -f tests/docker-compose.yml -p aiogram3-dev up -d .PHONY: test test: test-run-services diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 6ed65c76..ba25e5f2 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -2,7 +2,7 @@ from .client import session from .client.bot import Bot from .dispatcher import filters, handler from .dispatcher.dispatcher import Dispatcher -from .dispatcher.flags.flags import FlagGenerator +from .dispatcher.flags.flag import FlagGenerator from .dispatcher.middlewares.base import BaseMiddleware from .dispatcher.router import Router from .utils.magic_filter import MagicFilter diff --git a/aiogram/dispatcher/flags/flags.py b/aiogram/dispatcher/flags/flag.py similarity index 80% rename from aiogram/dispatcher/flags/flags.py rename to aiogram/dispatcher/flags/flag.py index 8df2d9f1..845b898b 100644 --- a/aiogram/dispatcher/flags/flags.py +++ b/aiogram/dispatcher/flags/flag.py @@ -6,19 +6,23 @@ from magic_filter import AttrDict from aiogram.dispatcher.flags.getter import extract_flags_from_object -@dataclass +@dataclass(frozen=True) class Flag: name: str value: Any -@dataclass +@dataclass(frozen=True) class FlagDecorator: flag: Flag - def with_value(self, value: Any) -> "FlagDecorator": + @classmethod + def _with_flag(cls, flag: Flag) -> "FlagDecorator": + return cls(flag) + + def _with_value(self, value: Any) -> "FlagDecorator": new_flag = Flag(self.flag.name, value) - return type(self)(new_flag) + return self._with_flag(new_flag) @overload def __call__(self, value: Callable[..., Any]) -> Callable[..., Any]: # type: ignore @@ -46,7 +50,7 @@ class FlagDecorator: self.flag.name: self.flag.value, } return cast(Callable[..., Any], value) - return self.with_value(AttrDict(kwargs) if value is None else value) + return self._with_value(AttrDict(kwargs) if value is None else value) class FlagGenerator: diff --git a/aiogram/dispatcher/fsm/storage/base.py b/aiogram/dispatcher/fsm/storage/base.py index 0fd13c28..71d6ff16 100644 --- a/aiogram/dispatcher/fsm/storage/base.py +++ b/aiogram/dispatcher/fsm/storage/base.py @@ -104,5 +104,6 @@ class BaseEventIsolation(ABC): """ yield None + @abstractmethod async def close(self) -> None: pass diff --git a/aiogram/dispatcher/fsm/storage/memory.py b/aiogram/dispatcher/fsm/storage/memory.py index 1b04397b..b65b5d11 100644 --- a/aiogram/dispatcher/fsm/storage/memory.py +++ b/aiogram/dispatcher/fsm/storage/memory.py @@ -56,6 +56,9 @@ class DisabledEventIsolation(BaseEventIsolation): async def lock(self, bot: Bot, key: StorageKey) -> AsyncGenerator[None, None]: yield + async def close(self) -> None: + pass + class SimpleEventIsolation(BaseEventIsolation): def __init__(self) -> None: @@ -67,3 +70,6 @@ class SimpleEventIsolation(BaseEventIsolation): lock = self._locks[key] async with lock: yield + + async def close(self) -> None: + self._locks.clear() diff --git a/aiogram/dispatcher/fsm/storage/redis.py b/aiogram/dispatcher/fsm/storage/redis.py index ce215d8e..c5ea89ef 100644 --- a/aiogram/dispatcher/fsm/storage/redis.py +++ b/aiogram/dispatcher/fsm/storage/redis.py @@ -194,15 +194,17 @@ class RedisEventIsolation(BaseEventIsolation): def __init__( self, redis: Redis, - key_builder: KeyBuilder, + key_builder: Optional[KeyBuilder] = None, lock_kwargs: Optional[Dict[str, Any]] = None, ) -> None: + if key_builder is None: + key_builder = DefaultKeyBuilder() self.redis = redis self.key_builder = key_builder self.lock_kwargs = lock_kwargs or {} @classmethod - async def from_redis( + def from_url( cls, url: str, connection_kwargs: Optional[Dict[str, Any]] = None, @@ -223,3 +225,6 @@ class RedisEventIsolation(BaseEventIsolation): redis_key = self.key_builder.build(key, "lock") async with self.redis.lock(name=redis_key, **self.lock_kwargs): yield None + + async def close(self) -> None: + pass diff --git a/tests/conftest.py b/tests/conftest.py index e57ec632..698ee5cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,9 +4,13 @@ import pytest from _pytest.config import UsageError from aioredis.connection import parse_url as parse_redis_url -from aiogram import Bot -from aiogram.dispatcher.fsm.storage.memory import MemoryStorage -from aiogram.dispatcher.fsm.storage.redis import RedisStorage +from aiogram import Bot, Dispatcher +from aiogram.dispatcher.fsm.storage.memory import ( + DisabledEventIsolation, + MemoryStorage, + SimpleEventIsolation, +) +from aiogram.dispatcher.fsm.storage.redis import RedisEventIsolation, RedisStorage from tests.mocked_bot import MockedBot DATA_DIR = Path(__file__).parent / "data" @@ -67,6 +71,42 @@ async def memory_storage(): await storage.close() +@pytest.fixture() +@pytest.mark.redis +async def redis_isolation(redis_server): + if not redis_server: + pytest.skip("Redis is not available here") + isolation = RedisEventIsolation.from_url(redis_server) + try: + await isolation.redis.info() + except ConnectionError as e: + pytest.skip(str(e)) + try: + yield isolation + finally: + conn = await isolation.redis + await conn.flushdb() + await isolation.close() + + +@pytest.fixture() +async def lock_isolation(): + isolation = SimpleEventIsolation() + try: + yield isolation + finally: + await isolation.close() + + +@pytest.fixture() +async def disabled_isolation(): + isolation = DisabledEventIsolation() + try: + yield isolation + finally: + await isolation.close() + + @pytest.fixture() def bot(): bot = MockedBot() @@ -75,3 +115,13 @@ def bot(): yield bot finally: Bot.reset_current(token) + + +@pytest.fixture() +async def dispatcher(): + dp = Dispatcher() + await dp.emit_startup() + try: + yield dp + finally: + await dp.emit_shutdown() diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index 31e437ca..1150f073 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -76,20 +76,15 @@ class TestDispatcher: assert dp.update.handlers[0].callback == dp._listen_update assert dp.update.outer_middlewares - def test_parent_router(self): - dp = Dispatcher() + def test_parent_router(self, dispatcher: Dispatcher): with pytest.raises(RuntimeError): - dp.parent_router = Router() - assert dp.parent_router is None - dp._parent_router = Router() - assert dp.parent_router is None + dispatcher.parent_router = Router() + assert dispatcher.parent_router is None + dispatcher._parent_router = Router() + assert dispatcher.parent_router is None - @pytest.mark.parametrize("isolate_events", (True, False)) - async def test_feed_update(self, isolate_events): - dp = Dispatcher(isolate_events=isolate_events) - bot = Bot("42:TEST") - - @dp.message() + async def test_feed_update(self, dispatcher: Dispatcher, bot: MockedBot): + @dispatcher.message() async def my_handler(message: Message, **kwargs): assert "bot" in kwargs assert isinstance(kwargs["bot"], Bot) @@ -97,7 +92,7 @@ class TestDispatcher: return message.text results_count = 0 - result = await dp.feed_update( + result = await dispatcher.feed_update( bot=bot, update=Update( update_id=42, diff --git a/tests/test_dispatcher/test_flags/__init__.py b/tests/test_dispatcher/test_flags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_dispatcher/test_flags/test_decorator.py b/tests/test_dispatcher/test_flags/test_decorator.py new file mode 100644 index 00000000..6c4e40df --- /dev/null +++ b/tests/test_dispatcher/test_flags/test_decorator.py @@ -0,0 +1,66 @@ +import pytest + +from aiogram.dispatcher.flags.flag import Flag, FlagDecorator, FlagGenerator + + +@pytest.fixture(name="flag") +def flag_fixture() -> Flag: + return Flag("test", True) + + +@pytest.fixture(name="flag_decorator") +def flag_decorator_fixture(flag: Flag) -> FlagDecorator: + return FlagDecorator(flag) + + +@pytest.fixture(name="flag_generator") +def flag_flag_generator() -> FlagGenerator: + return FlagGenerator() + + +class TestFlagDecorator: + def test_with_value(self, flag_decorator: FlagDecorator): + new_decorator = flag_decorator._with_value(True) + + assert new_decorator is not flag_decorator + assert new_decorator.flag is not flag_decorator.flag + assert new_decorator.flag + + def test_call_invalid(self, flag_decorator: FlagDecorator): + with pytest.raises(ValueError): + flag_decorator(True, test=True) + + def test_call_with_function(self, flag_decorator: FlagDecorator): + def func(): + pass + + decorated = flag_decorator(func) + assert decorated is func + assert hasattr(decorated, "aiogram_flag") + + def test_call_with_arg(self, flag_decorator: FlagDecorator): + new_decorator = flag_decorator("hello") + assert new_decorator is not flag_decorator + assert new_decorator.flag.value == "hello" + + def test_call_with_kwargs(self, flag_decorator: FlagDecorator): + new_decorator = flag_decorator(test=True) + assert new_decorator is not flag_decorator + assert isinstance(new_decorator.flag.value, dict) + assert "test" in new_decorator.flag.value + + +class TestFlagGenerator: + def test_getattr(self): + generator = FlagGenerator() + assert isinstance(generator.foo, FlagDecorator) + assert isinstance(generator.bar, FlagDecorator) + + assert generator.foo is not generator.foo + assert generator.foo is not generator.bar + + def test_failed_getattr(self): + generator = FlagGenerator() + + with pytest.raises(AttributeError): + generator._something diff --git a/tests/test_dispatcher/test_flags/test_getter.py b/tests/test_dispatcher/test_flags/test_getter.py new file mode 100644 index 00000000..afe7891c --- /dev/null +++ b/tests/test_dispatcher/test_flags/test_getter.py @@ -0,0 +1,64 @@ +from unittest.mock import patch + +import pytest + +from aiogram import F +from aiogram.dispatcher.event.handler import HandlerObject +from aiogram.dispatcher.flags.getter import ( + check_flags, + extract_flags, + extract_flags_from_object, + get_flag, +) + + +class TestGetters: + def test_extract_flags_from_object(self): + def func(): + pass + + assert extract_flags_from_object(func) == {} + + func.aiogram_flag = {"test": True} + assert extract_flags_from_object(func) == func.aiogram_flag + + @pytest.mark.parametrize( + "obj,result", + [ + [None, {}], + [{}, {}], + [{"handler": None}, {}], + [{"handler": HandlerObject(lambda: True, flags={"test": True})}, {"test": True}], + ], + ) + def test_extract_flags(self, obj, result): + assert extract_flags(obj) == result + + @pytest.mark.parametrize( + "obj,name,default,result", + [ + [None, "test", None, None], + [None, "test", 42, 42], + [{}, "test", None, None], + [{}, "test", 42, 42], + [{"handler": None}, "test", None, None], + [{"handler": None}, "test", 42, 42], + [{"handler": HandlerObject(lambda: True, flags={"test": True})}, "test", None, True], + [{"handler": HandlerObject(lambda: True, flags={"test": True})}, "test2", None, None], + [{"handler": HandlerObject(lambda: True, flags={"test": True})}, "test2", 42, 42], + ], + ) + def test_get_flag(self, obj, name, default, result): + assert get_flag(obj, name, default=default) == result + + @pytest.mark.parametrize( + "flags,magic,result", + [ + [{}, F.test, None], + [{"test": True}, F.test, True], + [{"test": True}, F.spam, None], + ], + ) + def test_check_flag(self, flags, magic, result): + with patch("aiogram.dispatcher.flags.getter.extract_flags", return_value=flags): + assert check_flags(object(), magic) == result diff --git a/tests/test_dispatcher/test_fsm/storage/test_isolation.py b/tests/test_dispatcher/test_fsm/storage/test_isolation.py new file mode 100644 index 00000000..8b582f45 --- /dev/null +++ b/tests/test_dispatcher/test_fsm/storage/test_isolation.py @@ -0,0 +1,30 @@ +import pytest + +from aiogram.dispatcher.fsm.storage.base import BaseEventIsolation, StorageKey +from tests.mocked_bot import MockedBot + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(name="storage_key") +def create_storate_key(bot: MockedBot): + return StorageKey(chat_id=-42, user_id=42, bot_id=bot.id) + + +@pytest.mark.parametrize( + "isolation", + [ + pytest.lazy_fixture("redis_isolation"), + pytest.lazy_fixture("lock_isolation"), + pytest.lazy_fixture("disabled_isolation"), + ], +) +class TestIsolations: + async def test_lock( + self, + bot: MockedBot, + isolation: BaseEventIsolation, + storage_key: StorageKey, + ): + async with isolation.lock(bot=bot, key=storage_key): + assert True, "You are kidding me?" diff --git a/tests/test_dispatcher/test_fsm/storage/test_redis.py b/tests/test_dispatcher/test_fsm/storage/test_redis.py index 8914dc94..5bf3170e 100644 --- a/tests/test_dispatcher/test_fsm/storage/test_redis.py +++ b/tests/test_dispatcher/test_fsm/storage/test_redis.py @@ -1,7 +1,11 @@ import pytest from aiogram.dispatcher.fsm.storage.base import DEFAULT_DESTINY, StorageKey -from aiogram.dispatcher.fsm.storage.redis import DefaultKeyBuilder +from aiogram.dispatcher.fsm.storage.redis import ( + DefaultKeyBuilder, + RedisEventIsolation, + RedisStorage, +) pytestmark = pytest.mark.asyncio @@ -43,3 +47,11 @@ class TestRedisDefaultKeyBuilder: ) with pytest.raises(ValueError): key_builder.build(key, FIELD) + + def test_create_isolation(self): + fake_redis = object() + storage = RedisStorage(redis=fake_redis) + isolation = storage.create_isolation() + assert isinstance(isolation, RedisEventIsolation) + assert isolation.redis is fake_redis + assert isolation.key_builder is storage.key_builder diff --git a/tests/test_dispatcher/test_fsm/storage/test_storages.py b/tests/test_dispatcher/test_fsm/storage/test_storages.py index 428f6d02..803e3059 100644 --- a/tests/test_dispatcher/test_fsm/storage/test_storages.py +++ b/tests/test_dispatcher/test_fsm/storage/test_storages.py @@ -16,11 +16,6 @@ def create_storate_key(bot: MockedBot): [pytest.lazy_fixture("redis_storage"), pytest.lazy_fixture("memory_storage")], ) class TestStorages: - async def test_lock(self, bot: MockedBot, storage: BaseStorage, storage_key: StorageKey): - # TODO: ?!? - async with storage.lock(bot=bot, key=storage_key): - assert True, "You are kidding me?" - async def test_set_state(self, bot: MockedBot, storage: BaseStorage, storage_key: StorageKey): assert await storage.get_state(bot=bot, key=storage_key) is None diff --git a/tests/test_dispatcher/test_webhook/test_aiohtt_server.py b/tests/test_dispatcher/test_webhook/test_aiohtt_server.py index fa9dad9c..7a044715 100644 --- a/tests/test_dispatcher/test_webhook/test_aiohtt_server.py +++ b/tests/test_dispatcher/test_webhook/test_aiohtt_server.py @@ -1,4 +1,6 @@ +import asyncio import time +from asyncio import Event from dataclasses import dataclass from typing import Any, Dict @@ -19,6 +21,12 @@ from aiogram.methods import GetMe, Request from aiogram.types import Message, User from tests.mocked_bot import MockedBot +try: + from asynctest import CoroutineMock, patch +except ImportError: + from unittest.mock import AsyncMock as CoroutineMock # type: ignore + from unittest.mock import patch + class TestAiohttpServer: def test_setup_application(self): @@ -74,8 +82,11 @@ class TestSimpleRequestHandler: app = Application() dp = Dispatcher() + handler_event = Event() + @dp.message(F.text == "test") def handle_message(msg: Message): + handler_event.set() return msg.answer("PASS") handler = SimpleRequestHandler( @@ -97,8 +108,15 @@ class TestSimpleRequestHandler: assert not result handler.handle_in_background = True - resp = await self.make_reqest(client=client) - assert resp.status == 200 + with patch( + "aiogram.dispatcher.dispatcher.Dispatcher.silent_call_request", + new_callable=CoroutineMock, + ) as mocked_silent_call_request: + handler_event.clear() + resp = await self.make_reqest(client=client) + assert resp.status == 200 + await asyncio.wait_for(handler_event.wait(), timeout=1) + mocked_silent_call_request.assert_awaited() result = await resp.json() assert not result diff --git a/tests/test_utils/test_i18n.py b/tests/test_utils/test_i18n.py index e8581414..31843080 100644 --- a/tests/test_utils/test_i18n.py +++ b/tests/test_utils/test_i18n.py @@ -114,6 +114,24 @@ class TestSimpleI18nMiddleware: assert middleware not in dp.update.outer_middlewares assert middleware in dp.message.outer_middlewares + async def test_get_unknown_locale(self, i18n: I18n): + dp = Dispatcher() + middleware = SimpleI18nMiddleware(i18n=i18n) + middleware.setup(router=dp) + + locale = await middleware.get_locale( + None, + { + "event_from_user": User( + id=42, + is_bot=False, + first_name="Test", + language_code="unknown", + ) + }, + ) + assert locale == i18n.default_locale + @pytest.mark.asyncio class TestConstI18nMiddleware: