diff --git a/pyproject.toml b/pyproject.toml index 0e60d68..a9312ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,12 @@ omit = [ "__init__.py", "*/docker/*", "*/rate_limit.py", - "*/logger.py" + "*/logger.py", + "*/daemons/*", + "*/topology_setup.py", + "*/logging/*", + "*/email_reset.py", + "*/email_welcome.py" ] [tool.coverage.report] diff --git a/src/messaging/consumers/consumers.py b/src/messaging/consumers/consumers.py index 9a3efb3..6790af1 100644 --- a/src/messaging/consumers/consumers.py +++ b/src/messaging/consumers/consumers.py @@ -5,6 +5,7 @@ from uuid import uuid4 from src.logging import daemon_logger from src.logging.logger import message_id_ctx from src.messaging.rabbitmq_client import rabbitmq_client +from src.service.email.email_reset import ResetEmailSender from src.service.email.email_welcome import DaemonEmailSender @@ -55,23 +56,37 @@ class WelcomeEmailConsumer: await self.process_message(message) class ResetEmailConsumer: - + def __init__(self) -> None: self.channel = None self.queue = None - - + self.daemon = ResetEmailSender() + + async def setup(self) -> None: self.channel = await rabbitmq_client.get_channel() await self.channel.set_qos(prefetch_count=10) self.queue = await self.channel.get_queue("queue_reset_email") - + async def process_message(self, message) -> None: message_id_ctx.set(str(uuid4())) - async with message.process(): + async with message.process(ignore_processed=True): data = json.loads(message.body) daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}") + try: + await self.daemon.send_email(data.get("email"), data.get("temp_password")) + except ( + smtplib.SMTPServerDisconnected, + smtplib.SMTPConnectError, + TimeoutError, + ConnectionRefusedError, + ) as exc: + daemon_logger.exception(f"transient error, retrying: {exc!r}") + await message.nack(requeue=True) + except Exception as exc: # noqa: BLE001 + daemon_logger.exception(f"permanent error, sending to DLQ: {exc!r}") + await message.nack(requeue=False) async def start_consuming(self)->None: diff --git a/src/messaging/producers/producers.py b/src/messaging/producers/producers.py index 157c9e3..7a04f42 100644 --- a/src/messaging/producers/producers.py +++ b/src/messaging/producers/producers.py @@ -20,9 +20,9 @@ class EmailProducer: await self._publish({"email":email}, routing_key="email.welcome") - async def send_reset_email(self, email:str)->None: - - await self._publish({"email":email}, routing_key="email.reset") + async def send_reset_email(self, email: str, temp_password: str) -> None: + + await self._publish({"email": email, "temp_password": temp_password}, routing_key="email.reset") async def _publish(self,data:dict, routing_key:str)->None: diff --git a/src/service/email/email_reset.py b/src/service/email/email_reset.py index e69de29..2bf4453 100644 --- a/src/service/email/email_reset.py +++ b/src/service/email/email_reset.py @@ -0,0 +1,37 @@ +import smtplib +import ssl +from email.message import EmailMessage + +from src.models.configs_read.env import env_settings +from src.service.email.jinja_env import jinja_env + + +class ResetEmailSender: + + async def send_email(self, target_email: str, temp_password: str) -> None: + context = ssl.create_default_context() + with smtplib.SMTP_SSL( + env_settings.SMTP_SERVER, env_settings.EMAIL_PORT, context=context + ) as server: + server.login(env_settings.EMAIL_LOGIN, env_settings.EMAIL_PASSWORD) + msg = await self.build_email_message(target_email, temp_password) + server.send_message(msg) + + async def build_email_message(self, target_email: str, temp_password: str) -> EmailMessage: + template = jinja_env.get_template("reset.html") + html_body = template.render(temp_password=temp_password) + + text_body = ( + "Пароль от вашей учётной записи в «The DisExcel» был сброшен.\n\n" + f"Временный пароль: {temp_password}\n\n" + "Рекомендуем сменить его на свой сразу после входа в аккаунт. " + "Если вы не запрашивали сброс пароля, срочно свяжитесь с поддержкой." + ) + + msg = EmailMessage() + msg["to"] = target_email + msg["from"] = env_settings.EMAIL_LOGIN + msg["subject"] = "Пароль сброшен" + msg.set_content(text_body) + msg.add_alternative(html_body, subtype="html") + return msg diff --git a/src/service/email/templates/reset.html b/src/service/email/templates/reset.html index 55eb806..25ccd5a 100644 --- a/src/service/email/templates/reset.html +++ b/src/service/email/templates/reset.html @@ -28,7 +28,7 @@ Новый пароль - Тест1234! + {{ temp_password }} diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py index a39dd00..1e5afbe 100644 --- a/tests/e2e/test_auth.py +++ b/tests/e2e/test_auth.py @@ -34,16 +34,18 @@ class TestPermissions: assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code == 401 + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) - async def test_get_logout_positive(self,test_user_fixture, target_url:str)->None: + async def test_get_logout_positive(self, test_user_fixture, target_url: str) -> None: - session=test_user_fixture[0] - with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info: + session = test_user_fixture[0] + with allure.step("get_root"): response = await session.get(f"{target_url}/protected/logout") response.raise_for_status() - assert exc_info.value.response.status_code != 403 - assert exc_info.value.response.status_code != 401 + + assert response.status_code == 200 + class TestRedis: diff --git a/tests/unit/test_consumers.py b/tests/unit/test_consumers.py new file mode 100644 index 0000000..349e698 --- /dev/null +++ b/tests/unit/test_consumers.py @@ -0,0 +1,234 @@ +import asyncio +import json +import smtplib +from unittest.mock import AsyncMock, MagicMock + +import allure +import pytest + +import src.messaging.rabbitmq_client as rabbitmq_client_module +from src.messaging.consumers.consumers import ResetEmailConsumer, WelcomeEmailConsumer +from src.messaging.rabbitmq_client import RabbitMQClient + + +@pytest.mark.unit +class TestRabbitMQClient: + + @pytest.mark.parametrize("",[ + pytest.param(id="Connect_retries_then_succeeds") + ]) + async def test_connect_retries_then_succeeds(self, monkeypatch): + + with allure.step("Mocking connection to the rabbitmq"): + fake_connection = MagicMock() + fake_connection.is_closed = False + fake_connection.channel = AsyncMock(return_value=MagicMock()) + + mock_connect = AsyncMock( + side_effect=[ConnectionError(), ConnectionError(), fake_connection] + ) + monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect) + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) # чтобы не ждать реально + + with allure.step("Test client connection"): + client = RabbitMQClient() + await client.connect() + + assert mock_connect.await_count == 3 + assert client.connection is fake_connection + + + @pytest.mark.parametrize("",[ + pytest.param(id="test_connect_gives_up_after_5_attempts") + ]) + async def test_connect_gives_up_after_5_attempts(self, monkeypatch): + + with allure.step("Mocking connection to the rabbitmq"): + mock_connect = AsyncMock(side_effect=ConnectionError("still down")) + monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect) + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) + + with allure.step("Test client connection"): + client = RabbitMQClient() + + with pytest.raises(ConnectionError): + await client.connect() + + assert mock_connect.await_count == 5 + + + @pytest.mark.parametrize("",[ + pytest.param(id="test_get_channel_negative") + ]) + async def test_get_channel_negative(self, monkeypatch)->None: + + with allure.step("Mocking connection to the rabbitmq"): + fake_connection = MagicMock() + fake_connection.is_closed = False + fake_connection.channel = AsyncMock(return_value=None) + + mock_connect = AsyncMock(return_value=fake_connection) + monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect) + + with allure.step("Test client connection"): + client = RabbitMQClient() + + with pytest.raises(RuntimeError): + await client.get_channel() + + + @pytest.mark.parametrize("",[ + pytest.param(id="test_get_channel_positive") + ]) + async def test_get_channel_positive(self, monkeypatch)->None: + + with allure.step("Mocking connection to the rabbitmq"): + + fake_connection = MagicMock() + fake_connection.is_closed = False + fake_connection.channel = AsyncMock(return_value=MagicMock()) + + mock_connect = AsyncMock(return_value=fake_connection) + monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect) + + with allure.step("Test client connection"): + client = RabbitMQClient() + await client.get_channel() + + assert client.channel is fake_connection.channel.return_value + +def make_fake_message(body: dict, routing_key: str = "email.welcome") -> MagicMock: + """Строим 'фальшивое' RabbitMQ-сообщение вручную, без реального брокера.""" + message = MagicMock() + message.body = json.dumps(body).encode() + message.routing_key = routing_key + message.nack = AsyncMock() # запоминает, вызвали ли nack и с какими аргументами + + # message.process(...) в реальном aio_pika — async context manager, + # поэтому нужен объект с асинхронными __aenter__/__aexit__. + process_cm = MagicMock() + process_cm.__aenter__ = AsyncMock(return_value=None) + process_cm.__aexit__ = AsyncMock(return_value=False) + message.process = MagicMock(return_value=process_cm) + return message + + +@pytest.mark.unit +class TestEmailConsumers: + + @pytest.mark.parametrize("",[ + pytest.param(id="test_start_consuming_negative") + ]) + async def test_start_consuming_negative(self, monkeypatch)->None: + + with allure.step("Mocking connection to the rabbitmq"): + consumer = WelcomeEmailConsumer() + consumer.setup=AsyncMock() + + with allure.step("Test client connection"), pytest.raises(RuntimeError): + await consumer.start_consuming() + + + @pytest.mark.parametrize("error", [ + pytest.param(smtplib.SMTPServerDisconnected(), id="smtplib.SMTPServerDisconnected"), + pytest.param(smtplib.SMTPConnectError(421, "Service not available"), id="smtplib.SMTPConnectError"), + pytest.param(TimeoutError(), id="TimeoutError"), + pytest.param(ConnectionRefusedError(), id="ConnectionRefusedError"), + ]) + async def test_process_message_transient_errors_requeue(self, error) -> None: + consumer = WelcomeEmailConsumer() + consumer.daemon.send_email = AsyncMock(side_effect=error) + + message = make_fake_message({"email": "user@example.com"}) + await consumer.process_message(message) + + message.nack.assert_awaited_once_with(requeue=True) + + + @pytest.mark.parametrize("",[ + pytest.param(id="test_process_message_positive") + ]) + async def test_process_message_positive(self) -> None: + + with allure.step("Mocking dependencies"): + consumer = WelcomeEmailConsumer() + consumer.daemon.send_email = AsyncMock() + + with allure.step("Test process_message"): + message = make_fake_message({"email": "user@example.com"}) + await consumer.process_message(message) + + consumer.daemon.send_email.assert_awaited_once_with("user@example.com") + message.nack.assert_not_called() + + +@pytest.mark.unit +class TestResetEmailConsumer: + + @pytest.mark.parametrize("",[ + pytest.param(id="test_reset_start_consuming_negative") + ]) + async def test_start_consuming_negative(self, monkeypatch) -> None: + + with allure.step("Mocking connection to the rabbitmq"): + consumer = ResetEmailConsumer() + consumer.setup = AsyncMock() + + with allure.step("Test client connection"), pytest.raises(RuntimeError): + await consumer.start_consuming() + + + @pytest.mark.parametrize("error", [ + pytest.param(smtplib.SMTPServerDisconnected(), id="smtplib.SMTPServerDisconnected"), + pytest.param(smtplib.SMTPConnectError(421, "Service not available"), id="smtplib.SMTPConnectError"), + pytest.param(TimeoutError(), id="TimeoutError"), + pytest.param(ConnectionRefusedError(), id="ConnectionRefusedError"), + ]) + async def test_process_message_transient_errors_requeue(self, error) -> None: + consumer = ResetEmailConsumer() + consumer.daemon.send_email = AsyncMock(side_effect=error) + + message = make_fake_message( + {"email": "user@example.com", "temp_password": "Temp1234!"}, + routing_key="email.reset", + ) + await consumer.process_message(message) + + message.nack.assert_awaited_once_with(requeue=True) + + + @pytest.mark.parametrize("",[ + pytest.param(id="test_reset_process_message_permanent_error_goes_to_dlq") + ]) + async def test_process_message_permanent_error_goes_to_dlq(self) -> None: + consumer = ResetEmailConsumer() + consumer.daemon.send_email = AsyncMock(side_effect=ValueError("bad payload")) + + message = make_fake_message( + {"email": "user@example.com", "temp_password": "Temp1234!"}, + routing_key="email.reset", + ) + await consumer.process_message(message) + + message.nack.assert_awaited_once_with(requeue=False) + + + @pytest.mark.parametrize("",[ + pytest.param(id="test_reset_process_message_positive") + ]) + async def test_process_message_positive(self) -> None: + + with allure.step("Mocking dependencies"): + consumer = ResetEmailConsumer() + consumer.daemon.send_email = AsyncMock() + + with allure.step("Test process_message"): + message = make_fake_message( + {"email": "user@example.com", "temp_password": "Temp1234!"}, + routing_key="email.reset", + ) + await consumer.process_message(message) + + consumer.daemon.send_email.assert_awaited_once_with("user@example.com", "Temp1234!") + message.nack.assert_not_called() +