From 10988bc76ded9e52d09f1d3b5be0299db6add0c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20D=C3=ADaz?= Date: Thu, 9 Oct 2025 13:51:19 -0300 Subject: [PATCH 1/3] chore(tests): Add sendgrid tests and remove dummy tests --- mailing/mailing.py | 2 +- {tests => mailing/tests}/__init__.py | 0 mailing/tests/conftest.py | 79 +++++++++++++++++++++++++ mailing/tests/test_mailing.py | 88 ++++++++++++++++++++++++++++ tests/test_dummy.py | 2 - 5 files changed, 168 insertions(+), 3 deletions(-) rename {tests => mailing/tests}/__init__.py (100%) create mode 100644 mailing/tests/conftest.py create mode 100644 mailing/tests/test_mailing.py delete mode 100644 tests/test_dummy.py 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_mailing.py b/mailing/tests/test_mailing.py new file mode 100644 index 0000000..b347197 --- /dev/null +++ b/mailing/tests/test_mailing.py @@ -0,0 +1,88 @@ +import logging + +from mailing.mailing import Email + + +def test_send_sendgrid_email_success_returns_response(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(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( + 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 From 20dbd8691ebebff4b26787542186a79fa7311540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20D=C3=ADaz?= Date: Thu, 9 Oct 2025 14:49:17 -0300 Subject: [PATCH 2/3] chore(tests): use specific test for sendgrid --- mailing/tests/test_sendgrid.py | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 mailing/tests/test_sendgrid.py 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) From 4c9fc4d627ea7c6b5b6ba2b10734a4b87414fff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20D=C3=ADaz?= Date: Thu, 9 Oct 2025 14:50:23 -0300 Subject: [PATCH 3/3] chore(tests): Remove general mailing tests --- mailing/tests/test_mailing.py | 88 ----------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 mailing/tests/test_mailing.py diff --git a/mailing/tests/test_mailing.py b/mailing/tests/test_mailing.py deleted file mode 100644 index b347197..0000000 --- a/mailing/tests/test_mailing.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging - -from mailing.mailing import Email - - -def test_send_sendgrid_email_success_returns_response(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(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( - 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)