From eee5f6c74b44ae807cfb59f266d1d93b5c53acc1 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 25 Aug 2025 16:01:39 +0300 Subject: [PATCH] Fix issue #20: Add ContactListsApi, related models, tests, examples --- examples/contacts/contact_lists.py | 35 ++ mailtrap/__init__.py | 1 + mailtrap/api/contacts.py | 5 + mailtrap/api/resources/contact_fields.py | 2 +- mailtrap/api/resources/contact_lists.py | 44 +++ mailtrap/models/contacts.py | 11 + ...est_contacts.py => test_contact_fields.py} | 0 tests/unit/api/test_contact_lists.py | 324 ++++++++++++++++++ tests/unit/models/test_contacts.py | 10 + 9 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 examples/contacts/contact_lists.py create mode 100644 mailtrap/api/resources/contact_lists.py rename tests/unit/api/{test_contacts.py => test_contact_fields.py} (100%) create mode 100644 tests/unit/api/test_contact_lists.py diff --git a/examples/contacts/contact_lists.py b/examples/contacts/contact_lists.py new file mode 100644 index 0000000..bccab80 --- /dev/null +++ b/examples/contacts/contact_lists.py @@ -0,0 +1,35 @@ +import mailtrap as mt +from mailtrap.models.common import DeletedObject +from mailtrap.models.contacts import ContactList + +API_TOKEN = "YOU_API_TOKEN" +ACCOUNT_ID = "YOU_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +contact_lists_api = client.contacts_api.contact_lists + + +def create_contact_list(name: str) -> ContactList: + params = mt.ContactListParams(name=name) + return contact_lists_api.create(params) + + +def update_contact_list(contact_list_id: int, name: str) -> ContactList: + params = mt.ContactListParams(name=name) + return contact_lists_api.update(contact_list_id, params) + + +def list_contact_lists() -> list[ContactList]: + return contact_lists_api.get_list() + + +def get_contact_list(contact_list_id: int) -> ContactList: + return contact_lists_api.get_by_id(contact_list_id) + + +def delete_contact_list(contact_list_id: int) -> DeletedObject: + return contact_lists_api.delete(contact_list_id) + + +if __name__ == "__main__": + print(list_contact_lists()) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 14c8d21..9f9201a 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -5,6 +5,7 @@ from .exceptions import ClientConfigurationError from .exceptions import MailtrapError from .models.contacts import ContactField +from .models.contacts import ContactListParams from .models.contacts import CreateContactFieldParams from .models.contacts import UpdateContactFieldParams from .models.mail import Address diff --git a/mailtrap/api/contacts.py b/mailtrap/api/contacts.py index cb67eac..baca27e 100644 --- a/mailtrap/api/contacts.py +++ b/mailtrap/api/contacts.py @@ -1,4 +1,5 @@ from mailtrap.api.resources.contact_fields import ContactFieldsApi +from mailtrap.api.resources.contact_lists import ContactListsApi from mailtrap.http import HttpClient @@ -10,3 +11,7 @@ def __init__(self, client: HttpClient, account_id: str) -> None: @property def contact_fields(self) -> ContactFieldsApi: return ContactFieldsApi(account_id=self._account_id, client=self._client) + + @property + def contact_lists(self) -> ContactListsApi: + return ContactListsApi(account_id=self._account_id, client=self._client) diff --git a/mailtrap/api/resources/contact_fields.py b/mailtrap/api/resources/contact_fields.py index 6958d6f..06521cd 100644 --- a/mailtrap/api/resources/contact_fields.py +++ b/mailtrap/api/resources/contact_fields.py @@ -44,6 +44,6 @@ def delete(self, field_id: int) -> DeletedObject: def _api_path(self, field_id: Optional[int] = None) -> str: path = f"/api/accounts/{self._account_id}/contacts/fields" - if field_id: + if field_id is not None: return f"{path}/{field_id}" return path diff --git a/mailtrap/api/resources/contact_lists.py b/mailtrap/api/resources/contact_lists.py new file mode 100644 index 0000000..28ced08 --- /dev/null +++ b/mailtrap/api/resources/contact_lists.py @@ -0,0 +1,44 @@ +from typing import Optional + +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.contacts import ContactList +from mailtrap.models.contacts import ContactListParams + + +class ContactListsApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + def get_list(self) -> list[ContactList]: + response = self._client.get(self._api_path()) + return [ContactList(**field) for field in response] + + def get_by_id(self, list_id: int) -> ContactList: + response = self._client.get(self._api_path(list_id)) + return ContactList(**response) + + def create(self, list_params: ContactListParams) -> ContactList: + response = self._client.post( + self._api_path(), + json=list_params.api_data, + ) + return ContactList(**response) + + def update(self, list_id: int, list_params: ContactListParams) -> ContactList: + response = self._client.patch( + self._api_path(list_id), + json=list_params.api_data, + ) + return ContactList(**response) + + def delete(self, list_id: int) -> DeletedObject: + self._client.delete(self._api_path(list_id)) + return DeletedObject(list_id) + + def _api_path(self, list_id: Optional[int] = None) -> str: + path = f"/api/accounts/{self._account_id}/contacts/lists" + if list_id is not None: + return f"{path}/{list_id}" + return path diff --git a/mailtrap/models/contacts.py b/mailtrap/models/contacts.py index d1e5598..dd4bc17 100644 --- a/mailtrap/models/contacts.py +++ b/mailtrap/models/contacts.py @@ -28,3 +28,14 @@ class ContactField: name: str data_type: str merge_tag: str + + +@dataclass +class ContactListParams(RequestParams): + name: str + + +@dataclass +class ContactList: + id: int + name: str diff --git a/tests/unit/api/test_contacts.py b/tests/unit/api/test_contact_fields.py similarity index 100% rename from tests/unit/api/test_contacts.py rename to tests/unit/api/test_contact_fields.py diff --git a/tests/unit/api/test_contact_lists.py b/tests/unit/api/test_contact_lists.py new file mode 100644 index 0000000..9bc18e8 --- /dev/null +++ b/tests/unit/api/test_contact_lists.py @@ -0,0 +1,324 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.contact_lists import ContactListsApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.contacts import ContactList +from mailtrap.models.contacts import ContactListParams +from tests import conftest + +ACCOUNT_ID = "321" +LIST_ID = 1234 +BASE_CONTACT_LISTS_URL = ( + f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/contacts/lists" +) + + +@pytest.fixture +def contact_lists_api() -> ContactListsApi: + return ContactListsApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_contact_list_dict() -> dict[str, Any]: + return { + "id": LIST_ID, + "name": "My Contact List", + } + + +@pytest.fixture +def create_contact_list_params() -> ContactListParams: + return ContactListParams(name="My Contact List") + + +@pytest.fixture +def update_contact_list_params() -> ContactListParams: + return ContactListParams(name="Updated Contact List") + + +class TestContactListsApi: + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_contact_lists_should_raise_api_errors( + self, + contact_lists_api: ContactListsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_CONTACT_LISTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contact_lists_api.get_list() + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_contact_lists_should_return_contact_list_list( + self, contact_lists_api: ContactListsApi, sample_contact_list_dict: dict + ) -> None: + responses.get( + BASE_CONTACT_LISTS_URL, + json=[sample_contact_list_dict], + status=200, + ) + + contact_lists = contact_lists_api.get_list() + + assert isinstance(contact_lists, list) + assert all( + isinstance(contact_list, ContactList) for contact_list in contact_lists + ) + assert contact_lists[0].id == LIST_ID + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_contact_list_should_raise_api_errors( + self, + contact_lists_api: ContactListsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + f"{BASE_CONTACT_LISTS_URL}/{LIST_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contact_lists_api.get_by_id(LIST_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_contact_list_should_return_contact_list( + self, contact_lists_api: ContactListsApi, sample_contact_list_dict: dict + ) -> None: + responses.get( + f"{BASE_CONTACT_LISTS_URL}/{LIST_ID}", + json=sample_contact_list_dict, + status=200, + ) + + contact_list = contact_lists_api.get_by_id(LIST_ID) + + assert isinstance(contact_list, ContactList) + assert contact_list.id == LIST_ID + assert contact_list.name == "My Contact List" + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_create_contact_list_should_raise_api_errors( + self, + contact_lists_api: ContactListsApi, + create_contact_list_params: ContactListParams, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post( + BASE_CONTACT_LISTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contact_lists_api.create(create_contact_list_params) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_contact_list_should_return_created_contact_list( + self, + contact_lists_api: ContactListsApi, + create_contact_list_params: ContactListParams, + ) -> None: + expected_response = { + "id": LIST_ID, + "name": "My Contact List", + } + responses.post( + BASE_CONTACT_LISTS_URL, + json=expected_response, + status=201, + ) + + contact_list = contact_lists_api.create(create_contact_list_params) + + assert isinstance(contact_list, ContactList) + assert contact_list.id == LIST_ID + assert contact_list.name == "My Contact List" + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_update_contact_list_should_raise_api_errors( + self, + contact_lists_api: ContactListsApi, + update_contact_list_params: ContactListParams, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.patch( + f"{BASE_CONTACT_LISTS_URL}/{LIST_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contact_lists_api.update(LIST_ID, update_contact_list_params) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_update_contact_list_should_return_updated_contact_list( + self, + contact_lists_api: ContactListsApi, + update_contact_list_params: ContactListParams, + ) -> None: + expected_response = { + "id": LIST_ID, + "name": "Updated Contact List", + } + responses.patch( + f"{BASE_CONTACT_LISTS_URL}/{LIST_ID}", + json=expected_response, + status=200, + ) + + contact_list = contact_lists_api.update(LIST_ID, update_contact_list_params) + + assert isinstance(contact_list, ContactList) + assert contact_list.id == LIST_ID + assert contact_list.name == "Updated Contact List" + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_delete_contact_list_should_raise_api_errors( + self, + contact_lists_api: ContactListsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.delete( + f"{BASE_CONTACT_LISTS_URL}/{LIST_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contact_lists_api.delete(LIST_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_contact_list_should_return_deleted_object( + self, contact_lists_api: ContactListsApi + ) -> None: + responses.delete( + f"{BASE_CONTACT_LISTS_URL}/{LIST_ID}", + status=204, + ) + + deleted_object = contact_lists_api.delete(LIST_ID) + + assert isinstance(deleted_object, DeletedObject) + assert deleted_object.id == LIST_ID diff --git a/tests/unit/models/test_contacts.py b/tests/unit/models/test_contacts.py index a0b6755..fc24946 100644 --- a/tests/unit/models/test_contacts.py +++ b/tests/unit/models/test_contacts.py @@ -1,5 +1,6 @@ import pytest +from mailtrap.models.contacts import ContactListParams from mailtrap.models.contacts import CreateContactFieldParams from mailtrap.models.contacts import UpdateContactFieldParams @@ -50,3 +51,12 @@ def test_update_contact_field_params_api_data_with_all_values(self) -> None: api_data = params.api_data assert api_data == {"name": "Updated Field", "merge_tag": "updated_field"} + + +class TestContactListParams: + def test_create_contact_field_params_api_data_should_return_correct_dict( + self, + ) -> None: + params = ContactListParams(name="Test List") + api_data = params.api_data + assert api_data == {"name": "Test List"}