From 6417fc0b1e7d712d0db64f12ffa79aa0f6f03d46 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Tue, 30 Sep 2025 23:38:51 +0300 Subject: [PATCH 1/2] Fix issue #42: Add GeneralApi, related models, examples, tests. --- examples/general/__init__.py | 0 examples/general/account_accesses.py | 23 ++ examples/general/accounts.py | 15 ++ examples/general/billing.py | 16 ++ examples/general/permissions.py | 29 +++ mailtrap/__init__.py | 2 + mailtrap/api/general.py | 26 +++ mailtrap/api/resources/account_accesses.py | 43 ++++ mailtrap/api/resources/accounts.py | 12 ++ mailtrap/api/resources/billing.py | 12 ++ mailtrap/api/resources/permissions.py | 39 ++++ mailtrap/client.py | 7 + mailtrap/models/accounts.py | 42 ++++ mailtrap/models/billing.py | 50 +++++ mailtrap/models/permissions.py | 26 +++ tests/unit/api/general/__init__.py | 0 .../unit/api/general/test_account_accesses.py | 201 ++++++++++++++++++ tests/unit/api/general/test_accounts.py | 98 +++++++++ tests/unit/api/general/test_billing.py | 104 +++++++++ tests/unit/api/general/test_permissions.py | 196 +++++++++++++++++ tests/unit/models/test_permissions.py | 24 +++ 21 files changed, 965 insertions(+) create mode 100644 examples/general/__init__.py create mode 100644 examples/general/account_accesses.py create mode 100644 examples/general/accounts.py create mode 100644 examples/general/billing.py create mode 100644 examples/general/permissions.py create mode 100644 mailtrap/api/general.py create mode 100644 mailtrap/api/resources/account_accesses.py create mode 100644 mailtrap/api/resources/accounts.py create mode 100644 mailtrap/api/resources/billing.py create mode 100644 mailtrap/api/resources/permissions.py create mode 100644 mailtrap/models/accounts.py create mode 100644 mailtrap/models/billing.py create mode 100644 tests/unit/api/general/__init__.py create mode 100644 tests/unit/api/general/test_account_accesses.py create mode 100644 tests/unit/api/general/test_accounts.py create mode 100644 tests/unit/api/general/test_billing.py create mode 100644 tests/unit/api/general/test_permissions.py create mode 100644 tests/unit/models/test_permissions.py diff --git a/examples/general/__init__.py b/examples/general/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/general/account_accesses.py b/examples/general/account_accesses.py new file mode 100644 index 0000000..893c089 --- /dev/null +++ b/examples/general/account_accesses.py @@ -0,0 +1,23 @@ +import mailtrap as mt +from mailtrap.models.accounts import AccountAccess +from mailtrap.models.common import DeletedObject + +API_TOKEN = "YOUR_API_TOKEN" +ACCOUNT_ID = "YOUR_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN) +account_accesses_api = client.general_api.account_accesses + + +def get_account_accesses(account_id: int) -> list[AccountAccess]: + return account_accesses_api.get_list(account_id=account_id) + + +def delete_account_access(account_id: int, account_access_id: int) -> DeletedObject: + return account_accesses_api.delete( + account_id=account_id, account_access_id=account_access_id + ) + + +if __name__ == "__main__": + print(get_account_accesses(ACCOUNT_ID)) diff --git a/examples/general/accounts.py b/examples/general/accounts.py new file mode 100644 index 0000000..6e3ee24 --- /dev/null +++ b/examples/general/accounts.py @@ -0,0 +1,15 @@ +import mailtrap as mt +from mailtrap.models.accounts import Account + +API_TOKEN = "YOUR_API_TOKEN" + +client = mt.MailtrapClient(token=API_TOKEN) +accounts_api = client.general_api.accounts + + +def get_accounts() -> list[Account]: + return accounts_api.get_list() + + +if __name__ == "__main__": + print(get_accounts()) diff --git a/examples/general/billing.py b/examples/general/billing.py new file mode 100644 index 0000000..cfade7b --- /dev/null +++ b/examples/general/billing.py @@ -0,0 +1,16 @@ +import mailtrap as mt +from mailtrap.models.billing import BillingCycleUsage + +API_TOKEN = "YOUR_API_TOKEN" +ACCOUNT_ID = "YOUR_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN) +billing_api = client.general_api.billing + + +def get_current_billing_usage(account_id: int) -> BillingCycleUsage: + return billing_api.get_current_billing_usage(account_id=account_id) + + +if __name__ == "__main__": + print(get_current_billing_usage(ACCOUNT_ID)) diff --git a/examples/general/permissions.py b/examples/general/permissions.py new file mode 100644 index 0000000..8c04db1 --- /dev/null +++ b/examples/general/permissions.py @@ -0,0 +1,29 @@ +import mailtrap as mt +from mailtrap.models.permissions import PermissionResource +from mailtrap.models.permissions import UpdatePermissionsResponse + +API_TOKEN = "YOUR_API_TOKEN" +ACCOUNT_ID = "YOUR_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN) +permissions_api = client.general_api.permissions + + +def get_permission_resources(account_id: int) -> list[PermissionResource]: + return permissions_api.get_resources(account_id=account_id) + + +def bulk_permissions_update( + account_id: int, + account_access_id: int, + permissions: list[mt.PermissionResourceParams], +) -> UpdatePermissionsResponse: + return permissions_api.bulk_permissions_update( + account_id=account_id, + account_access_id=account_access_id, + permissions=permissions, + ) + + +if __name__ == "__main__": + print(get_permission_resources(ACCOUNT_ID)) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index b1cfada..1ea679a 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -5,6 +5,7 @@ from .exceptions import AuthorizationError from .exceptions import ClientConfigurationError from .exceptions import MailtrapError +from .models.accounts import AccountAccessFilterParams from .models.contacts import ContactListParams from .models.contacts import CreateContactFieldParams from .models.contacts import CreateContactParams @@ -24,6 +25,7 @@ from .models.mail import Mail from .models.mail import MailFromTemplate from .models.messages import UpdateEmailMessageParams +from .models.permissions import PermissionResourceParams from .models.projects import ProjectParams from .models.templates import CreateEmailTemplateParams from .models.templates import UpdateEmailTemplateParams diff --git a/mailtrap/api/general.py b/mailtrap/api/general.py new file mode 100644 index 0000000..b47646e --- /dev/null +++ b/mailtrap/api/general.py @@ -0,0 +1,26 @@ +from mailtrap.api.resources.account_accesses import AccountAccessesApi +from mailtrap.api.resources.accounts import AccountsApi +from mailtrap.api.resources.billing import BillingApi +from mailtrap.api.resources.permissions import PermissionsApi +from mailtrap.http import HttpClient + + +class GeneralApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + @property + def accounts(self) -> AccountsApi: + return AccountsApi(client=self._client) + + @property + def account_accesses(self) -> AccountAccessesApi: + return AccountAccessesApi(client=self._client) + + @property + def billing(self) -> BillingApi: + return BillingApi(client=self._client) + + @property + def permissions(self) -> PermissionsApi: + return PermissionsApi(client=self._client) diff --git a/mailtrap/api/resources/account_accesses.py b/mailtrap/api/resources/account_accesses.py new file mode 100644 index 0000000..deb9c92 --- /dev/null +++ b/mailtrap/api/resources/account_accesses.py @@ -0,0 +1,43 @@ +from typing import Optional +from urllib.parse import quote + +from mailtrap.http import HttpClient +from mailtrap.models.accounts import AccountAccess +from mailtrap.models.accounts import AccountAccessFilterParams +from mailtrap.models.common import DeletedObject + + +class AccountAccessesApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + def get_list( + self, account_id: int, filter_params: Optional[AccountAccessFilterParams] = None + ) -> list[AccountAccess]: + """ + Get list of account accesses for which specifier_type is User or Invite. + You have to have account admin/owner permissions for this endpoint to work. + If you specify project_ids, inbox_ids or domain_ids, the endpoint will return + account accesses for these resources. + """ + response = self._client.get( + self._api_path(account_id), + params=filter_params.api_data if filter_params else None, + ) + return [AccountAccess(**account_access) for account_access in response] + + def delete(self, account_id: int, account_access_id: int) -> DeletedObject: + """ + If specifier type is User, it removes user permissions. + If specifier type is Invite or ApiToken, it removes specifier + along with permissions. You have to be an account admin/owner + for this method to work. + """ + self._client.delete(self._api_path(account_id, account_access_id)) + return DeletedObject(account_access_id) + + def _api_path(self, account_id: int, account_access_id: Optional[int] = None) -> str: + path = f"/api/accounts/{account_id}/account_accesses" + if account_access_id is not None: + return f"{path}/{quote(str(account_access_id), safe='')}" + return path diff --git a/mailtrap/api/resources/accounts.py b/mailtrap/api/resources/accounts.py new file mode 100644 index 0000000..82371ae --- /dev/null +++ b/mailtrap/api/resources/accounts.py @@ -0,0 +1,12 @@ +from mailtrap.http import HttpClient +from mailtrap.models.accounts import Account + + +class AccountsApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + def get_list(self) -> list[Account]: + """Get a list of your Mailtrap accounts.""" + response = self._client.get("/api/accounts") + return [Account(**account) for account in response] diff --git a/mailtrap/api/resources/billing.py b/mailtrap/api/resources/billing.py new file mode 100644 index 0000000..68825ed --- /dev/null +++ b/mailtrap/api/resources/billing.py @@ -0,0 +1,12 @@ +from mailtrap.http import HttpClient +from mailtrap.models.billing import BillingCycleUsage + + +class BillingApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + def get_current_billing_usage(self, account_id: int) -> BillingCycleUsage: + """Get current billing cycle usage for Email Testing and Email Sending.""" + response = self._client.get(f"/api/accounts/{account_id}/billing/usage") + return BillingCycleUsage(**response) diff --git a/mailtrap/api/resources/permissions.py b/mailtrap/api/resources/permissions.py new file mode 100644 index 0000000..5ef4414 --- /dev/null +++ b/mailtrap/api/resources/permissions.py @@ -0,0 +1,39 @@ +from mailtrap.http import HttpClient +from mailtrap.models.permissions import PermissionResource +from mailtrap.models.permissions import PermissionResourceParams +from mailtrap.models.permissions import UpdatePermissionsResponse + + +class PermissionsApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + def get_resources(self, account_id: int) -> list[PermissionResource]: + """ + Get all resources in your account (Inboxes, Projects, Domains, + Email Campaigns, Billing and Account itself) to which the token + has admin access. + """ + response = self._client.get(f"/api/accounts/{account_id}/permissions/resources") + return [PermissionResource(**resource) for resource in response] + + def bulk_permissions_update( + self, + account_id: int, + account_access_id: int, + permissions: list[PermissionResourceParams], + ) -> UpdatePermissionsResponse: + """ + Manage user or token permissions. For this endpoint, you should send + an array of objects (in JSON format) as the body of the request. + If you send a combination of resource_type and resource_id that already exists, + the permission is updated. If the combination doesn't exist, + the permission is created. + """ + response = self._client.put( + f"/api/accounts/{account_id}" + f"/account_accesses/{account_access_id}" + "/permissions/bulk", + json={"permissions": [resource.api_data for resource in permissions]}, + ) + return UpdatePermissionsResponse(**response) diff --git a/mailtrap/client.py b/mailtrap/client.py index 1881f0a..fb97a43 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -6,6 +6,7 @@ from pydantic import TypeAdapter from mailtrap.api.contacts import ContactsBaseApi +from mailtrap.api.general import GeneralApi from mailtrap.api.sending import SendingApi from mailtrap.api.suppressions import SuppressionsBaseApi from mailtrap.api.templates import EmailTemplatesApi @@ -53,6 +54,12 @@ def __init__( self._validate_itself() + @property + def general_api(self) -> GeneralApi: + return GeneralApi( + client=HttpClient(host=GENERAL_HOST, headers=self.headers), + ) + @property def testing_api(self) -> TestingApi: self._validate_account_id() diff --git a/mailtrap/models/accounts.py b/mailtrap/models/accounts.py new file mode 100644 index 0000000..b7dcaed --- /dev/null +++ b/mailtrap/models/accounts.py @@ -0,0 +1,42 @@ +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestParams +from mailtrap.models.permissions import Permissions + + +@dataclass +class AccountAccessFilterParams(RequestParams): + domain_ids: list[str] + inbox_ids: list[str] + project_ids: list[str] + + +@dataclass +class Account: + id: int + name: str + access_levels: list[int] + + +@dataclass +class Specifier: + id: int + email: str + name: str + two_factor_authentication_enabled: bool + + +@dataclass +class AccountAccessResource: + resource_id: int + resource_type: str + access_level: int + + +@dataclass +class AccountAccess: + id: int + specifier_type: str + specifier: Specifier + resources: list[AccountAccessResource] + permissions: Permissions diff --git a/mailtrap/models/billing.py b/mailtrap/models/billing.py new file mode 100644 index 0000000..e4d1962 --- /dev/null +++ b/mailtrap/models/billing.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from pydantic.dataclasses import dataclass + + +@dataclass +class MessagesCount: + current: int + limit: int + + +@dataclass +class UsageTesting: + sent_messages_count: MessagesCount + forwarded_messages_count: MessagesCount + + +@dataclass +class UsageSending: + sent_messages_count: MessagesCount + + +@dataclass +class Plan: + name: str + + +@dataclass +class Testing: + plan: Plan + usage: UsageTesting + + +@dataclass +class Sending: + plan: Plan + usage: UsageSending + + +@dataclass +class Billing: + cycle_start: datetime + cycle_end: datetime + + +@dataclass +class BillingCycleUsage: + billing: Billing + testing: Testing + sending: Sending diff --git a/mailtrap/models/permissions.py b/mailtrap/models/permissions.py index 30b1e74..d7f27c7 100644 --- a/mailtrap/models/permissions.py +++ b/mailtrap/models/permissions.py @@ -1,5 +1,9 @@ +from typing import Optional + from pydantic.dataclasses import dataclass +from mailtrap.models.common import RequestParams + @dataclass class Permissions: @@ -7,3 +11,25 @@ class Permissions: can_update: bool can_destroy: bool can_leave: bool + + +@dataclass +class PermissionResource: + id: int + name: str + type: str + access_level: int + resources: list["PermissionResource"] + + +@dataclass +class PermissionResourceParams(RequestParams): + resource_id: str + resource_type: str + access_level: Optional[str] = None + _destroy: Optional[bool] = None + + +@dataclass +class UpdatePermissionsResponse: + message: str diff --git a/tests/unit/api/general/__init__.py b/tests/unit/api/general/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/general/test_account_accesses.py b/tests/unit/api/general/test_account_accesses.py new file mode 100644 index 0000000..d306a41 --- /dev/null +++ b/tests/unit/api/general/test_account_accesses.py @@ -0,0 +1,201 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.account_accesses import AccountAccessesApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.accounts import AccountAccess +from mailtrap.models.accounts import AccountAccessFilterParams +from mailtrap.models.common import DeletedObject +from tests import conftest + +ACCOUNT_ID = 26730 +ACCOUNT_ACCESS_ID = 4788 +BASE_ACCOUNT_ACCESSES_URL = ( + f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/account_accesses" +) + + +@pytest.fixture +def client() -> AccountAccessesApi: + return AccountAccessesApi(client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_account_access_dict() -> dict[str, Any]: + return { + "id": ACCOUNT_ACCESS_ID, + "specifier_type": "User", + "specifier": { + "id": 1234, + "email": "user@example.com", + "name": "John Doe", + "two_factor_authentication_enabled": True, + }, + "resources": [ + {"resource_id": 1, "resource_type": "project", "access_level": 100} + ], + "permissions": { + "can_read": True, + "can_update": True, + "can_destroy": False, + "can_leave": False, + }, + } + + +@pytest.fixture +def sample_filter_params() -> AccountAccessFilterParams: + return AccountAccessFilterParams( + project_ids=["3938"], inbox_ids=["3757"], domain_ids=["3883"] + ) + + +class TestAccountAccessesApi: + + @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_list_should_raise_api_errors( + self, + client: AccountAccessesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_ACCOUNT_ACCESSES_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_list(ACCOUNT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_list_should_return_account_accesses_list( + self, client: AccountAccessesApi, sample_account_access_dict: dict + ) -> None: + responses.get( + BASE_ACCOUNT_ACCESSES_URL, + json=[sample_account_access_dict], + status=200, + ) + + account_accesses = client.get_list(ACCOUNT_ID) + + assert isinstance(account_accesses, list) + assert all(isinstance(access, AccountAccess) for access in account_accesses) + assert len(account_accesses) == 1 + assert account_accesses[0].id == ACCOUNT_ACCESS_ID + assert account_accesses[0].specifier_type == "User" + assert account_accesses[0].specifier.email == "user@example.com" + + @responses.activate + def test_get_list_with_filter_params_should_include_query_params( + self, + client: AccountAccessesApi, + sample_account_access_dict: dict, + sample_filter_params: AccountAccessFilterParams, + ) -> None: + responses.get( + BASE_ACCOUNT_ACCESSES_URL, + json=[sample_account_access_dict], + status=200, + ) + + client.get_list(ACCOUNT_ID, sample_filter_params) + + # Verify the request was made with correct parameters + assert len(responses.calls) == 1 + request = responses.calls[0].request + assert "project_ids=3938" in request.url + assert "inbox_ids=3757" in request.url + assert "domain_ids=3883" in request.url + + @responses.activate + def test_get_list_should_return_empty_list(self, client: AccountAccessesApi) -> None: + responses.get( + BASE_ACCOUNT_ACCESSES_URL, + json=[], + status=200, + ) + + account_accesses = client.get_list(ACCOUNT_ID) + + assert isinstance(account_accesses, list) + assert len(account_accesses) == 0 + + @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_should_raise_api_errors( + self, + client: AccountAccessesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + url = f"{BASE_ACCOUNT_ACCESSES_URL}/{ACCOUNT_ACCESS_ID}" + responses.delete( + url, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.delete(ACCOUNT_ID, ACCOUNT_ACCESS_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_should_return_deleted_object( + self, client: AccountAccessesApi + ) -> None: + url = f"{BASE_ACCOUNT_ACCESSES_URL}/{ACCOUNT_ACCESS_ID}" + responses.delete( + url, + status=200, + json={"id": ACCOUNT_ACCESS_ID}, + ) + + result = client.delete(ACCOUNT_ID, ACCOUNT_ACCESS_ID) + + assert isinstance(result, DeletedObject) + assert result.id == ACCOUNT_ACCESS_ID diff --git a/tests/unit/api/general/test_accounts.py b/tests/unit/api/general/test_accounts.py new file mode 100644 index 0000000..942e517 --- /dev/null +++ b/tests/unit/api/general/test_accounts.py @@ -0,0 +1,98 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.accounts import AccountsApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.accounts import Account +from tests import conftest + +BASE_ACCOUNTS_URL = f"https://{GENERAL_HOST}/api/accounts" + + +@pytest.fixture +def client() -> AccountsApi: + return AccountsApi(client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_account_dict() -> dict[str, Any]: + return {"id": 26730, "name": "James", "access_levels": [100]} + + +@pytest.fixture +def sample_accounts_list() -> list[dict[str, Any]]: + return [ + {"id": 26730, "name": "James", "access_levels": [100]}, + {"id": 26731, "name": "John", "access_levels": [1000]}, + ] + + +class TestAccountsApi: + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_list_should_raise_api_errors( + self, + client: AccountsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_ACCOUNTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_list() + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_list_should_return_accounts_list( + self, client: AccountsApi, sample_accounts_list: list[dict] + ) -> None: + responses.get( + BASE_ACCOUNTS_URL, + json=sample_accounts_list, + status=200, + ) + + accounts = client.get_list() + + assert isinstance(accounts, list) + assert all(isinstance(account, Account) for account in accounts) + assert len(accounts) == 2 + assert accounts[0].id == 26730 + assert accounts[0].name == "James" + assert accounts[0].access_levels == [100] + assert accounts[1].id == 26731 + assert accounts[1].name == "John" + assert accounts[1].access_levels == [1000] + + @responses.activate + def test_get_list_should_return_empty_list(self, client: AccountsApi) -> None: + responses.get( + BASE_ACCOUNTS_URL, + json=[], + status=200, + ) + + accounts = client.get_list() + + assert isinstance(accounts, list) + assert len(accounts) == 0 diff --git a/tests/unit/api/general/test_billing.py b/tests/unit/api/general/test_billing.py new file mode 100644 index 0000000..9b26d54 --- /dev/null +++ b/tests/unit/api/general/test_billing.py @@ -0,0 +1,104 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.billing import BillingApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.billing import BillingCycleUsage +from tests import conftest + +ACCOUNT_ID = 26730 +BASE_BILLING_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/billing/usage" + + +@pytest.fixture +def client() -> BillingApi: + return BillingApi(client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_billing_usage_dict() -> dict[str, Any]: + return { + "billing": { + "cycle_start": "2023-01-01T00:00:00Z", + "cycle_end": "2023-01-31T23:59:59Z", + }, + "testing": { + "plan": {"name": "Free"}, + "usage": { + "sent_messages_count": {"current": 100, "limit": 1000}, + "forwarded_messages_count": {"current": 5, "limit": 100}, + }, + }, + "sending": { + "plan": {"name": "Free"}, + "usage": {"sent_messages_count": {"current": 50, "limit": 500}}, + }, + } + + +class TestBillingApi: + + @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_current_billing_usage_should_raise_api_errors( + self, + client: BillingApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_BILLING_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_current_billing_usage(ACCOUNT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_current_billing_usage_should_return_billing_usage( + self, client: BillingApi, sample_billing_usage_dict: dict + ) -> None: + responses.get( + BASE_BILLING_URL, + json=sample_billing_usage_dict, + status=200, + ) + + billing_usage = client.get_current_billing_usage(ACCOUNT_ID) + + assert isinstance(billing_usage, BillingCycleUsage) + assert billing_usage.testing.plan.name == "Free" + assert billing_usage.testing.usage.sent_messages_count.current == 100 + assert billing_usage.testing.usage.sent_messages_count.limit == 1000 + assert billing_usage.testing.usage.forwarded_messages_count.current == 5 + assert billing_usage.testing.usage.forwarded_messages_count.limit == 100 + assert billing_usage.sending.plan.name == "Free" + assert billing_usage.sending.usage.sent_messages_count.current == 50 + assert billing_usage.sending.usage.sent_messages_count.limit == 500 diff --git a/tests/unit/api/general/test_permissions.py b/tests/unit/api/general/test_permissions.py new file mode 100644 index 0000000..fe1109b --- /dev/null +++ b/tests/unit/api/general/test_permissions.py @@ -0,0 +1,196 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.permissions import PermissionsApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.permissions import PermissionResource +from mailtrap.models.permissions import PermissionResourceParams +from mailtrap.models.permissions import UpdatePermissionsResponse +from tests import conftest + +ACCOUNT_ID = 26730 +ACCOUNT_ACCESS_ID = 5142 +BASE_PERMISSIONS_URL = ( + f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/permissions/resources" +) +BASE_BULK_PERMISSIONS_URL = ( + f"https://{GENERAL_HOST}/api/accounts" + f"/{ACCOUNT_ID}/account_accesses" + f"/{ACCOUNT_ACCESS_ID}/permissions/bulk" +) + + +@pytest.fixture +def client() -> PermissionsApi: + return PermissionsApi(client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_permission_resource_dict() -> dict[str, Any]: + return { + "id": 1, + "name": "Test Project", + "type": "project", + "access_level": 100, + "resources": [ + { + "id": 2, + "name": "Test Inbox", + "type": "inbox", + "access_level": 100, + "resources": [], + } + ], + } + + +@pytest.fixture +def sample_permission_params() -> list[PermissionResourceParams]: + return [ + PermissionResourceParams( + resource_id="3281", resource_type="account", access_level="viewer" + ), + PermissionResourceParams( + resource_id="3809", resource_type="inbox", _destroy=True + ), + ] + + +@pytest.fixture +def sample_update_permissions_response_dict() -> dict[str, Any]: + return {"message": "Permissions have been updated!"} + + +class TestPermissionsApi: + + @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_resources_should_raise_api_errors( + self, + client: PermissionsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_PERMISSIONS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_resources(ACCOUNT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_resources_should_return_permission_resources_list( + self, client: PermissionsApi, sample_permission_resource_dict: dict + ) -> None: + responses.get( + BASE_PERMISSIONS_URL, + json=[sample_permission_resource_dict], + status=200, + ) + + resources = client.get_resources(ACCOUNT_ID) + + assert isinstance(resources, list) + assert all(isinstance(resource, PermissionResource) for resource in resources) + assert len(resources) == 1 + assert resources[0].id == 1 + assert resources[0].name == "Test Project" + assert resources[0].type == "project" + assert resources[0].access_level == 100 + assert len(resources[0].resources) == 1 + assert resources[0].resources[0].name == "Test Inbox" + + @responses.activate + def test_get_resources_should_return_empty_list(self, client: PermissionsApi) -> None: + responses.get( + BASE_PERMISSIONS_URL, + json=[], + status=200, + ) + + resources = client.get_resources(ACCOUNT_ID) + + assert isinstance(resources, list) + assert len(resources) == 0 + + @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_bulk_permissions_update_should_raise_api_errors( + self, + client: PermissionsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.put( + BASE_BULK_PERMISSIONS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.bulk_permissions_update(ACCOUNT_ID, ACCOUNT_ACCESS_ID, []) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_bulk_permissions_update_should_return_update_response( + self, + client: PermissionsApi, + sample_permission_params: list[PermissionResourceParams], + sample_update_permissions_response_dict: dict, + ) -> None: + responses.put( + BASE_BULK_PERMISSIONS_URL, + json=sample_update_permissions_response_dict, + status=200, + ) + + result = client.bulk_permissions_update( + ACCOUNT_ID, ACCOUNT_ACCESS_ID, sample_permission_params + ) + + assert isinstance(result, UpdatePermissionsResponse) + assert result.message == "Permissions have been updated!" diff --git a/tests/unit/models/test_permissions.py b/tests/unit/models/test_permissions.py new file mode 100644 index 0000000..09a631f --- /dev/null +++ b/tests/unit/models/test_permissions.py @@ -0,0 +1,24 @@ +from mailtrap.models.permissions import PermissionResourceParams + + +class TestPermissionResourceParams: + def test_api_data_with_all_fields_should_return_complete_dict(self) -> None: + params = PermissionResourceParams( + resource_id="123", resource_type="inbox", access_level="admin", _destroy=True + ) + + api_data = params.api_data + + assert api_data == { + "resource_id": "123", + "resource_type": "inbox", + "access_level": "admin", + "_destroy": True, + } + + def test_api_data_with_required_fields_only_should_return_minimal_dict(self) -> None: + params = PermissionResourceParams(resource_id="456", resource_type="project") + + api_data = params.api_data + + assert api_data == {"resource_id": "456", "resource_type": "project"} From 3d491493405407d6dcb8d589da4fb46752c32ac3 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Wed, 1 Oct 2025 00:05:10 +0300 Subject: [PATCH 2/2] Fix issue #42: Make fields in AccountAccessesFiltersParams are optional --- mailtrap/models/accounts.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mailtrap/models/accounts.py b/mailtrap/models/accounts.py index b7dcaed..310d2f7 100644 --- a/mailtrap/models/accounts.py +++ b/mailtrap/models/accounts.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic.dataclasses import dataclass from mailtrap.models.common import RequestParams @@ -6,9 +8,9 @@ @dataclass class AccountAccessFilterParams(RequestParams): - domain_ids: list[str] - inbox_ids: list[str] - project_ids: list[str] + domain_ids: Optional[list[str]] = None + inbox_ids: Optional[list[str]] = None + project_ids: Optional[list[str]] = None @dataclass