diff --git a/examples/contacts/contacts.py b/examples/contacts/contacts.py new file mode 100644 index 0000000..6649055 --- /dev/null +++ b/examples/contacts/contacts.py @@ -0,0 +1,72 @@ +from typing import Optional +from typing import Union + +import mailtrap as mt +from mailtrap.models.common import DeletedObject +from mailtrap.models.contacts import Contact + +API_TOKEN = "YOU_API_TOKEN" +ACCOUNT_ID = "YOU_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +contacts_api = client.contacts_api.contacts + + +def create_contact( + email: str, + fields: Optional[dict[str, Union[str, int, float, bool]]] = None, + list_ids: Optional[list[int]] = None, +) -> Contact: + params = mt.CreateContactParams( + email=email, + fields=fields, + list_ids=list_ids, + ) + return contacts_api.create(params) + + +def update_contact( + contact_id_or_email: str, + new_email: Optional[str] = None, + fields: Optional[dict[str, Union[str, int, float, bool]]] = None, + list_ids_included: Optional[list[int]] = None, + list_ids_excluded: Optional[list[int]] = None, + unsubscribed: Optional[bool] = None, +) -> Contact: + params = mt.UpdateContactParams( + email=new_email, + fields=fields, + list_ids_included=list_ids_included, + list_ids_excluded=list_ids_excluded, + unsubscribed=unsubscribed, + ) + return contacts_api.update(contact_id_or_email, params) + + +def get_contact(contact_id_or_email: str) -> Contact: + return contacts_api.get_by_id(contact_id_or_email) + + +def delete_contact(contact_id_or_email: str) -> DeletedObject: + return contacts_api.delete(contact_id_or_email) + + +if __name__ == "__main__": + created_contact = create_contact( + email="testemail@test.com", + fields={ + "first_name": "Test", + "last_name": "Test", + }, + ) + print(created_contact) + updated_contact = update_contact( + created_contact.id, + fields={ + "first_name": "John", + "last_name": "Doe", + }, + ) + print(updated_contact) + deleted_contact = delete_contact(updated_contact.id) + print(deleted_contact) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 9f9201a..be406e3 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -4,10 +4,11 @@ from .exceptions import AuthorizationError 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 CreateContactParams from .models.contacts import UpdateContactFieldParams +from .models.contacts import UpdateContactParams from .models.mail import Address from .models.mail import Attachment from .models.mail import BaseMail diff --git a/mailtrap/api/contacts.py b/mailtrap/api/contacts.py index baca27e..ec5820b 100644 --- a/mailtrap/api/contacts.py +++ b/mailtrap/api/contacts.py @@ -1,5 +1,6 @@ from mailtrap.api.resources.contact_fields import ContactFieldsApi from mailtrap.api.resources.contact_lists import ContactListsApi +from mailtrap.api.resources.contacts import ContactsApi from mailtrap.http import HttpClient @@ -15,3 +16,7 @@ def contact_fields(self) -> ContactFieldsApi: @property def contact_lists(self) -> ContactListsApi: return ContactListsApi(account_id=self._account_id, client=self._client) + + @property + def contacts(self) -> ContactsApi: + return ContactsApi(account_id=self._account_id, client=self._client) diff --git a/mailtrap/api/resources/contacts.py b/mailtrap/api/resources/contacts.py new file mode 100644 index 0000000..e160d66 --- /dev/null +++ b/mailtrap/api/resources/contacts.py @@ -0,0 +1,45 @@ +from typing import Optional +from urllib.parse import quote + +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.contacts import Contact +from mailtrap.models.contacts import ContactResponse +from mailtrap.models.contacts import CreateContactParams +from mailtrap.models.contacts import UpdateContactParams + + +class ContactsApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + def get_by_id(self, contact_id_or_email: str) -> Contact: + response = self._client.get(self._api_path(contact_id_or_email)) + return ContactResponse(**response).data + + def create(self, contact_params: CreateContactParams) -> Contact: + response = self._client.post( + self._api_path(), + json={"contact": contact_params.api_data}, + ) + return ContactResponse(**response).data + + def update( + self, contact_id_or_email: str, contact_params: UpdateContactParams + ) -> Contact: + response = self._client.patch( + self._api_path(contact_id_or_email), + json={"contact": contact_params.api_data}, + ) + return ContactResponse(**response).data + + def delete(self, contact_id_or_email: str) -> DeletedObject: + self._client.delete(self._api_path(contact_id_or_email)) + return DeletedObject(contact_id_or_email) + + def _api_path(self, contact_id_or_email: Optional[str] = None) -> str: + path = f"/api/accounts/{self._account_id}/contacts" + if contact_id_or_email is not None: + return f"{path}/{quote(contact_id_or_email, safe='')}" + return path diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py index 42fdf82..4bb767a 100644 --- a/mailtrap/models/common.py +++ b/mailtrap/models/common.py @@ -1,5 +1,6 @@ from typing import Any from typing import TypeVar +from typing import Union from typing import cast from pydantic import TypeAdapter @@ -20,4 +21,4 @@ def api_data(self: T) -> dict[str, Any]: @dataclass class DeletedObject: - id: int + id: Union[int, str] diff --git a/mailtrap/models/contacts.py b/mailtrap/models/contacts.py index dd4bc17..5e5995b 100644 --- a/mailtrap/models/contacts.py +++ b/mailtrap/models/contacts.py @@ -1,4 +1,6 @@ +from enum import Enum from typing import Optional +from typing import Union from pydantic.dataclasses import dataclass @@ -39,3 +41,57 @@ class ContactListParams(RequestParams): class ContactList: id: int name: str + + +class ContactStatus(str, Enum): + SUBSCRIBED = "subscribed" + UNSUBSCRIBED = "unsubscribed" + + +@dataclass +class CreateContactParams(RequestParams): + email: str + fields: Optional[dict[str, Union[str, int, float, bool]]] = ( + None # field_merge_tag: value + ) + list_ids: Optional[list[int]] = None + + +@dataclass +class UpdateContactParams(RequestParams): + email: Optional[str] = None + fields: Optional[dict[str, Union[str, int, float, bool]]] = ( + None # field_merge_tag: value + ) + list_ids_included: Optional[list[int]] = None + list_ids_excluded: Optional[list[int]] = None + unsubscribed: Optional[bool] = None + + def __post_init__(self) -> None: + if all( + value is None + for value in [ + self.email, + self.fields, + self.list_ids_included, + self.list_ids_excluded, + self.unsubscribed, + ] + ): + raise ValueError("At least one field must be provided for update action") + + +@dataclass +class Contact: + id: str + email: str + fields: dict[str, Union[str, int, float, bool]] # field_merge_tag: value + list_ids: list[int] + status: ContactStatus + created_at: int + updated_at: int + + +@dataclass +class ContactResponse: + data: Contact diff --git a/tests/conftest.py b/tests/conftest.py index 3324ff3..0b82b00 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,3 +9,13 @@ NOT_FOUND_STATUS_CODE = 404 NOT_FOUND_ERROR_MESSAGE = "Not Found" NOT_FOUND_RESPONSE = {"error": NOT_FOUND_ERROR_MESSAGE} + +RATE_LIMIT_ERROR_STATUS_CODE = 429 +RATE_LIMIT_ERROR_MESSAGE = "Rate limit exceeded" +RATE_LIMIT_ERROR_RESPONSE = {"errors": RATE_LIMIT_ERROR_MESSAGE} + +INTERNAL_SERVER_ERROR_STATUS_CODE = 500 +INTERNAL_SERVER_ERROR_MESSAGE = "Unexpected error" +INTERNAL_SERVER_ERROR_RESPONSE = {"errors": "Unexpected error"} + +VALIDATION_ERRORS_STATUS_CODE = 422 diff --git a/tests/unit/api/test_contact_fields.py b/tests/unit/api/test_contact_fields.py index c9b0e7c..4764fd4 100644 --- a/tests/unit/api/test_contact_fields.py +++ b/tests/unit/api/test_contact_fields.py @@ -18,6 +18,18 @@ BASE_CONTACT_FIELDS_URL = ( f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/contacts/fields" ) +VALIDATION_ERRORS_RESPONSE = { + "errors": { + "name": [["is too long (maximum is 80 characters)", "has already been taken"]], + "merge_tag": [ + ["is too long (maximum is 80 characters)", "has already been taken"] + ], + } +} +VALIDATION_ERRORS_MESSAGE = ( + "name: ['is too long (maximum is 80 characters)', 'has already been taken']; " + "merge_tag: ['is too long (maximum is 80 characters)', 'has already been taken']" +) @pytest.fixture @@ -62,6 +74,16 @@ class TestContactsApi: conftest.FORBIDDEN_RESPONSE, conftest.FORBIDDEN_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -117,6 +139,16 @@ def test_get_contact_fields_should_return_contact_field_list( conftest.NOT_FOUND_RESPONSE, conftest.NOT_FOUND_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -169,6 +201,21 @@ def test_get_contact_field_should_return_contact_field( conftest.FORBIDDEN_RESPONSE, conftest.FORBIDDEN_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + VALIDATION_ERRORS_RESPONSE, + VALIDATION_ERRORS_MESSAGE, + ), ], ) @responses.activate @@ -235,6 +282,21 @@ def test_create_contact_field_should_return_created_contact_field( conftest.NOT_FOUND_RESPONSE, conftest.NOT_FOUND_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + VALIDATION_ERRORS_RESPONSE, + VALIDATION_ERRORS_MESSAGE, + ), ], ) @responses.activate @@ -301,6 +363,39 @@ def test_update_contact_field_should_return_updated_contact_field( conftest.NOT_FOUND_RESPONSE, conftest.NOT_FOUND_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + { + "errors": { + "usage": [ + ( + "This field is used in the steps of automation(s): " + "%{automation names}." + ), + ( + "This field is used in the conditions of segment(s): " + "{segment names}." + ), + ] + } + }, + ( + "usage: This field is used in the steps of automation(s): " + "%{automation names}.; " + "usage: This field is used in the conditions of segment(s): " + "{segment names}." + ), + ), ], ) @responses.activate diff --git a/tests/unit/api/test_contact_lists.py b/tests/unit/api/test_contact_lists.py index 9bc18e8..bed2057 100644 --- a/tests/unit/api/test_contact_lists.py +++ b/tests/unit/api/test_contact_lists.py @@ -57,6 +57,16 @@ class TestContactListsApi: conftest.FORBIDDEN_RESPONSE, conftest.FORBIDDEN_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -114,6 +124,16 @@ def test_get_contact_lists_should_return_contact_list_list( conftest.NOT_FOUND_RESPONSE, conftest.NOT_FOUND_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -164,6 +184,16 @@ def test_get_contact_list_should_return_contact_list( conftest.FORBIDDEN_RESPONSE, conftest.FORBIDDEN_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -226,6 +256,16 @@ def test_create_contact_list_should_return_created_contact_list( conftest.NOT_FOUND_RESPONSE, conftest.NOT_FOUND_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -288,6 +328,16 @@ def test_update_contact_list_should_return_updated_contact_list( conftest.NOT_FOUND_RESPONSE, conftest.NOT_FOUND_ERROR_MESSAGE, ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), ], ) @responses.activate diff --git a/tests/unit/api/test_contacts.py b/tests/unit/api/test_contacts.py new file mode 100644 index 0000000..3d39bb7 --- /dev/null +++ b/tests/unit/api/test_contacts.py @@ -0,0 +1,365 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.contacts import ContactsApi +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 Contact +from mailtrap.models.contacts import ContactStatus +from mailtrap.models.contacts import CreateContactParams +from mailtrap.models.contacts import UpdateContactParams +from tests import conftest + +ACCOUNT_ID = "321" +CONTACT_ID = "018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132" +BASE_CONTACTS_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/contacts" + + +@pytest.fixture +def contacts_api() -> ContactsApi: + return ContactsApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_contact_dict() -> dict[str, Any]: + return { + "data": { + "id": CONTACT_ID, + "status": "subscribed", + "email": "john.smith@example.com", + "fields": {"first_name": "John", "last_name": "Smith"}, + "list_ids": [1, 2, 3], + "created_at": 1742820600230, + "updated_at": 1742820600230, + } + } + + +@pytest.fixture +def create_contact_params() -> CreateContactParams: + return CreateContactParams( + email="john.smith@example.com", + fields={"first_name": "John", "last_name": "Smith"}, + list_ids=[1, 2, 3], + ) + + +@pytest.fixture +def update_contact_params() -> UpdateContactParams: + return UpdateContactParams( + email="john.updated@example.com", + fields={"first_name": "John Updated", "last_name": "Smith Updated"}, + ) + + +class TestContactstApi: + + @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, + ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_contact_should_raise_api_errors( + self, + contacts_api: ContactsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + f"{BASE_CONTACTS_URL}/{CONTACT_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contacts_api.get_by_id(CONTACT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_contact_should_return_contact( + self, contacts_api: ContactsApi, sample_contact_dict: dict + ) -> None: + responses.get( + f"{BASE_CONTACTS_URL}/{CONTACT_ID}", + json=sample_contact_dict, + status=200, + ) + + contact = contacts_api.get_by_id(CONTACT_ID) + + assert isinstance(contact, Contact) + assert contact.id == CONTACT_ID + assert contact.email == "john.smith@example.com" + assert contact.status == ContactStatus.SUBSCRIBED + assert contact.fields["first_name"] == "John" + assert contact.fields["last_name"] == "Smith" + assert contact.list_ids == [1, 2, 3] + + @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.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), + ( + 409, + {"errors": "Contact exists"}, + "Contact exists", + ), + ( + 422, + {"errors": {"email": ["Email is invalid"]}}, + "Email is invalid", + ), + ], + ) + @responses.activate + def test_create_contact_should_raise_api_errors( + self, + contacts_api: ContactsApi, + create_contact_params: CreateContactParams, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post( + BASE_CONTACTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contacts_api.create(create_contact_params) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_contact_should_return_created_contact( + self, + contacts_api: ContactsApi, + create_contact_params: CreateContactParams, + ) -> None: + expected_response = { + "data": { + "id": CONTACT_ID, + "status": "subscribed", + "email": "john.smith@example.com", + "fields": {"first_name": "John", "last_name": "Smith"}, + "list_ids": [1, 2, 3], + "created_at": 1742820600230, + "updated_at": 1742820600230, + } + } + responses.post( + BASE_CONTACTS_URL, + json=expected_response, + status=201, + ) + + contact = contacts_api.create(create_contact_params) + + assert isinstance(contact, Contact) + assert contact.id == CONTACT_ID + assert contact.email == "john.smith@example.com" + assert contact.status == ContactStatus.SUBSCRIBED + assert contact.fields["first_name"] == "John" + assert contact.fields["last_name"] == "Smith" + assert contact.list_ids == [1, 2, 3] + + @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.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), + ( + 409, + {"errors": "Contact exists"}, + "Contact exists", + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + {"errors": {"email": [["is invalid"]], "birthdate": [["is invalid"]]}}, + "email: ['is invalid']; birthdate: ['is invalid']", + ), + ], + ) + @responses.activate + def test_update_contact_should_raise_api_errors( + self, + contacts_api: ContactsApi, + update_contact_params: UpdateContactParams, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.patch( + f"{BASE_CONTACTS_URL}/{CONTACT_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contacts_api.update(CONTACT_ID, update_contact_params) + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_update_contact_should_return_updated_contact( + self, + contacts_api: ContactsApi, + update_contact_params: UpdateContactParams, + ) -> None: + expected_response = { + "data": { + "id": CONTACT_ID, + "status": "subscribed", + "email": "john.updated@example.com", + "fields": {"first_name": "John Updated", "last_name": "Smith Updated"}, + "list_ids": [1, 2, 3], + "created_at": 1742820600230, + "updated_at": 1742820600230, + } + } + responses.patch( + f"{BASE_CONTACTS_URL}/{CONTACT_ID}", + json=expected_response, + status=200, + ) + + contact = contacts_api.update(CONTACT_ID, update_contact_params) + + assert isinstance(contact, Contact) + assert contact.id == CONTACT_ID + assert contact.email == "john.updated@example.com" + assert contact.status == ContactStatus.SUBSCRIBED + assert contact.fields["first_name"] == "John Updated" + assert contact.fields["last_name"] == "Smith Updated" + + @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, + ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.INTERNAL_SERVER_ERROR_STATUS_CODE, + conftest.INTERNAL_SERVER_ERROR_RESPONSE, + conftest.INTERNAL_SERVER_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_delete_contact_should_raise_api_errors( + self, + contacts_api: ContactsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.delete( + f"{BASE_CONTACTS_URL}/{CONTACT_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + contacts_api.delete(CONTACT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_contact_should_return_deleted_object( + self, contacts_api: ContactsApi + ) -> None: + responses.delete( + f"{BASE_CONTACTS_URL}/{CONTACT_ID}", + status=204, + ) + + deleted_object = contacts_api.delete(CONTACT_ID) + + assert isinstance(deleted_object, DeletedObject) + assert deleted_object.id == CONTACT_ID diff --git a/tests/unit/models/test_contacts.py b/tests/unit/models/test_contacts.py index fc24946..49b26ee 100644 --- a/tests/unit/models/test_contacts.py +++ b/tests/unit/models/test_contacts.py @@ -2,7 +2,9 @@ from mailtrap.models.contacts import ContactListParams from mailtrap.models.contacts import CreateContactFieldParams +from mailtrap.models.contacts import CreateContactParams from mailtrap.models.contacts import UpdateContactFieldParams +from mailtrap.models.contacts import UpdateContactParams class TestCreateContactFieldParams: @@ -60,3 +62,61 @@ def test_create_contact_field_params_api_data_should_return_correct_dict( params = ContactListParams(name="Test List") api_data = params.api_data assert api_data == {"name": "Test List"} + + +class TestCreateContactParams: + def test_create_contact_params_api_data_should_exclude_none_values( + self, + ) -> None: + params = CreateContactParams(email="test@test.com") + api_data = params.api_data + assert api_data == {"email": "test@test.com"} + + def test_create_contact_params_api_data_should_return_correct_dicts( + self, + ) -> None: + params = CreateContactParams( + email="test@test.com", fields={"first_name": "Test"}, list_ids=[1] + ) + api_data = params.api_data + assert api_data == { + "email": "test@test.com", + "fields": {"first_name": "Test"}, + "list_ids": [1], + } + + +class TestUpdateContactParams: + def test_update_contact_params_with_none_values_should_raise_error( + self, + ) -> None: + with pytest.raises( + ValueError, match="At least one field must be provided for update action" + ): + _ = UpdateContactParams() + + def test_update_contact_params_api_data_should_exclude_none_values( + self, + ) -> None: + params = UpdateContactParams(email="test@test.com") + api_data = params.api_data + assert api_data == {"email": "test@test.com"} + + def test_update_contact_params_api_data_should_return_correct_dicts( + self, + ) -> None: + params = UpdateContactParams( + email="test@test.com", + fields={"first_name": "Test"}, + list_ids_included=[2], + list_ids_excluded=[1], + unsubscribed=False, + ) + api_data = params.api_data + assert api_data == { + "email": "test@test.com", + "fields": {"first_name": "Test"}, + "list_ids_included": [2], + "list_ids_excluded": [1], + "unsubscribed": False, + }