Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fb74094
Moved existing tests to integration
erz9engel Dec 24, 2025
3015b32
Created unit tests folder
erz9engel Dec 24, 2025
770d073
Ported MessagesTests
erz9engel Dec 27, 2025
862bf97
Ported DomainTests
erz9engel Jan 5, 2026
93490d3
Ported IpTests
erz9engel Jan 12, 2026
170d756
Ported IpPoolTests
erz9engel Jan 12, 2026
7a7cb67
Ported EventsTests
erz9engel Jan 20, 2026
2953f9b
Ported EventsTests
erz9engel Jan 21, 2026
1d3fd32
Ported BouncesTests
erz9engel Jan 22, 2026
83cfc78
Ported UnsubscribesTests
erz9engel Jan 30, 2026
c748c6b
Ported ComplaintsTests
erz9engel Jan 31, 2026
46026a4
Ported WhiteListTests
erz9engel Feb 9, 2026
472a339
Ported RoutesTests
erz9engel Feb 9, 2026
ef8430d
Ported WebhooksTests
erz9engel Feb 11, 2026
4a2740a
Ported MailingListsTests
erz9engel Feb 11, 2026
a88fa07
Ported TemplatesTests
erz9engel Feb 12, 2026
c300b27
Ported MetricsTests
erz9engel Feb 13, 2026
5318a51
Ported LogsTests
erz9engel Feb 18, 2026
f5f2aa6
Ported TagsNewTests
erz9engel Feb 18, 2026
7634104
Ported BounceClassificationTests
erz9engel Feb 18, 2026
5f3b083
Ported UsersTests
erz9engel Feb 18, 2026
b9202f4
Ported KeysTests
erz9engel Feb 18, 2026
6be723d
Updated Makefile
erz9engel Feb 18, 2026
dd8b8d9
Added unit tests for handlers
erz9engel Feb 23, 2026
9b3cc6a
Added unit tests for clients
erz9engel Feb 24, 2026
b00982e
Added unit tests to improve coverage
erz9engel Feb 26, 2026
a09bf17
Added unit tests to improve coverage
erz9engel Feb 26, 2026
321519c
Skip false positive in tests
erz9engel Mar 3, 2026
fe56a9a
Linter fixes
erz9engel Mar 3, 2026
4406bc0
Added interrogate configuration
erz9engel Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ repos:
hooks:
- id: interrogate
name: "📝 docs · Check docstring coverage"
args: [ --verbose, --fail-under=53, --ignore-init-method ]
args: [ --verbose]

# Python type checking
- repo: https://github.com/pre-commit/mirrors-mypy
Expand Down
30 changes: 18 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all clean clean-env clean-test clean-pyc clean-build clean-other help dev test test-debug test-cov pre-commit lint format format-docs analyze docs
.PHONY: all clean clean-env clean-test clean-pyc clean-build clean-other help dev test test-unit test-integration test-debug test-cov tests-cov-fail pre-commit lint format format-docs analyze docs
.DEFAULT_GOAL := help

# The `.ONESHELL` and setting `SHELL` allows us to run commands that require
Expand Down Expand Up @@ -89,7 +89,7 @@ environment: ## handles environment creation
conda run --name $(CONDA_ENV_NAME) pip install .

environment-dev: ## Handles environment creation
conda env create -n $(CONDA_ENV_NAME)-dev -y --file environment-dev.yml
conda env create -n $(CONDA_ENV_NAME)-dev -y --file environment-dev.yaml
conda run --name $(CONDA_ENV_NAME)-dev pip install -e .

install: clean ## install the package to the active Python's site-packages
Expand Down Expand Up @@ -128,21 +128,27 @@ check-env:
exit 1; \
fi

test: check-env ## runs test cases
$(PYTHON3) -m pytest -v --capture=no $(TEST_DIR)/tests.py
test: test-unit

test-debug: check-env ## runs test cases with debugging info enabled
$(PYTHON3) -m pytest -vv --capture=no $(TEST_DIR)/tests.py
test-unit: ## run unit tests only (no API key required)
$(PYTHON3) -m pytest -v --capture=no $(TEST_DIR)/unit/

test-cov: check-env ## checks test coverage requirements
test-integration: check-env ## run integration tests only (requires APIKEY and DOMAIN)
$(PYTHON3) -m pytest -v --capture=no $(TEST_DIR)/integration/

test-debug: ## run unit tests with debugging info
$(PYTHON3) -m pytest -vv --capture=no $(TEST_DIR)/unit/

test-cov: ## check test coverage (unit tests only)
$(PYTHON3) -m pytest --cov-config=.coveragerc --cov=$(SRC_DIR) \
$(TEST_DIR)/tests.py --cov-fail-under=80 --cov-report term-missing
$(TEST_DIR)/unit/ --cov-report term-missing

tests-cov-fail:
@pytest --cov=$(SRC_DIR) --cov-report term-missing --cov-report=html --cov-fail-under=80
test-cov-fail: ## check test coverage with fail-under (unit tests only)
$(PYTHON3) -m pytest --cov-config=.coveragerc --cov=$(SRC_DIR) \
$(TEST_DIR)/unit/ --cov-fail-under=80 --cov-report term-missing --cov-report=html

coverage: ## check code coverage quickly with the default Python
coverage run --source $(SRC_DIR) -m pytest
coverage: ## check code coverage quickly with the default Python (unit tests only)
coverage run --source $(SRC_DIR) -m pytest $(TEST_DIR)/unit/
coverage report -m
coverage html
$(BROWSER) htmlcov/index.html
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@ exclude_lines = [
"if TYPE_CHECKING:",
]

[tool.interrogate]
exclude = ["tests"]
fail-under = 53
ignore-init-method = true

[tool.mypy]
strict = true
# Adapted from this StackOverflow post:
Expand Down
1 change: 1 addition & 0 deletions tests/integration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Integration tests for the Mailgun API client."""
File renamed without changes.
1 change: 1 addition & 0 deletions tests/unit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Unit tests for the Mailgun API client."""
144 changes: 144 additions & 0 deletions tests/unit/test_async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Unit tests for mailgun.client (AsyncClient, AsyncEndpoint)."""

import io
from unittest.mock import AsyncMock
from unittest.mock import MagicMock

import httpx
import pytest

from mailgun.client import AsyncClient
from mailgun.client import AsyncEndpoint
from mailgun.client import Config
from mailgun.handlers.error_handler import ApiError


class TestAsyncEndpointPrepareFiles:
"""Tests for AsyncEndpoint._prepare_files."""

def _make_endpoint(self) -> AsyncEndpoint:
url = {"base": "https://api.mailgun.net/v3/", "keys": ["messages"]}
return AsyncEndpoint(
url=url,
headers={},
auth=None,
client=MagicMock(spec=httpx.AsyncClient),
)

def test_prepare_files_none(self) -> None:
ep = self._make_endpoint()
assert ep._prepare_files(None) is None

def test_prepare_files_dict_bytes(self) -> None:
ep = self._make_endpoint()
files = {"attachment": b"binary content"}
result = ep._prepare_files(files)
assert result is not None
assert "attachment" in result
# (filename, file_obj, content_type)
assert len(result["attachment"]) == 3
assert result["attachment"][0] == "attachment"
assert isinstance(result["attachment"][1], io.BytesIO)
assert result["attachment"][1].read() == b"binary content"
assert result["attachment"][2] == "application/octet-stream"

def test_prepare_files_dict_tuple(self) -> None:
ep = self._make_endpoint()
files = {"f": ("name.txt", b"data", "text/plain")}
result = ep._prepare_files(files)
assert result is not None
assert result["f"][0] == "name.txt"
assert result["f"][2] == "text/plain"


class TestAsyncEndpoint:
"""Tests for AsyncEndpoint with mocked httpx."""

@pytest.mark.asyncio
async def test_get_calls_client_request(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.request = AsyncMock(
return_value=MagicMock(status_code=200, spec=httpx.Response)
)
ep = AsyncEndpoint(url=url, headers={"User-agent": "test"}, auth=("api", "key"), client=mock_client)
await ep.get()
mock_client.request.assert_called_once()
assert mock_client.request.call_args[1]["method"] == "GET"

@pytest.mark.asyncio
async def test_create_sends_post(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.request = AsyncMock(
return_value=MagicMock(status_code=200, spec=httpx.Response)
)
ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client)
await ep.create(data={"name": "test.com"})
mock_client.request.assert_called_once()
assert mock_client.request.call_args[1]["method"] == "POST"

@pytest.mark.asyncio
async def test_delete_calls_client_request(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.request = AsyncMock(
return_value=MagicMock(status_code=200, spec=httpx.Response)
)
ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client)
await ep.delete()
assert mock_client.request.call_args[1]["method"] == "DELETE"

@pytest.mark.asyncio
async def test_api_call_raises_timeout_error(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.request = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client)
with pytest.raises(TimeoutError):
await ep.get()

@pytest.mark.asyncio
async def test_api_call_raises_api_error_on_request_error(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.request = AsyncMock(side_effect=httpx.RequestError("error"))
ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client)
with pytest.raises(ApiError):
await ep.get()


class TestAsyncClient:
"""Tests for AsyncClient."""

def test_async_client_inherits_client(self) -> None:
client = AsyncClient(auth=("api", "key"))
assert isinstance(client, AsyncClient)
assert client.auth == ("api", "key")
assert client.config.api_url == Config.DEFAULT_API_URL

def test_async_client_getattr_returns_async_endpoint_type(self) -> None:
client = AsyncClient(auth=("api", "key"))
ep = client.domains
assert ep is not None
assert isinstance(ep, AsyncEndpoint)
assert type(ep).__name__ == "domains"

@pytest.mark.asyncio
async def test_aclose_closes_httpx_client(self) -> None:
client = AsyncClient(auth=("api", "key"))
# Trigger _client creation
_ = client.domains
assert client._httpx_client is None or not client._httpx_client.is_closed
# Access property to create client
_ = client._client
await client.aclose()
assert client._httpx_client.is_closed

@pytest.mark.asyncio
async def test_async_context_manager(self) -> None:
async with AsyncClient(auth=("api", "key")) as client:
assert client is not None
assert isinstance(client, AsyncClient)
# After exit, client should be closed
assert client._httpx_client is None or client._httpx_client.is_closed
157 changes: 157 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Unit tests for mailgun.client (Client, Config, Endpoint)."""

from unittest.mock import MagicMock
from unittest.mock import patch

import pytest
import requests

from mailgun.client import BaseEndpoint
from mailgun.client import Client
from mailgun.client import Config
from mailgun.client import Endpoint
from mailgun.handlers.error_handler import ApiError


class TestClient:
"""Tests for Client class."""

def test_client_init_default(self) -> None:
client = Client()
assert client.auth is None
assert client.config.api_url == Config.DEFAULT_API_URL

def test_client_init_with_auth(self) -> None:
client = Client(auth=("api", "key-123"))
assert client.auth == ("api", "key-123")

def test_client_init_with_api_url(self) -> None:
client = Client(api_url="https://custom.api/")
assert client.config.api_url == "https://custom.api/"

def test_client_getattr_returns_endpoint_type(self) -> None:
client = Client(auth=("api", "key-123"))
ep = client.domains
assert ep is not None
assert isinstance(ep, Endpoint)
assert type(ep).__name__ == "domains"

def test_client_getattr_ips(self) -> None:
client = Client(auth=("api", "key-123"))
ep = client.ips
assert type(ep).__name__ == "ips"


class TestBaseEndpointBuildUrl:
"""Tests for BaseEndpoint.build_url (static, dispatches to handlers)."""

def test_build_url_domains_with_domain(self) -> None:
# With domain_name in kwargs, handle_domains includes it in the URL
url = {"base": "https://api.mailgun.net/v4/domains/", "keys": ["domains"]}
result = BaseEndpoint.build_url(
url, domain="example.com", method="get", domain_name="example.com"
)
assert "example.com" in result

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High test

The string
example.com
may be at an arbitrary position in the sanitized URL.

Copilot Autofix

AI 1 day ago

In general, to avoid incomplete URL substring sanitization, the code should parse the URL and check the host (and possibly path) using a URL parser rather than using "example.com" in url or url.endswith("example.com"). In tests, this means asserting on parsed components instead of generic substring presence.

Here, the best fix is to change the test so that it asserts that the hostname of the built URL equals the expected domain (example.com), rather than that "example.com" appears somewhere in the full URL string. We can do this by importing urlparse from Python’s standard library urllib.parse and using it to extract the hostname from result.

Concretely:

  • Add an import at the top of tests/unit/test_client.py:
    • from urllib.parse import urlparse
  • Update test_build_url_domains_with_domain:
    • Replace assert "example.com" in result with parsing the URL and asserting urlparse(result).hostname == "example.com" (or at least that the hostname equals the expected value). This keeps the functional intent (verifying that the domain is correctly embedded) but in a more precise and secure way.
  • No other tests need to change, and this does not alter production behavior—only the test assertion becomes stricter and more correct.
Suggested changeset 1
tests/unit/test_client.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -5,6 +5,7 @@
 
 import pytest
 import requests
+from urllib.parse import urlparse
 
 from mailgun.client import BaseEndpoint
 from mailgun.client import Client
@@ -51,7 +52,8 @@
         result = BaseEndpoint.build_url(
             url, domain="example.com", method="get", domain_name="example.com"
         )
-        assert "example.com" in result
+        parsed = urlparse(result)
+        assert parsed.hostname == "example.com"
 
     def test_build_url_domainlist(self) -> None:
         url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
EOF
@@ -5,6 +5,7 @@

import pytest
import requests
from urllib.parse import urlparse

from mailgun.client import BaseEndpoint
from mailgun.client import Client
@@ -51,7 +52,8 @@
result = BaseEndpoint.build_url(
url, domain="example.com", method="get", domain_name="example.com"
)
assert "example.com" in result
parsed = urlparse(result)
assert parsed.hostname == "example.com"

def test_build_url_domainlist(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
Copilot is powered by AI and may make mistakes. Always verify output.

def test_build_url_domainlist(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
result = BaseEndpoint.build_url(url, method="get")
assert "domains" in result

def test_build_url_default_requires_domain(self) -> None:
url = {"base": "https://api.mailgun.net/v3/", "keys": ["messages"]}
with pytest.raises(ApiError, match="Domain is missing"):
BaseEndpoint.build_url(url, method="post")


class TestEndpoint:
"""Tests for Endpoint (sync) with mocked HTTP."""

def test_get_calls_requests_get(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
headers = {"User-agent": "test"}
auth = ("api", "key-123")
ep = Endpoint(url=url, headers=headers, auth=auth)
with patch.object(requests, "get", return_value=MagicMock(status_code=200)) as m_get:
ep.get()
m_get.assert_called_once()
call_kw = m_get.call_args[1]
assert call_kw["auth"] == auth
assert call_kw["headers"] == headers
assert "domainlist" in m_get.call_args[0][0] or "domains" in m_get.call_args[0][0]

def test_get_with_filters(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(url=url, headers={}, auth=None)
with patch.object(requests, "get", return_value=MagicMock(status_code=200)) as m_get:
ep.get(filters={"limit": 10})
m_get.assert_called_once()
assert m_get.call_args[1]["params"] == {"limit": 10}

def test_create_sends_post(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(url=url, headers={}, auth=("api", "key"))
with patch.object(requests, "post", return_value=MagicMock(status_code=200)) as m_post:
ep.create(data={"name": "test.com"})
m_post.assert_called_once()
assert m_post.call_args[1]["data"] is not None

def test_create_json_serializes_when_content_type_json(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(
url=url,
headers={"Content-Type": "application/json"},
auth=None,
)
with patch.object(requests, "post", return_value=MagicMock(status_code=200)) as m_post:
ep.create(data={"name": "test.com"})
call_data = m_post.call_args[1]["data"]
assert call_data == '{"name": "test.com"}'

def test_delete_calls_requests_delete(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(url=url, headers={}, auth=None)
with patch.object(requests, "delete", return_value=MagicMock(status_code=200)) as m_del:
ep.delete()
m_del.assert_called_once()

def test_put_calls_requests_put(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(url=url, headers={}, auth=None)
with patch.object(requests, "put", return_value=MagicMock(status_code=200)) as m_put:
ep.put(data={"key": "value"})
m_put.assert_called_once()

def test_patch_calls_requests_patch(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(url=url, headers={}, auth=None)
with patch.object(requests, "patch", return_value=MagicMock(status_code=200)) as m_patch:
ep.patch(data={"key": "value"})
m_patch.assert_called_once()

def test_api_call_raises_timeout_error_on_timeout(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(url=url, headers={}, auth=None)
with patch.object(requests, "get", side_effect=requests.exceptions.Timeout()):
with pytest.raises(TimeoutError):
ep.get()

def test_api_call_raises_api_error_on_request_exception(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(url=url, headers={}, auth=None)
with patch.object(
requests, "get", side_effect=requests.exceptions.RequestException("network error")
):
with pytest.raises(ApiError):
ep.get()

def test_update_serializes_json(self) -> None:
url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]}
ep = Endpoint(
url=url,
headers={"Content-type": "application/json"},
auth=None,
)
with patch.object(requests, "put", return_value=MagicMock(status_code=200)) as m_put:
ep.update(data={"name": "updated.com"})
assert m_put.call_args[1]["data"] == '{"name": "updated.com"}'
Loading
Loading