diff --git a/app/core/tests/test_email_sender.py b/app/core/tests/test_email_sender.py new file mode 100644 index 0000000..7d6f1ad --- /dev/null +++ b/app/core/tests/test_email_sender.py @@ -0,0 +1,301 @@ +import logging +import pytest +from pathlib import Path +from unittest.mock import MagicMock + +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 + + +@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}} + + + """) + + _ = text_template.write_text("Hello {{ name }}") + + return html_template, text_template + + +# Test get_email_sender dependency injection for EmailSender +# ---------------------------------------------------------------------------------------------------------------------- + + +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 + + +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_get_email_sender_raises_not_implemented_error_for_invalid_email_sender_types(settings_fixture: SettingsDep): + 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_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 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_fixture: tuple[Path, Path], + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +): + sender = LocalEmailSender(settings_fixture) + 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"}) + + assert "Failed to compile MJML to HTML" in caplog.text + assert "Hello ErrorTest" in result + + +# Test LocalEmailSender send_email which logs emails +# ---------------------------------------------------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_email_logs_details_and_returns_placeholder( + settings_fixture: SettingsDep, + email_templates_fixture: tuple[Path, Path], + caplog: pytest.LogCaptureFixture, +): + caplog.set_level(logging.DEBUG) + settings_fixture.email_sender_type = "local" + sender = LocalEmailSender(settings_fixture) + 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 "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_templates_fixture: tuple[Path, Path] +): + settings_fixture.email_sender_type = "local" + sender = LocalEmailSender(settings_fixture) + 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" + 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_fixture: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, 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_fixture: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, 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_fixture: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + 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_fixture: tuple[Path, Path]): + sender = LocalEmailSender(settings_fixture) + _, text_template = email_templates_fixture + 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_fixture: tuple[Path, Path]): + sender = SmtpEmailSender(settings_fixture) + _, text_template = email_templates_fixture + 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, 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") + 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) + 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) + + +@pytest.mark.asyncio +async def test_send_email_logs_network_error_during_sending( + settings_fixture: SettingsDep, + 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) + 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) + + 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, 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) + 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) + 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) + + 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, 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") + 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) + 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) + 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, 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") + 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) + 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 + + mock_smtp_cls.assert_called_with("smtp.secure.com", 465) + mock_instance.send_message.assert_called_once() + mock_instance.quit.assert_called_once() diff --git a/app/core/tests/test_storage.py b/app/core/tests/test_storage.py new file mode 100644 index 0000000..a8f5a80 --- /dev/null +++ b/app/core/tests/test_storage.py @@ -0,0 +1,148 @@ +import pytest +from pathlib import Path +from unittest.mock import MagicMock +from fastapi import FastAPI, Request, UploadFile +from sqlalchemy_file import File +from sqlalchemy_file.storage import StorageManager + +from app.core.storage import add_storage_route, get_storage, Storage, setup_storage +from app.core.settings import Settings + + +@pytest.fixture +def mock_request(): + return MagicMock(spec=Request, base_url="http://testserver/") + + +@pytest.mark.asyncio +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) + 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) + 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_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 +): + 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") + 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): + monkeypatch.setattr(settings_fixture, "storage_backend", "invalid_backend") + + 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() 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