tests, reset_password first steps
This commit is contained in:
+6
-1
@@ -58,7 +58,12 @@ omit = [
|
|||||||
"__init__.py",
|
"__init__.py",
|
||||||
"*/docker/*",
|
"*/docker/*",
|
||||||
"*/rate_limit.py",
|
"*/rate_limit.py",
|
||||||
"*/logger.py"
|
"*/logger.py",
|
||||||
|
"*/daemons/*",
|
||||||
|
"*/topology_setup.py",
|
||||||
|
"*/logging/*",
|
||||||
|
"*/email_reset.py",
|
||||||
|
"*/email_welcome.py"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from uuid import uuid4
|
|||||||
from src.logging import daemon_logger
|
from src.logging import daemon_logger
|
||||||
from src.logging.logger import message_id_ctx
|
from src.logging.logger import message_id_ctx
|
||||||
from src.messaging.rabbitmq_client import rabbitmq_client
|
from src.messaging.rabbitmq_client import rabbitmq_client
|
||||||
|
from src.service.email.email_reset import ResetEmailSender
|
||||||
from src.service.email.email_welcome import DaemonEmailSender
|
from src.service.email.email_welcome import DaemonEmailSender
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ class ResetEmailConsumer:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.channel = None
|
self.channel = None
|
||||||
self.queue = None
|
self.queue = None
|
||||||
|
self.daemon = ResetEmailSender()
|
||||||
|
|
||||||
|
|
||||||
async def setup(self) -> None:
|
async def setup(self) -> None:
|
||||||
@@ -69,9 +71,22 @@ class ResetEmailConsumer:
|
|||||||
|
|
||||||
async def process_message(self, message) -> None:
|
async def process_message(self, message) -> None:
|
||||||
message_id_ctx.set(str(uuid4()))
|
message_id_ctx.set(str(uuid4()))
|
||||||
async with message.process():
|
async with message.process(ignore_processed=True):
|
||||||
data = json.loads(message.body)
|
data = json.loads(message.body)
|
||||||
daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}")
|
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:
|
async def start_consuming(self)->None:
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ class EmailProducer:
|
|||||||
|
|
||||||
await self._publish({"email":email}, routing_key="email.welcome")
|
await self._publish({"email":email}, routing_key="email.welcome")
|
||||||
|
|
||||||
async def send_reset_email(self, email:str)->None:
|
async def send_reset_email(self, email: str, temp_password: str) -> None:
|
||||||
|
|
||||||
await self._publish({"email":email}, routing_key="email.reset")
|
await self._publish({"email": email, "temp_password": temp_password}, routing_key="email.reset")
|
||||||
|
|
||||||
async def _publish(self,data:dict, routing_key:str)->None:
|
async def _publish(self,data:dict, routing_key:str)->None:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px; text-align:center;">
|
<td style="padding:20px; text-align:center;">
|
||||||
<span style="display:block; margin:0 0 4px 0; color:#9ca3af; font-size:12px; text-transform:uppercase; letter-spacing:0.05em;">Новый пароль</span>
|
<span style="display:block; margin:0 0 4px 0; color:#9ca3af; font-size:12px; text-transform:uppercase; letter-spacing:0.05em;">Новый пароль</span>
|
||||||
<span style="display:inline-block; font-family: 'Courier New', monospace; font-size:20px; font-weight:700; color:#111827; letter-spacing:0.05em;">Тест1234!</span>
|
<span style="display:inline-block; font-family: 'Courier New', monospace; font-size:20px; font-weight:700; color:#111827; letter-spacing:0.05em;">{{ temp_password }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ class TestPermissions:
|
|||||||
assert exc_info.value.response.status_code != 403
|
assert exc_info.value.response.status_code != 403
|
||||||
assert exc_info.value.response.status_code == 401
|
assert exc_info.value.response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
|
@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]
|
session = test_user_fixture[0]
|
||||||
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
|
with allure.step("get_root"):
|
||||||
|
|
||||||
response = await session.get(f"{target_url}/protected/logout")
|
response = await session.get(f"{target_url}/protected/logout")
|
||||||
response.raise_for_status()
|
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:
|
class TestRedis:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
Reference in New Issue
Block a user