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()
+