From 2c6552cc04ec16123c054c94fe95a573cad31a66 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Wed, 31 Dec 2025 14:15:29 +0530 Subject: [PATCH 01/10] test(email_sender): Testing complete for get_email_sender --- app/conftest.py | 23 +++++++++++++++++++++++ app/core/tests/test_email_sender.py | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 app/core/tests/test_email_sender.py diff --git a/app/conftest.py b/app/conftest.py index ab9f0ef..6d63414 100644 --- a/app/conftest.py +++ b/app/conftest.py @@ -188,3 +188,26 @@ async def authenticated_admin_fixture(fastapi_app_fixture: FastAPI, db_fixture: user, auth_user = await get_auth_user(UserType.ADMIN, db_fixture) fastapi_app_fixture.dependency_overrides[get_current_user] = lambda: auth_user yield user + + +@pytest.fixture +def email_templates(tmp_path: Path) -> tuple[Path, Path]: + html_template = tmp_path / "test.mjml" + text_template = tmp_path / "test.txt" + + _ = html_template.write_text(""" + + + Hello {{ name}} + + + """) + + _ = text_template.write_text("Hello {{ name }}") + + return html_template, text_template + + +@pytest.fixture +def fake_settings(settings_fixture: Settings) -> Settings: + return settings_fixture diff --git a/app/core/tests/test_email_sender.py b/app/core/tests/test_email_sender.py new file mode 100644 index 0000000..2aae6c9 --- /dev/null +++ b/app/core/tests/test_email_sender.py @@ -0,0 +1,24 @@ +import pytest +# from pathlib import Path + +from app.core.email_sender import LocalEmailSender, SmtpEmailSender, get_email_sender +from app.core.settings import SettingsDep + + +class TestEmailSender: + def test_get_email_sender_returns_local_sender(self, settings_fixture: SettingsDep): + settings_fixture.email_sender_type = "local" + sender = get_email_sender(settings_fixture) + assert isinstance(sender, LocalEmailSender) + assert sender.settings == settings_fixture + + def test_get_email_sender_returns_smtp_sender(self, settings_fixture: SettingsDep): + settings_fixture.email_sender_type = "smtp" + sender = get_email_sender(settings_fixture) + assert isinstance(sender, SmtpEmailSender) + assert sender.settings == settings_fixture + + def test_get_email_sender_raises_not_implemented_error(self, settings_fixture: SettingsDep): + settings_fixture.email_sender_type = "invalid" # type: ignore + with pytest.raises(NotImplementedError, match="Email sender type 'invalid' is not implemented."): + _ = get_email_sender(settings_fixture) From e8f71acf362c423f09d800fc81fac6fdc728d177 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Thu, 1 Jan 2026 09:17:36 +0530 Subject: [PATCH 02/10] test: Email sender testing completed --- app/core/tests/test_email_sender.py | 232 +++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 2 deletions(-) diff --git a/app/core/tests/test_email_sender.py b/app/core/tests/test_email_sender.py index 2aae6c9..d9c3b7f 100644 --- a/app/core/tests/test_email_sender.py +++ b/app/core/tests/test_email_sender.py @@ -1,10 +1,40 @@ +import logging import pytest -# from pathlib import Path +from pathlib import Path +from pydantic import EmailStr +from typing import Callable +from unittest.mock import patch -from app.core.email_sender import LocalEmailSender, SmtpEmailSender, get_email_sender +from app.core.email_sender import Email, LocalEmailSender, SmtpEmailSender, get_email_sender from app.core.settings import SettingsDep +EmailFactory = Callable[..., Email] + + +@pytest.fixture +def email_factory(email_templates: tuple[Path, Path]): + """Returns a function that creates Email objects""" + html_template, text_template = email_templates + + def _create_email( + sender: str = "test@testmail.com", + receivers: list[EmailStr] | None = None, + subject: str = "Test", + template_data: dict[str, object] | None = None, + ): + return Email( + sender=sender, + receivers=receivers or ["receiver@testemail.com"], + subject=subject, + body_html_template=html_template, + body_text_template=text_template, + template_data=template_data or {"name": "TestUser"}, + ) + + return _create_email + + class TestEmailSender: def test_get_email_sender_returns_local_sender(self, settings_fixture: SettingsDep): settings_fixture.email_sender_type = "local" @@ -22,3 +52,201 @@ def test_get_email_sender_raises_not_implemented_error(self, settings_fixture: S settings_fixture.email_sender_type = "invalid" # type: ignore with pytest.raises(NotImplementedError, match="Email sender type 'invalid' is not implemented."): _ = get_email_sender(settings_fixture) + + +class TestLocalEmailSender: + @pytest.mark.asyncio + async def test_send_email_logs_and_returns_placeholder( + self, + settings_fixture: SettingsDep, + email_factory: EmailFactory, + caplog: pytest.LogCaptureFixture, + ): + caplog.set_level(logging.DEBUG) + settings_fixture.email_sender_type = "local" + sender = LocalEmailSender(settings_fixture) + email = email_factory(subject="Test") + message_id = await sender.send_email(email) + + assert message_id == "local-message-id-placeholder" + assert "Simulated sending email to" in caplog.text + assert "receiver@testemail.com" in caplog.text + assert "Test" in caplog.text + + @pytest.mark.asyncio + async def test_send_email_with_multiple_receivers( + self, settings_fixture: SettingsDep, caplog: pytest.LogCaptureFixture, email_factory: EmailFactory + ): + settings_fixture.email_sender_type = "local" + sender = LocalEmailSender(settings_fixture) + email = email_factory(receivers=["recipient1@testemail.com", "recipient2@testemail.com"]) + message_id = await sender.send_email(email) + + assert message_id == "local-message-id-placeholder" + assert "recipient1@testemail.com" in caplog.text + assert "recipient2@testemail.com" in caplog.text + + +class TestEmailSenderRender: + def test_render_text_template_with_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, text_template = email_templates + + result = sender.render("text", text_template, {"name": "John"}) + assert "Hello John" in result + + def test_render_text_template_empty_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, text_template = email_templates + + result = sender.render("text", text_template, {}) + assert "Hello" in result + + def test_render_html_template_with_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + html_template, _ = email_templates + + result = sender.render("html", html_template, {"name": "Alice"}) + assert "Hello Alice" in result + + def test_render_with_none_template_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, text_template = email_templates + + result = sender.render("text", text_template, None) + assert isinstance(result, str) + assert "Hello" in result + + def test_render_preserves_template_variables_without_data(self, settings_fixture: SettingsDep, tmp_path: Path): + sender = LocalEmailSender(settings_fixture) + text_template = tmp_path / "test_vars.txt" + _ = text_template.write_text("Hello {{ name }}, your email is {{ email }}") + + result = sender.render("text", text_template, None) + assert "Hello" in result + + def test_render_complex_template_data(self, settings_fixture: SettingsDep, tmp_path: Path): + sender = LocalEmailSender(settings_fixture) + text_template = tmp_path / "complex.txt" + _ = text_template.write_text("User: {{ user.name }}, Email: {{ user.email }}") + + result = sender.render("text", text_template, {"user": {"name": "Bob", "email": "bob@example.com"}}) + assert "User: Bob" in result + assert "bob@example.com" in result + + +class TestSmtpEmailSender: + # def test_smtp_sender_initializes_with_settings(self, settings_fixture: SettingsDep): + # settings_fixture.email_sender_type = "smtp" + # sender = SmtpEmailSender(settings_fixture) + # assert sender.settings == settings_fixture + + def test_render_methods_are_available(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + """Verify SmtpEmailSender inherits render method from EmailSender""" + sender = SmtpEmailSender(settings_fixture) + _, text_template = email_templates + result = sender.render("text", text_template, {"name": "Test"}) + assert "Hello Test" in result + + @pytest.mark.asyncio + async def test_send_email_creates_valid_message_structure( + self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + ): + """Test that send_email raises when SMTP connection fails""" + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_host", "invalid-host-xyz") + monkeypatch.setattr(settings_fixture, "email_smtp_port", 9595) + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) + monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", False) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp: + mock_smtp.side_effect = Exception("Simulated Connection Error") + with pytest.raises(Exception, match="Simulated Connection Error"): + _ = await sender.send_email(email) + + @pytest.mark.asyncio + async def test_send_email_logs_error_on_failure( + self, + settings_fixture: SettingsDep, + email_factory: EmailFactory, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + ): + """Test that SMTP errors are logged""" + caplog.set_level(logging.ERROR) + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp: + mock_smtp.side_effect = Exception("Major Network Fail") + with pytest.raises(Exception): + _ = await sender.send_email(email) + assert "Failed to send email via SMTP" in caplog.text + assert "Major Network Fail" in caplog.text + + @pytest.mark.asyncio + async def test_send_email_with_attachments( + self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + ): + """Tests that attachments are correctly added to the MIME message""" + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) + monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", False) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + email.attachments = {"report.pdf": b"%PDF-1.4 content..."} + + with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp_cls: + mock_instance = mock_smtp_cls.return_value + _ = await sender.send_email(email) + sent_message = mock_instance.send_message.call_args[0][0] + attachment_found = False + for part in sent_message.walk(): + if part.get_filename() == "report.pdf": + attachment_found = True + assert part["Content-Disposition"] == 'attachment; filename="report.pdf"' + assert attachment_found, "Attachment part was not found in the MIME message" + + @pytest.mark.asyncio + async def test_send_email_flow_with_tls_and_auth( + self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + ): + """Tests the TLS upgrade and Login flow""" + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.example.com") + monkeypatch.setattr(settings_fixture, "email_smtp_port", 587) + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) + monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", True) + monkeypatch.setattr(settings_fixture, "email_smtp_username", "testuser") + monkeypatch.setattr(settings_fixture, "email_smtp_password", "testpass") + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp_cls: + mock_instance = mock_smtp_cls.return_value + _ = await sender.send_email(email) + + mock_smtp_cls.assert_called_with("smtp.example.com", 587) # Correct Host/Port + mock_instance.starttls.assert_called_once() # TLS was started + mock_instance.login.assert_called_once_with("testuser", "testpass") # Login called + mock_instance.send_message.assert_called_once() # Message sent + mock_instance.quit.assert_called_once() # Connection closed + + @pytest.mark.asyncio + async def test_send_email_flow_with_ssl( + self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + ): + """Tests the SSL connection flow""" + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.secure.com") + monkeypatch.setattr(settings_fixture, "email_smtp_port", 465) + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", True) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + + with patch("app.core.email_sender.smtplib.SMTP_SSL") as mock_smtp_ssl_cls: + mock_instance = mock_smtp_ssl_cls.return_value + _ = await sender.send_email(email) + mock_smtp_ssl_cls.assert_called_with("smtp.secure.com", 465) + mock_instance.send_message.assert_called_once() + mock_instance.quit.assert_called_once() From 8a6cd38c0fd898436f3e1539b46c419f75321500 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Thu, 1 Jan 2026 12:57:13 +0530 Subject: [PATCH 03/10] test: completed unit tests for email_sender --- app/core/tests/test_email_sender.py | 451 +++++++++++++++------------- 1 file changed, 250 insertions(+), 201 deletions(-) diff --git a/app/core/tests/test_email_sender.py b/app/core/tests/test_email_sender.py index d9c3b7f..dcdc6e1 100644 --- a/app/core/tests/test_email_sender.py +++ b/app/core/tests/test_email_sender.py @@ -2,10 +2,10 @@ import pytest from pathlib import Path from pydantic import EmailStr -from typing import Callable -from unittest.mock import patch +from typing import Callable, override +from unittest.mock import MagicMock -from app.core.email_sender import Email, LocalEmailSender, SmtpEmailSender, get_email_sender +from app.core.email_sender import Email, EmailSender, LocalEmailSender, SmtpEmailSender, get_email_sender from app.core.settings import SettingsDep @@ -14,7 +14,6 @@ @pytest.fixture def email_factory(email_templates: tuple[Path, Path]): - """Returns a function that creates Email objects""" html_template, text_template = email_templates def _create_email( @@ -35,218 +34,268 @@ def _create_email( return _create_email -class TestEmailSender: - def test_get_email_sender_returns_local_sender(self, settings_fixture: SettingsDep): - settings_fixture.email_sender_type = "local" - sender = get_email_sender(settings_fixture) - assert isinstance(sender, LocalEmailSender) - assert sender.settings == settings_fixture - - def test_get_email_sender_returns_smtp_sender(self, settings_fixture: SettingsDep): - settings_fixture.email_sender_type = "smtp" - sender = get_email_sender(settings_fixture) - assert isinstance(sender, SmtpEmailSender) - assert sender.settings == settings_fixture - - def test_get_email_sender_raises_not_implemented_error(self, settings_fixture: SettingsDep): - settings_fixture.email_sender_type = "invalid" # type: ignore - with pytest.raises(NotImplementedError, match="Email sender type 'invalid' is not implemented."): - _ = get_email_sender(settings_fixture) - - -class TestLocalEmailSender: - @pytest.mark.asyncio - async def test_send_email_logs_and_returns_placeholder( - self, - settings_fixture: SettingsDep, - email_factory: EmailFactory, - caplog: pytest.LogCaptureFixture, - ): - caplog.set_level(logging.DEBUG) - settings_fixture.email_sender_type = "local" - sender = LocalEmailSender(settings_fixture) - email = email_factory(subject="Test") - message_id = await sender.send_email(email) - - assert message_id == "local-message-id-placeholder" - assert "Simulated sending email to" in caplog.text - assert "receiver@testemail.com" in caplog.text - assert "Test" in caplog.text - - @pytest.mark.asyncio - async def test_send_email_with_multiple_receivers( - self, settings_fixture: SettingsDep, caplog: pytest.LogCaptureFixture, email_factory: EmailFactory - ): - settings_fixture.email_sender_type = "local" - sender = LocalEmailSender(settings_fixture) - email = email_factory(receivers=["recipient1@testemail.com", "recipient2@testemail.com"]) - message_id = await sender.send_email(email) +# Test get_email_sender dependency injection for EmailSender +# ---------------------------------------------------------------------------------------------------------------------- - assert message_id == "local-message-id-placeholder" - assert "recipient1@testemail.com" in caplog.text - assert "recipient2@testemail.com" in caplog.text +def test_get_email_sender_returns_local_sender_when_email_sender_type_is_local(settings_fixture: SettingsDep): + settings_fixture.email_sender_type = "local" + sender = get_email_sender(settings_fixture) + assert isinstance(sender, LocalEmailSender) + assert sender.settings == settings_fixture -class TestEmailSenderRender: - def test_render_text_template_with_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): - sender = LocalEmailSender(settings_fixture) - _, text_template = email_templates - result = sender.render("text", text_template, {"name": "John"}) - assert "Hello John" in result +def test_get_email_sender_returns_smtp_sender_when_email_sender_type_is_smtp(settings_fixture: SettingsDep): + settings_fixture.email_sender_type = "smtp" + sender = get_email_sender(settings_fixture) + assert isinstance(sender, SmtpEmailSender) + assert sender.settings == settings_fixture - def test_render_text_template_empty_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): - sender = LocalEmailSender(settings_fixture) - _, text_template = email_templates - result = sender.render("text", text_template, {}) - assert "Hello" in result +def test_get_email_sender_raises_not_implemented_error_for_invalid_email_sender_types(settings_fixture: SettingsDep): + settings_fixture.email_sender_type = "invalid" # type: ignore + with pytest.raises(NotImplementedError, match="Email sender type 'invalid' is not implemented."): + _ = get_email_sender(settings_fixture) - def test_render_html_template_with_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): - sender = LocalEmailSender(settings_fixture) - html_template, _ = email_templates - result = sender.render("html", html_template, {"name": "Alice"}) - assert "Hello Alice" in result +@pytest.mark.asyncio +async def test_base_email_sender_raises_not_implemented(settings_fixture: SettingsDep, email_factory: EmailFactory): + class ConcreteSender(EmailSender): + @override + async def send_email(self, email: Email) -> str: + return await super().send_email(email) # type: ignore - def test_render_with_none_template_data(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): - sender = LocalEmailSender(settings_fixture) - _, text_template = email_templates + sender = ConcreteSender(settings_fixture) + email = email_factory() - result = sender.render("text", text_template, None) - assert isinstance(result, str) - assert "Hello" in result + with pytest.raises(NotImplementedError): + _ = await sender.send_email(email) - def test_render_preserves_template_variables_without_data(self, settings_fixture: SettingsDep, tmp_path: Path): - sender = LocalEmailSender(settings_fixture) - text_template = tmp_path / "test_vars.txt" - _ = text_template.write_text("Hello {{ name }}, your email is {{ email }}") - result = sender.render("text", text_template, None) - assert "Hello" in result +def test_render_logs_error_and_returns_raw_content_on_mjml_failure( + settings_fixture: SettingsDep, + email_templates: tuple[Path, Path], + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +): + sender = LocalEmailSender(settings_fixture) + html_template, _ = email_templates + mock_mjml = MagicMock(side_effect=Exception("Simulated MJML Failure")) + monkeypatch.setattr("app.core.email_sender.mjml2html", mock_mjml) + result = sender.render("html", html_template, {"name": "ErrorTest"}) - def test_render_complex_template_data(self, settings_fixture: SettingsDep, tmp_path: Path): - sender = LocalEmailSender(settings_fixture) - text_template = tmp_path / "complex.txt" - _ = text_template.write_text("User: {{ user.name }}, Email: {{ user.email }}") + assert "Failed to compile MJML to HTML" in caplog.text + assert "Hello ErrorTest" in result - result = sender.render("text", text_template, {"user": {"name": "Bob", "email": "bob@example.com"}}) - assert "User: Bob" in result - assert "bob@example.com" in result +# Test LocalEmailSender send_email which logs emails +# ---------------------------------------------------------------------------------------------------------------------- -class TestSmtpEmailSender: - # def test_smtp_sender_initializes_with_settings(self, settings_fixture: SettingsDep): - # settings_fixture.email_sender_type = "smtp" - # sender = SmtpEmailSender(settings_fixture) - # assert sender.settings == settings_fixture - def test_render_methods_are_available(self, settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): - """Verify SmtpEmailSender inherits render method from EmailSender""" - sender = SmtpEmailSender(settings_fixture) - _, text_template = email_templates - result = sender.render("text", text_template, {"name": "Test"}) - assert "Hello Test" in result +@pytest.mark.asyncio +async def test_send_email_logs_details_and_returns_placeholder( + settings_fixture: SettingsDep, + email_factory: EmailFactory, + caplog: pytest.LogCaptureFixture, +): + caplog.set_level(logging.DEBUG) + settings_fixture.email_sender_type = "local" + sender = LocalEmailSender(settings_fixture) + email = email_factory(subject="Test") + message_id = await sender.send_email(email) - @pytest.mark.asyncio - async def test_send_email_creates_valid_message_structure( - self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch - ): - """Test that send_email raises when SMTP connection fails""" - monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") - monkeypatch.setattr(settings_fixture, "email_smtp_host", "invalid-host-xyz") - monkeypatch.setattr(settings_fixture, "email_smtp_port", 9595) - monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) - monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", False) - sender = SmtpEmailSender(settings_fixture) - email = email_factory() - with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp: - mock_smtp.side_effect = Exception("Simulated Connection Error") - with pytest.raises(Exception, match="Simulated Connection Error"): - _ = await sender.send_email(email) - - @pytest.mark.asyncio - async def test_send_email_logs_error_on_failure( - self, - settings_fixture: SettingsDep, - email_factory: EmailFactory, - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, - ): - """Test that SMTP errors are logged""" - caplog.set_level(logging.ERROR) - monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") - sender = SmtpEmailSender(settings_fixture) - email = email_factory() - with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp: - mock_smtp.side_effect = Exception("Major Network Fail") - with pytest.raises(Exception): - _ = await sender.send_email(email) - assert "Failed to send email via SMTP" in caplog.text - assert "Major Network Fail" in caplog.text - - @pytest.mark.asyncio - async def test_send_email_with_attachments( - self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch - ): - """Tests that attachments are correctly added to the MIME message""" - monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") - monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) - monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", False) - sender = SmtpEmailSender(settings_fixture) - email = email_factory() - email.attachments = {"report.pdf": b"%PDF-1.4 content..."} - - with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp_cls: - mock_instance = mock_smtp_cls.return_value - _ = await sender.send_email(email) - sent_message = mock_instance.send_message.call_args[0][0] - attachment_found = False - for part in sent_message.walk(): - if part.get_filename() == "report.pdf": - attachment_found = True - assert part["Content-Disposition"] == 'attachment; filename="report.pdf"' - assert attachment_found, "Attachment part was not found in the MIME message" - - @pytest.mark.asyncio - async def test_send_email_flow_with_tls_and_auth( - self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch - ): - """Tests the TLS upgrade and Login flow""" - monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") - monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.example.com") - monkeypatch.setattr(settings_fixture, "email_smtp_port", 587) - monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) - monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", True) - monkeypatch.setattr(settings_fixture, "email_smtp_username", "testuser") - monkeypatch.setattr(settings_fixture, "email_smtp_password", "testpass") - sender = SmtpEmailSender(settings_fixture) - email = email_factory() - with patch("app.core.email_sender.smtplib.SMTP") as mock_smtp_cls: - mock_instance = mock_smtp_cls.return_value - _ = await sender.send_email(email) - - mock_smtp_cls.assert_called_with("smtp.example.com", 587) # Correct Host/Port - mock_instance.starttls.assert_called_once() # TLS was started - mock_instance.login.assert_called_once_with("testuser", "testpass") # Login called - mock_instance.send_message.assert_called_once() # Message sent - mock_instance.quit.assert_called_once() # Connection closed - - @pytest.mark.asyncio - async def test_send_email_flow_with_ssl( - self, settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch - ): - """Tests the SSL connection flow""" - monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") - monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.secure.com") - monkeypatch.setattr(settings_fixture, "email_smtp_port", 465) - monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", True) - sender = SmtpEmailSender(settings_fixture) - email = email_factory() - - with patch("app.core.email_sender.smtplib.SMTP_SSL") as mock_smtp_ssl_cls: - mock_instance = mock_smtp_ssl_cls.return_value - _ = await sender.send_email(email) - mock_smtp_ssl_cls.assert_called_with("smtp.secure.com", 465) - mock_instance.send_message.assert_called_once() - mock_instance.quit.assert_called_once() + assert message_id == "local-message-id-placeholder" + assert "Simulated sending email to" in caplog.text + assert "receiver@testemail.com" in caplog.text + assert "Test" in caplog.text + + +@pytest.mark.asyncio +async def test_send_email_with_multiple_receivers_logs_all_recipients( + settings_fixture: SettingsDep, caplog: pytest.LogCaptureFixture, email_factory: EmailFactory +): + settings_fixture.email_sender_type = "local" + sender = LocalEmailSender(settings_fixture) + email = email_factory(receivers=["recipient1@testemail.com", "recipient2@testemail.com"]) + message_id = await sender.send_email(email) + + assert message_id == "local-message-id-placeholder" + assert "recipient1@testemail.com" in caplog.text + assert "recipient2@testemail.com" in caplog.text + + +# Test LocalEmailSender render which renders an email template of the given type +# ---------------------------------------------------------------------------------------------------------------------- + + +def test_render_text_template_with_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, text_template = email_templates + result = sender.render("text", text_template, {"name": "John"}) + + assert "Hello John" in result + + +def test_render_text_template_empty_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, text_template = email_templates + result = sender.render("text", text_template, {}) + + assert "Hello" in result + + +def test_render_html_template_with_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + html_template, _ = email_templates + result = sender.render("html", html_template, {"name": "Alice"}) + + assert "Hello Alice" in result + + +def test_render_with_none_template_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, text_template = email_templates + result = sender.render("text", text_template, None) + + assert isinstance(result, str) + assert "Hello" in result + + +def test_render_preserves_template_variables_without_data(settings_fixture: SettingsDep, tmp_path: Path): + sender = LocalEmailSender(settings_fixture) + text_template = tmp_path / "test_vars.txt" + _ = text_template.write_text("Hello {{ name }}, your email is {{ email }}") + result = sender.render("text", text_template, None) + + assert "Hello" in result + + +def test_render_complex_template_data(settings_fixture: SettingsDep, tmp_path: Path): + sender = LocalEmailSender(settings_fixture) + text_template = tmp_path / "complex.txt" + _ = text_template.write_text("User: {{ user.name }}, Email: {{ user.email }}") + result = sender.render("text", text_template, {"user": {"name": "Bob", "email": "bob@example.com"}}) + + assert "User: Bob" in result + assert "bob@example.com" in result + + +def test_render_methods_are_available(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): + """Verify SmtpEmailSender inherits render method from EmailSender""" + sender = SmtpEmailSender(settings_fixture) + _, text_template = email_templates + result = sender.render("text", text_template, {"name": "Test"}) + + assert "Hello Test" in result + + +# Test SmtpEmailSender +# ---------------------------------------------------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_email_raises_exception_when_smtp_connection_fails( + settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_host", "invalid-host-xyz") + monkeypatch.setattr(settings_fixture, "email_smtp_port", 9595) + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) + monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", False) + mock_smtp_cls = MagicMock(side_effect=Exception("Simulated Connection Error")) + monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + with pytest.raises(Exception, match="Simulated Connection Error"): + _ = await sender.send_email(email) + + +@pytest.mark.asyncio +async def test_send_email_logs_network_error_during_sending( + settings_fixture: SettingsDep, + email_factory: EmailFactory, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +): + caplog.set_level(logging.ERROR) + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + mock_smtp_cls = MagicMock(side_effect=Exception("Major Network Fail")) + monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + with pytest.raises(Exception): + _ = await sender.send_email(email) + + assert "Failed to send email via SMTP" in caplog.text + assert "Major Network Fail" in caplog.text + + +@pytest.mark.asyncio +async def test_send_email_with_attachments_gets_added_to_the_MIME_multipart( + settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) + monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", False) + mock_smtp_cls = MagicMock() + mock_instance = mock_smtp_cls.return_value + monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + email.attachments = {"report.pdf": b"%PDF-1.4 content..."} + _ = await sender.send_email(email) + + mock_instance.send_message.assert_called_once() + sent_message = mock_instance.send_message.call_args[0][0] + attachment_found = False + for part in sent_message.walk(): + if part.get_filename() == "report.pdf": + attachment_found = True + assert part["Content-Disposition"] == 'attachment; filename="report.pdf"' + assert attachment_found, "Attachment part was not found in the MIME message" + + +@pytest.mark.asyncio +async def test_send_email_follows_correct_flow_for_tls_connection_with_login( + settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.example.com") + monkeypatch.setattr(settings_fixture, "email_smtp_port", 587) + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) + monkeypatch.setattr(settings_fixture, "email_smtp_use_tls", True) + monkeypatch.setattr(settings_fixture, "email_smtp_username", "testuser") + monkeypatch.setattr(settings_fixture, "email_smtp_password", "testpass") + mock_smtp_cls = MagicMock() + mock_instance = mock_smtp_cls.return_value + monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + _ = await sender.send_email(email) + + mock_smtp_cls.assert_called_with("smtp.example.com", 587) + mock_instance.starttls.assert_called_once() + mock_instance.login.assert_called_once_with("testuser", "testpass") + mock_instance.send_message.assert_called_once() + mock_instance.quit.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_email_follows_correct_flow_for_ssl( + settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") + monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.secure.com") + monkeypatch.setattr(settings_fixture, "email_smtp_port", 465) + monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", True) + mock_smtp_cls = MagicMock() + mock_instance = mock_smtp_cls.return_value + monkeypatch.setattr("app.core.email_sender.smtplib.SMTP_SSL", mock_smtp_cls) + sender = SmtpEmailSender(settings_fixture) + email = email_factory() + _ = await sender.send_email(email) + mock_instance = mock_smtp_cls.return_value + + mock_smtp_cls.assert_called_with("smtp.secure.com", 465) + mock_instance.send_message.assert_called_once() + mock_instance.quit.assert_called_once() From 3902f7e7d33fbecdab27f02a33443161cdf6e72e Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Thu, 1 Jan 2026 15:48:46 +0530 Subject: [PATCH 04/10] test: code refractor and fixed the requested changes --- app/conftest.py | 23 ------ app/core/tests/test_email_sender.py | 124 ++++++++++++++-------------- app/fixtures/email_factory.py | 14 ++++ 3 files changed, 76 insertions(+), 85 deletions(-) create mode 100644 app/fixtures/email_factory.py diff --git a/app/conftest.py b/app/conftest.py index 1c9933f..9da6513 100644 --- a/app/conftest.py +++ b/app/conftest.py @@ -189,26 +189,3 @@ async def authenticated_admin_fixture(fastapi_app_fixture: FastAPI, db_fixture: user, auth_user = await get_auth_user(UserType.ADMIN, db_fixture) fastapi_app_fixture.dependency_overrides[get_current_user] = lambda: auth_user yield user - - -@pytest.fixture -def email_templates(tmp_path: Path) -> tuple[Path, Path]: - html_template = tmp_path / "test.mjml" - text_template = tmp_path / "test.txt" - - _ = html_template.write_text(""" - - - Hello {{ name}} - - - """) - - _ = text_template.write_text("Hello {{ name }}") - - return html_template, text_template - - -@pytest.fixture -def fake_settings(settings_fixture: Settings) -> Settings: - return settings_fixture diff --git a/app/core/tests/test_email_sender.py b/app/core/tests/test_email_sender.py index dcdc6e1..7d6f1ad 100644 --- a/app/core/tests/test_email_sender.py +++ b/app/core/tests/test_email_sender.py @@ -1,37 +1,29 @@ import logging import pytest from pathlib import Path -from pydantic import EmailStr -from typing import Callable, override from unittest.mock import MagicMock -from app.core.email_sender import Email, EmailSender, LocalEmailSender, SmtpEmailSender, get_email_sender +from app.core.email_sender import EmailSender, LocalEmailSender, SmtpEmailSender, get_email_sender from app.core.settings import SettingsDep +from app.fixtures.email_factory import EmailFactory -EmailFactory = Callable[..., Email] +@pytest.fixture +def email_templates_fixture(tmp_path: Path) -> tuple[Path, Path]: + html_template = tmp_path / "test.mjml" + text_template = tmp_path / "test.txt" + _ = html_template.write_text(""" + + + Hello {{ name}} + + + """) -@pytest.fixture -def email_factory(email_templates: tuple[Path, Path]): - html_template, text_template = email_templates - - def _create_email( - sender: str = "test@testmail.com", - receivers: list[EmailStr] | None = None, - subject: str = "Test", - template_data: dict[str, object] | None = None, - ): - return Email( - sender=sender, - receivers=receivers or ["receiver@testemail.com"], - subject=subject, - body_html_template=html_template, - body_text_template=text_template, - template_data=template_data or {"name": "TestUser"}, - ) - - return _create_email + _ = text_template.write_text("Hello {{ name }}") + + return html_template, text_template # Test get_email_sender dependency injection for EmailSender @@ -53,33 +45,31 @@ def test_get_email_sender_returns_smtp_sender_when_email_sender_type_is_smtp(set def test_get_email_sender_raises_not_implemented_error_for_invalid_email_sender_types(settings_fixture: SettingsDep): - settings_fixture.email_sender_type = "invalid" # type: ignore + settings_fixture.email_sender_type = "invalid" # pyright: ignore[reportAttributeAccessIssue] with pytest.raises(NotImplementedError, match="Email sender type 'invalid' is not implemented."): _ = get_email_sender(settings_fixture) @pytest.mark.asyncio -async def test_base_email_sender_raises_not_implemented(settings_fixture: SettingsDep, email_factory: EmailFactory): - class ConcreteSender(EmailSender): - @override - async def send_email(self, email: Email) -> str: - return await super().send_email(email) # type: ignore - - sender = ConcreteSender(settings_fixture) - email = email_factory() +async def test_base_email_sender_raises_not_implemented( + settings_fixture: SettingsDep, email_templates_fixture: tuple[Path, Path] +): + html_template, text_template = email_templates_fixture + email = EmailFactory.build(body_html_template=html_template, body_text_template=text_template) + mock_self = MagicMock() with pytest.raises(NotImplementedError): - _ = await sender.send_email(email) + _ = await EmailSender.send_email(mock_self, email) # pyright: ignore[reportAbstractUsage] def test_render_logs_error_and_returns_raw_content_on_mjml_failure( settings_fixture: SettingsDep, - email_templates: tuple[Path, Path], + email_templates_fixture: tuple[Path, Path], caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ): sender = LocalEmailSender(settings_fixture) - html_template, _ = email_templates + html_template, _ = email_templates_fixture mock_mjml = MagicMock(side_effect=Exception("Simulated MJML Failure")) monkeypatch.setattr("app.core.email_sender.mjml2html", mock_mjml) result = sender.render("html", html_template, {"name": "ErrorTest"}) @@ -95,28 +85,34 @@ def test_render_logs_error_and_returns_raw_content_on_mjml_failure( @pytest.mark.asyncio async def test_send_email_logs_details_and_returns_placeholder( settings_fixture: SettingsDep, - email_factory: EmailFactory, + email_templates_fixture: tuple[Path, Path], caplog: pytest.LogCaptureFixture, ): caplog.set_level(logging.DEBUG) settings_fixture.email_sender_type = "local" sender = LocalEmailSender(settings_fixture) - email = email_factory(subject="Test") + html_template, text_template = email_templates_fixture + email = EmailFactory.build(subject="Test", body_html_template=html_template, body_text_template=text_template) message_id = await sender.send_email(email) assert message_id == "local-message-id-placeholder" assert "Simulated sending email to" in caplog.text - assert "receiver@testemail.com" in caplog.text + assert "recipient@testemail.com" in caplog.text assert "Test" in caplog.text @pytest.mark.asyncio async def test_send_email_with_multiple_receivers_logs_all_recipients( - settings_fixture: SettingsDep, caplog: pytest.LogCaptureFixture, email_factory: EmailFactory + settings_fixture: SettingsDep, caplog: pytest.LogCaptureFixture, email_templates_fixture: tuple[Path, Path] ): settings_fixture.email_sender_type = "local" sender = LocalEmailSender(settings_fixture) - email = email_factory(receivers=["recipient1@testemail.com", "recipient2@testemail.com"]) + html_template, text_template = email_templates_fixture + email = EmailFactory.build( + receivers=["recipient1@testemail.com", "recipient2@testemail.com"], + body_html_template=html_template, + body_text_template=text_template, + ) message_id = await sender.send_email(email) assert message_id == "local-message-id-placeholder" @@ -128,33 +124,33 @@ async def test_send_email_with_multiple_receivers_logs_all_recipients( # ---------------------------------------------------------------------------------------------------------------------- -def test_render_text_template_with_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): +def test_render_text_template_with_data(settings_fixture: SettingsDep, email_templates_fixture: tuple[Path, Path]): sender = LocalEmailSender(settings_fixture) - _, text_template = email_templates + _, text_template = email_templates_fixture result = sender.render("text", text_template, {"name": "John"}) assert "Hello John" in result -def test_render_text_template_empty_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): +def test_render_text_template_empty_data(settings_fixture: SettingsDep, email_templates_fixture: tuple[Path, Path]): sender = LocalEmailSender(settings_fixture) - _, text_template = email_templates + _, text_template = email_templates_fixture result = sender.render("text", text_template, {}) assert "Hello" in result -def test_render_html_template_with_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): +def test_render_html_template_with_data(settings_fixture: SettingsDep, email_templates_fixture: tuple[Path, Path]): sender = LocalEmailSender(settings_fixture) - html_template, _ = email_templates + html_template, _ = email_templates_fixture result = sender.render("html", html_template, {"name": "Alice"}) assert "Hello Alice" in result -def test_render_with_none_template_data(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): +def test_render_with_none_template_data(settings_fixture: SettingsDep, email_templates_fixture: tuple[Path, Path]): sender = LocalEmailSender(settings_fixture) - _, text_template = email_templates + _, text_template = email_templates_fixture result = sender.render("text", text_template, None) assert isinstance(result, str) @@ -180,10 +176,9 @@ def test_render_complex_template_data(settings_fixture: SettingsDep, tmp_path: P assert "bob@example.com" in result -def test_render_methods_are_available(settings_fixture: SettingsDep, email_templates: tuple[Path, Path]): - """Verify SmtpEmailSender inherits render method from EmailSender""" +def test_render_methods_are_available(settings_fixture: SettingsDep, email_templates_fixture: tuple[Path, Path]): sender = SmtpEmailSender(settings_fixture) - _, text_template = email_templates + _, text_template = email_templates_fixture result = sender.render("text", text_template, {"name": "Test"}) assert "Hello Test" in result @@ -195,7 +190,7 @@ def test_render_methods_are_available(settings_fixture: SettingsDep, email_templ @pytest.mark.asyncio async def test_send_email_raises_exception_when_smtp_connection_fails( - settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + settings_fixture: SettingsDep, monkeypatch: pytest.MonkeyPatch, email_templates_fixture: tuple[Path, Path] ): monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") monkeypatch.setattr(settings_fixture, "email_smtp_host", "invalid-host-xyz") @@ -205,7 +200,8 @@ async def test_send_email_raises_exception_when_smtp_connection_fails( mock_smtp_cls = MagicMock(side_effect=Exception("Simulated Connection Error")) monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) sender = SmtpEmailSender(settings_fixture) - email = email_factory() + html_template, text_template = email_templates_fixture + email = EmailFactory.build(body_html_template=html_template, body_text_template=text_template) with pytest.raises(Exception, match="Simulated Connection Error"): _ = await sender.send_email(email) @@ -213,16 +209,17 @@ async def test_send_email_raises_exception_when_smtp_connection_fails( @pytest.mark.asyncio async def test_send_email_logs_network_error_during_sending( settings_fixture: SettingsDep, - email_factory: EmailFactory, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, + email_templates_fixture: tuple[Path, Path], ): caplog.set_level(logging.ERROR) monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") mock_smtp_cls = MagicMock(side_effect=Exception("Major Network Fail")) monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) sender = SmtpEmailSender(settings_fixture) - email = email_factory() + html_template, text_template = email_templates_fixture + email = EmailFactory.build(body_html_template=html_template, body_text_template=text_template) with pytest.raises(Exception): _ = await sender.send_email(email) @@ -232,7 +229,7 @@ async def test_send_email_logs_network_error_during_sending( @pytest.mark.asyncio async def test_send_email_with_attachments_gets_added_to_the_MIME_multipart( - settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + settings_fixture: SettingsDep, monkeypatch: pytest.MonkeyPatch, email_templates_fixture: tuple[Path, Path] ): monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") monkeypatch.setattr(settings_fixture, "email_smtp_use_ssl", False) @@ -241,7 +238,8 @@ async def test_send_email_with_attachments_gets_added_to_the_MIME_multipart( mock_instance = mock_smtp_cls.return_value monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) sender = SmtpEmailSender(settings_fixture) - email = email_factory() + html_template, text_template = email_templates_fixture + email = EmailFactory.build(body_html_template=html_template, body_text_template=text_template) email.attachments = {"report.pdf": b"%PDF-1.4 content..."} _ = await sender.send_email(email) @@ -257,7 +255,7 @@ async def test_send_email_with_attachments_gets_added_to_the_MIME_multipart( @pytest.mark.asyncio async def test_send_email_follows_correct_flow_for_tls_connection_with_login( - settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + settings_fixture: SettingsDep, monkeypatch: pytest.MonkeyPatch, email_templates_fixture: tuple[Path, Path] ): monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.example.com") @@ -270,7 +268,8 @@ async def test_send_email_follows_correct_flow_for_tls_connection_with_login( mock_instance = mock_smtp_cls.return_value monkeypatch.setattr("app.core.email_sender.smtplib.SMTP", mock_smtp_cls) sender = SmtpEmailSender(settings_fixture) - email = email_factory() + html_template, text_template = email_templates_fixture + email = EmailFactory.build(body_html_template=html_template, body_text_template=text_template) _ = await sender.send_email(email) mock_smtp_cls.assert_called_with("smtp.example.com", 587) @@ -282,7 +281,7 @@ async def test_send_email_follows_correct_flow_for_tls_connection_with_login( @pytest.mark.asyncio async def test_send_email_follows_correct_flow_for_ssl( - settings_fixture: SettingsDep, email_factory: EmailFactory, monkeypatch: pytest.MonkeyPatch + settings_fixture: SettingsDep, monkeypatch: pytest.MonkeyPatch, email_templates_fixture: tuple[Path, Path] ): monkeypatch.setattr(settings_fixture, "email_sender_type", "smtp") monkeypatch.setattr(settings_fixture, "email_smtp_host", "smtp.secure.com") @@ -292,7 +291,8 @@ async def test_send_email_follows_correct_flow_for_ssl( mock_instance = mock_smtp_cls.return_value monkeypatch.setattr("app.core.email_sender.smtplib.SMTP_SSL", mock_smtp_cls) sender = SmtpEmailSender(settings_fixture) - email = email_factory() + html_template, text_template = email_templates_fixture + email = EmailFactory.build(body_html_template=html_template, body_text_template=text_template) _ = await sender.send_email(email) mock_instance = mock_smtp_cls.return_value diff --git a/app/fixtures/email_factory.py b/app/fixtures/email_factory.py new file mode 100644 index 0000000..7370c40 --- /dev/null +++ b/app/fixtures/email_factory.py @@ -0,0 +1,14 @@ +import factory +from app.core.email_sender import Email + + +class EmailFactory(factory.Factory[Email]): + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + model = Email + + sender = "test@testemail.com" + receivers = ["recipient@testemail.com"] + subject = "Test" + template_data = {"name": "TestUser"} + body_html_template = None + body_text_template = None From d1fd0d3f9a472b902ba617de65200e5d96a02a86 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Fri, 2 Jan 2026 15:20:28 +0530 Subject: [PATCH 05/10] tests: Unit tests added for storage module --- app/core/tests/test_storage.py | 132 +++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 app/core/tests/test_storage.py diff --git a/app/core/tests/test_storage.py b/app/core/tests/test_storage.py new file mode 100644 index 0000000..45841db --- /dev/null +++ b/app/core/tests/test_storage.py @@ -0,0 +1,132 @@ +import pytest +from pathlib import Path +from unittest.mock import MagicMock +from fastapi import FastAPI, Request, UploadFile +from sqlalchemy_file import File + +from app.core.storage import Storage, setup_storage +from app.core.settings import Settings + + +@pytest.fixture +def mock_request(): + request = MagicMock(spec=Request) + request.base_url = "http://testserver/" + return request + + +@pytest.mark.asyncio +async def test_storage_prepare_reads_and_uploadfile_and_converts_to_sqlalchemy_file( + mock_request: MagicMock, settings_fixture: Settings +): + storage = Storage(request=mock_request, settings=settings_fixture) + mock_upload = MagicMock(spec=UploadFile) + mock_upload.filename = "test.jpg" + mock_upload.content_type = "image/jpeg" + + async def async_read(): + return b"image_content" + + mock_upload.read = async_read + result = await storage.prepare(mock_upload) + + assert isinstance(result, File) + assert result.filename == "test.jpg" + assert result.content_type == "image/jpeg" + + +def test_cdn_url_returns_none_when_file_is_none(mock_request: MagicMock, settings_fixture: Settings): + storage = Storage(request=mock_request, settings=settings_fixture) + assert storage.cdn_url(None) is None + + +def test_cdn_url_returns_local_url_when_backend_is_local( + mock_request: MagicMock, settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings_fixture, "storage_backend", "local") + storage = Storage(request=mock_request, settings=settings_fixture) + mock_file = MagicMock() + mock_file.id = "file-123" + url = storage.cdn_url(mock_file) + + assert str(url) == "http://testserver/storage/file-123" + + +def test_cdn_url_returns_remote_url_when_backend_is_dummy( + mock_request: MagicMock, settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(settings_fixture, "storage_backend", "dummy") + storage = Storage(request=mock_request, settings=settings_fixture) + # Passing a dict to simulate file["url"] behavior since mocking __getitem__ is unnecessarily complicated + mock_file = {"url": "http://cdn.example.com/file-123"} + + url = storage.cdn_url(mock_file) # pyright: ignore[reportArgumentType, reportCallIssue] + assert url == "http://cdn.example.com/file-123" + + +def test_setup_storage_returns_early_if_already_configured(settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch): + app = FastAPI() + mock_manager = MagicMock() + mock_manager.get_default.return_value = "existing_storage" + monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) + setup_storage(app, settings_fixture) + + mock_manager.add_storage.assert_not_called() + + +def test_setup_storage_configures_local_backend( + settings_fixture: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + app = FastAPI() + app.mount = MagicMock() + monkeypatch.setattr(settings_fixture, "storage_backend", "local") + monkeypatch.setattr(settings_fixture, "storage_local_base_path", str(tmp_path / "local_storage")) + mock_manager = MagicMock() + mock_driver_cls = MagicMock() + mock_static_files = MagicMock() + mock_manager.get_default.side_effect = RuntimeError("Not configured") # Force setup flow + mock_driver_instance = mock_driver_cls.return_value + mock_container = MagicMock() + mock_driver_instance.get_container.return_value = mock_container + monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) + monkeypatch.setattr("app.core.storage.LocalStorageDriver", mock_driver_cls) + monkeypatch.setattr("app.core.storage.StaticFiles", mock_static_files) + setup_storage(app, settings_fixture) + files_path = tmp_path / "local_storage" / "files" + + assert files_path.exists() + assert files_path.is_dir() + mock_driver_cls.assert_called_once() + mock_driver_instance.get_container.assert_called_with("files") + app.mount.assert_called_once() + args, kwargs = app.mount.call_args + assert args[0] == "/storage" + assert kwargs["name"] == "storage" + mock_manager.add_storage.assert_called_with("local", mock_container) + + +def test_setup_storage_configures_dummy_backend(settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch): + app = FastAPI() + monkeypatch.setattr(settings_fixture, "storage_backend", "dummy") + mock_manager = MagicMock() + mock_driver_cls = MagicMock() + mock_manager.get_default.side_effect = RuntimeError("Not configured") + mock_container = MagicMock() + mock_driver_cls.return_value.create_container.return_value = mock_container + monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) + monkeypatch.setattr("app.core.storage.DummyStorageDriver", mock_driver_cls) + setup_storage(app, settings_fixture) + + mock_driver_cls.assert_called_with("key", "secret") + mock_manager.add_storage.assert_called_with("dummy", mock_container) + + +def test_setup_storage_raises_error_for_invalid_backend(settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch): + app = FastAPI() + monkeypatch.setattr(settings_fixture, "storage_backend", "invalid_backend") + mock_manager = MagicMock() + mock_manager.get_default.side_effect = RuntimeError("Not configured") + monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) + + with pytest.raises(ValueError, match="Unsupported storage backend: invalid_backend"): + setup_storage(app, settings_fixture) From f5952d883d779ff246e0043023583aca63641d67 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Fri, 2 Jan 2026 16:59:11 +0530 Subject: [PATCH 06/10] fix: Fixed Ci tests --- app/core/tests/test_storage.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/core/tests/test_storage.py b/app/core/tests/test_storage.py index 45841db..2141b52 100644 --- a/app/core/tests/test_storage.py +++ b/app/core/tests/test_storage.py @@ -10,8 +10,7 @@ @pytest.fixture def mock_request(): - request = MagicMock(spec=Request) - request.base_url = "http://testserver/" + request = MagicMock(spec=Request, base_url="http://testserver/") return request From f91441ca32c0a387fcf48f90ab22a1110b349611 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Fri, 2 Jan 2026 17:11:21 +0530 Subject: [PATCH 07/10] fix: Fixed failing CI tests --- app/core/tests/test_storage.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/core/tests/test_storage.py b/app/core/tests/test_storage.py index 2141b52..e5d7bf4 100644 --- a/app/core/tests/test_storage.py +++ b/app/core/tests/test_storage.py @@ -67,7 +67,7 @@ def test_setup_storage_returns_early_if_already_configured(settings_fixture: Set app = FastAPI() mock_manager = MagicMock() mock_manager.get_default.return_value = "existing_storage" - monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) + monkeypatch.setattr("sqlalchemy_file.storage.StorageManager", mock_manager) setup_storage(app, settings_fixture) mock_manager.add_storage.assert_not_called() @@ -83,13 +83,13 @@ def test_setup_storage_configures_local_backend( mock_manager = MagicMock() mock_driver_cls = MagicMock() mock_static_files = MagicMock() - mock_manager.get_default.side_effect = RuntimeError("Not configured") # Force setup flow + mock_manager.get_default.side_effect = RuntimeError("Not configured") mock_driver_instance = mock_driver_cls.return_value mock_container = MagicMock() mock_driver_instance.get_container.return_value = mock_container - monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) - monkeypatch.setattr("app.core.storage.LocalStorageDriver", mock_driver_cls) - monkeypatch.setattr("app.core.storage.StaticFiles", mock_static_files) + monkeypatch.setattr("sqlalchemy_file.storage.StorageManager", mock_manager) + monkeypatch.setattr("libcloud.storage.drivers.local.LocalStorageDriver", mock_driver_cls) + monkeypatch.setattr("fastapi.staticfiles.StaticFiles", mock_static_files) setup_storage(app, settings_fixture) files_path = tmp_path / "local_storage" / "files" From 14fc981d240e462c9c2447d59bfa9771ef748a3f Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Mon, 5 Jan 2026 08:48:04 +0530 Subject: [PATCH 08/10] tests: Updated storage tests --- app/core/tests/test_storage.py | 98 +++++++++++++++++----------------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/app/core/tests/test_storage.py b/app/core/tests/test_storage.py index e5d7bf4..c7ebf0e 100644 --- a/app/core/tests/test_storage.py +++ b/app/core/tests/test_storage.py @@ -1,8 +1,10 @@ import pytest from pathlib import Path from unittest.mock import MagicMock -from fastapi import FastAPI, Request, UploadFile +from fastapi import Request, UploadFile + from sqlalchemy_file import File +from sqlalchemy_file.storage import StorageManager from app.core.storage import Storage, setup_storage from app.core.settings import Settings @@ -10,12 +12,11 @@ @pytest.fixture def mock_request(): - request = MagicMock(spec=Request, base_url="http://testserver/") - return request + return MagicMock(spec=Request, base_url="http://testserver/") @pytest.mark.asyncio -async def test_storage_prepare_reads_and_uploadfile_and_converts_to_sqlalchemy_file( +async def test_storage_prepare_reads_uploadfile_and_returns_sqlalchemy_file( mock_request: MagicMock, settings_fixture: Settings ): storage = Storage(request=mock_request, settings=settings_fixture) @@ -36,6 +37,7 @@ async def async_read(): def test_cdn_url_returns_none_when_file_is_none(mock_request: MagicMock, settings_fixture: Settings): storage = Storage(request=mock_request, settings=settings_fixture) + assert storage.cdn_url(None) is None @@ -56,76 +58,72 @@ def test_cdn_url_returns_remote_url_when_backend_is_dummy( ): monkeypatch.setattr(settings_fixture, "storage_backend", "dummy") storage = Storage(request=mock_request, settings=settings_fixture) - # Passing a dict to simulate file["url"] behavior since mocking __getitem__ is unnecessarily complicated mock_file = {"url": "http://cdn.example.com/file-123"} url = storage.cdn_url(mock_file) # pyright: ignore[reportArgumentType, reportCallIssue] assert url == "http://cdn.example.com/file-123" -def test_setup_storage_returns_early_if_already_configured(settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch): - app = FastAPI() - mock_manager = MagicMock() - mock_manager.get_default.return_value = "existing_storage" - monkeypatch.setattr("sqlalchemy_file.storage.StorageManager", mock_manager) - setup_storage(app, settings_fixture) - - mock_manager.add_storage.assert_not_called() - - def test_setup_storage_configures_local_backend( settings_fixture: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - app = FastAPI() - app.mount = MagicMock() monkeypatch.setattr(settings_fixture, "storage_backend", "local") - monkeypatch.setattr(settings_fixture, "storage_local_base_path", str(tmp_path / "local_storage")) - mock_manager = MagicMock() + monkeypatch.setattr(settings_fixture, "storage_local_base_path", str(tmp_path)) + mock_driver_cls = MagicMock() - mock_static_files = MagicMock() - mock_manager.get_default.side_effect = RuntimeError("Not configured") - mock_driver_instance = mock_driver_cls.return_value + mock_driver = mock_driver_cls.return_value mock_container = MagicMock() - mock_driver_instance.get_container.return_value = mock_container - monkeypatch.setattr("sqlalchemy_file.storage.StorageManager", mock_manager) - monkeypatch.setattr("libcloud.storage.drivers.local.LocalStorageDriver", mock_driver_cls) - monkeypatch.setattr("fastapi.staticfiles.StaticFiles", mock_static_files) - setup_storage(app, settings_fixture) - files_path = tmp_path / "local_storage" / "files" + mock_driver.get_container.return_value = mock_container + + monkeypatch.setattr( + "app.core.storage.LocalStorageDriver", + mock_driver_cls, + ) + + monkeypatch.setattr( + "app.core.storage.StorageManager.add_storage", + MagicMock(), + ) + + setup_storage(settings_fixture) + + files_path = tmp_path / "files" assert files_path.exists() assert files_path.is_dir() - mock_driver_cls.assert_called_once() - mock_driver_instance.get_container.assert_called_with("files") - app.mount.assert_called_once() - args, kwargs = app.mount.call_args - assert args[0] == "/storage" - assert kwargs["name"] == "storage" - mock_manager.add_storage.assert_called_with("local", mock_container) + + mock_driver.get_container.assert_called_once_with("files") + StorageManager.add_storage.assert_called_once_with("default", mock_container) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] def test_setup_storage_configures_dummy_backend(settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch): - app = FastAPI() monkeypatch.setattr(settings_fixture, "storage_backend", "dummy") - mock_manager = MagicMock() + mock_driver_cls = MagicMock() - mock_manager.get_default.side_effect = RuntimeError("Not configured") + mock_driver = mock_driver_cls.return_value mock_container = MagicMock() - mock_driver_cls.return_value.create_container.return_value = mock_container - monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) - monkeypatch.setattr("app.core.storage.DummyStorageDriver", mock_driver_cls) - setup_storage(app, settings_fixture) - mock_driver_cls.assert_called_with("key", "secret") - mock_manager.add_storage.assert_called_with("dummy", mock_container) + mock_driver.create_container.return_value = mock_container + + monkeypatch.setattr( + "app.core.storage.DummyStorageDriver", + mock_driver_cls, + ) + + monkeypatch.setattr( + "app.core.storage.StorageManager.add_storage", + MagicMock(), + ) + + setup_storage(settings_fixture) + + mock_driver_cls.assert_called_once_with("key", "secret") + mock_driver.create_container.assert_called_once_with("dummy-container") + StorageManager.add_storage.assert_called_once_with("default", mock_container) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] def test_setup_storage_raises_error_for_invalid_backend(settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch): - app = FastAPI() monkeypatch.setattr(settings_fixture, "storage_backend", "invalid_backend") - mock_manager = MagicMock() - mock_manager.get_default.side_effect = RuntimeError("Not configured") - monkeypatch.setattr("app.core.storage.StorageManager", mock_manager) - with pytest.raises(ValueError, match="Unsupported storage backend: invalid_backend"): - setup_storage(app, settings_fixture) + with pytest.raises(ValueError, match="Unsupported storage backend"): + setup_storage(settings_fixture) From 02529351dc409b1811dcf80bf5feef2982b10ad2 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Mon, 5 Jan 2026 09:54:26 +0530 Subject: [PATCH 09/10] tests: Code refractor --- app/core/tests/test_storage.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/app/core/tests/test_storage.py b/app/core/tests/test_storage.py index c7ebf0e..6f878de 100644 --- a/app/core/tests/test_storage.py +++ b/app/core/tests/test_storage.py @@ -2,7 +2,6 @@ from pathlib import Path from unittest.mock import MagicMock from fastapi import Request, UploadFile - from sqlalchemy_file import File from sqlalchemy_file.storage import StorageManager @@ -59,8 +58,8 @@ def test_cdn_url_returns_remote_url_when_backend_is_dummy( monkeypatch.setattr(settings_fixture, "storage_backend", "dummy") storage = Storage(request=mock_request, settings=settings_fixture) mock_file = {"url": "http://cdn.example.com/file-123"} - url = storage.cdn_url(mock_file) # pyright: ignore[reportArgumentType, reportCallIssue] + assert url == "http://cdn.example.com/file-123" @@ -69,52 +68,41 @@ def test_setup_storage_configures_local_backend( ): monkeypatch.setattr(settings_fixture, "storage_backend", "local") monkeypatch.setattr(settings_fixture, "storage_local_base_path", str(tmp_path)) - mock_driver_cls = MagicMock() mock_driver = mock_driver_cls.return_value mock_container = MagicMock() - mock_driver.get_container.return_value = mock_container - monkeypatch.setattr( "app.core.storage.LocalStorageDriver", mock_driver_cls, ) - monkeypatch.setattr( "app.core.storage.StorageManager.add_storage", MagicMock(), ) - setup_storage(settings_fixture) - files_path = tmp_path / "files" + assert files_path.exists() assert files_path.is_dir() - mock_driver.get_container.assert_called_once_with("files") StorageManager.add_storage.assert_called_once_with("default", mock_container) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] def test_setup_storage_configures_dummy_backend(settings_fixture: Settings, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(settings_fixture, "storage_backend", "dummy") - mock_driver_cls = MagicMock() mock_driver = mock_driver_cls.return_value mock_container = MagicMock() - mock_driver.create_container.return_value = mock_container - monkeypatch.setattr( "app.core.storage.DummyStorageDriver", mock_driver_cls, ) - monkeypatch.setattr( "app.core.storage.StorageManager.add_storage", MagicMock(), ) - setup_storage(settings_fixture) mock_driver_cls.assert_called_once_with("key", "secret") From 5bb373110d02df8ceadad31abc91a11f899238a0 Mon Sep 17 00:00:00 2001 From: PulinduVR Date: Mon, 5 Jan 2026 10:10:48 +0530 Subject: [PATCH 10/10] test: test added for add_storage --- app/core/tests/test_storage.py | 35 ++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/app/core/tests/test_storage.py b/app/core/tests/test_storage.py index 6f878de..a8f5a80 100644 --- a/app/core/tests/test_storage.py +++ b/app/core/tests/test_storage.py @@ -1,11 +1,11 @@ import pytest from pathlib import Path from unittest.mock import MagicMock -from fastapi import Request, UploadFile +from fastapi import FastAPI, Request, UploadFile from sqlalchemy_file import File from sqlalchemy_file.storage import StorageManager -from app.core.storage import Storage, setup_storage +from app.core.storage import add_storage_route, get_storage, Storage, setup_storage from app.core.settings import Settings @@ -63,6 +63,14 @@ def test_cdn_url_returns_remote_url_when_backend_is_dummy( assert url == "http://cdn.example.com/file-123" +def test_get_storage_returns_storage(mock_request: MagicMock, settings_fixture: Settings): + storage = get_storage(request=mock_request, settings=settings_fixture) + + assert isinstance(storage, Storage) + assert storage.request is mock_request + assert storage.settings is settings_fixture + + def test_setup_storage_configures_local_backend( settings_fixture: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): @@ -115,3 +123,26 @@ def test_setup_storage_raises_error_for_invalid_backend(settings_fixture: Settin with pytest.raises(ValueError, match="Unsupported storage backend"): setup_storage(settings_fixture) + + +def test_add_storage_route_mounts_static_files_for_local_backend( + settings_fixture: Settings, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + app = FastAPI() + monkeypatch.setattr(settings_fixture, "storage_backend", "local") + monkeypatch.setattr(settings_fixture, "storage_local_base_path", str(tmp_path)) + app.mount = MagicMock() + mock_static_files = MagicMock() + monkeypatch.setattr( + "app.core.storage.StaticFiles", + mock_static_files, + ) + add_storage_route(app, settings_fixture) + + app.mount.assert_called_once() + args, kwargs = app.mount.call_args + assert args[0] == "/storage" + assert kwargs["name"] == "storage" + mock_static_files.assert_called_once()