diff --git a/mailing/mailing.py b/mailing/mailing.py index 6b6fc45..5d9884c 100644 --- a/mailing/mailing.py +++ b/mailing/mailing.py @@ -41,9 +41,9 @@ def send_sendgrid_email(self, **kwargs: Any) -> Any | None: ) try: response = sg.send(message) - return response except Exception as e: if hasattr(e, "body"): logger.error("SendGrid error body: %s", e.body) logger.exception("SendGrid exception") return None + return response diff --git a/tests/__init__.py b/mailing/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to mailing/tests/__init__.py diff --git a/mailing/tests/conftest.py b/mailing/tests/conftest.py new file mode 100644 index 0000000..124002d --- /dev/null +++ b/mailing/tests/conftest.py @@ -0,0 +1,79 @@ +import os +import types +import pytest + + +@pytest.fixture(autouse=True) +def _env_settings(monkeypatch): + """Ensure settings has required env vars for tests and patch settings module. + Autouse so all tests get consistent defaults. + """ + monkeypatch.setenv("SENDGRID_API_KEY", "test-api-key") + monkeypatch.setenv("SENDGRID_FROM_EMAIL", "from@example.com") + + # Also patch the already-imported settings module so defaults apply even if + # settings was imported before this fixture ran. + import settings as _settings + + monkeypatch.setattr(_settings, "SENDGRID_API_KEY", "test-api-key", raising=False) + monkeypatch.setattr(_settings, "SENDGRID_FROM_EMAIL", "from@example.com", raising=False) + yield + + +@pytest.fixture() +def email_data(): + return { + "subject": "Hello", + "message": "Plain text body", + "recipient_list": ["to1@example.com", "to2@example.com"], + "from_email": "from@example.com", + "html_content": "
HTML
", + } + + +class DummyMail: + """Lightweight substitute for sendgrid.helpers.mail.Mail used in tests.""" + + def __init__(self, **kwargs): + # store all kwargs to allow assertions in tests + self.kwargs = kwargs + + +class DummySendGridClient: + """A dummy client whose send() we can customize per test.""" + + def __init__(self, *_, **__): + # send behavior can be overridden by tests via attribute replacement + self._response = "ok" + + def send(self, mail): # noqa: D401 + """Return a predefined response; overridden in tests for error path.""" + return self._response + + +@pytest.fixture() +def mock_sendgrid_success(monkeypatch): + """Patch mailing.mailing with dummy Mail and SendGrid client that succeed.""" + import mailing.mailing as mm + + monkeypatch.setattr(mm, "Mail", DummyMail, raising=True) + monkeypatch.setattr(mm, "SendGridAPIClient", DummySendGridClient, raising=True) + return mm + + +@pytest.fixture() +def mock_sendgrid_error(monkeypatch): + """Patch mailing.mailing with dummy Mail and a client that raises Exception.""" + import mailing.mailing as mm + + class ErroringClient(DummySendGridClient): + def send(self, mail): + # Simulate an exception that has a body attribute like SendGridError + err = Exception("send failed") + # attach a body attribute to mimic SDK errors + err.body = {"errors": ["some error"]} + raise err + + monkeypatch.setattr(mm, "Mail", DummyMail, raising=True) + monkeypatch.setattr(mm, "SendGridAPIClient", ErroringClient, raising=True) + return mm diff --git a/mailing/tests/test_sendgrid.py b/mailing/tests/test_sendgrid.py new file mode 100644 index 0000000..7375334 --- /dev/null +++ b/mailing/tests/test_sendgrid.py @@ -0,0 +1,89 @@ +import logging + +from mailing.mailing import Email + + +class TestSendgridMailing: + def test_send_sendgrid_email_success_returns_response(self, mock_sendgrid_success, email_data): + # Given + email = Email(**email_data) + + # When + response = email.send_sendgrid_email() + + # Then + assert response == "ok" + + # Also verify Mail was constructed with expected kwargs + # The DummyMail stores kwargs; access the last constructed instance by recreating with same data + # Instead, we build another to compare keys; more robust is to rebuild and compare content + m = mock_sendgrid_success.Mail( + from_email=email_data["from_email"], + to_emails=email_data["recipient_list"], + subject=email_data["subject"], + html_content=email_data["html_content"], + plain_text_content=email_data["message"], + ) + # ensure keys are as expected; values are validated via equality + expected = m.kwargs + + # Now recreate the Email and intercept the actual Mail instance to assert args + captured = {} + + class CapturingMail(mock_sendgrid_success.Mail): + def __init__(self, **kwargs): + super().__init__(**kwargs) + captured.update(kwargs) + + # Patch in the capturing version + import mailing.mailing as mm + + mm.Mail = CapturingMail + Email(**email_data).send_sendgrid_email() + + assert captured == expected + + def test_send_sendgrid_email_uses_default_from_email_when_not_provided( + self, mock_sendgrid_success + ): + # Given: no from_email passed, should use settings.SENDGRID_FROM_EMAIL from env + data = { + "subject": "Subj", + "message": "Body", + "recipient_list": ["t@example.com"], + "html_content": None, + } + # Capture constructed kwargs + captured = {} + + class CapturingMail(mock_sendgrid_success.Mail): + def __init__(self, **kwargs): + super().__init__(**kwargs) + captured.update(kwargs) + + import mailing.mailing as mm + + mm.Mail = CapturingMail + + # When + Email(**data).send_sendgrid_email() + + # Then + assert captured["from_email"] == "from@example.com" + assert captured["plain_text_content"] == "Body" + assert captured["html_content"] is None + + def test_send_sendgrid_email_logs_and_returns_none_on_exception( + self, mock_sendgrid_error, caplog, email_data + ): + # Given + email = Email(**email_data) + + # When + with caplog.at_level(logging.ERROR): + result = email.send_sendgrid_email() + + # Then + assert result is None + # Ensure an error was logged containing our simulated body + assert any("SendGrid error body" in rec.getMessage() for rec in caplog.records) diff --git a/tests/test_dummy.py b/tests/test_dummy.py deleted file mode 100644 index f4f5361..0000000 --- a/tests/test_dummy.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_dummy(): - assert True