Skip to content

Commit efa0737

Browse files
Ihor BilousIhor Bilous
authored andcommitted
Fix issue #29: Add tests for error responses, add user_input validation for create and update actions
1 parent 26d8c57 commit efa0737

File tree

6 files changed

+169
-17
lines changed

6 files changed

+169
-17
lines changed

examples/testing/projects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@ def find_project_by_name(project_name: str, projects: list[Project]) -> Optional
2424
projects = projects_api.get_list()
2525
project_id = find_project_by_name(project_name, projects)
2626
project = projects_api.get_by_id(project_id)
27-
updated_projected = projects_api.update(project_id, "Updated-project-name")
27+
updated_project = projects_api.update(project_id, "Updated-project-name")
2828
deleted_object = projects_api.delete(project_id)

mailtrap/api/resources/projects.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from mailtrap.http import HttpClient
22
from mailtrap.schemas.base import DeletedObject
33
from mailtrap.schemas.projects import Project
4+
from mailtrap.schemas.projects import ProjectInput
45

56

67
class ProjectsApi:
@@ -19,13 +20,15 @@ def get_by_id(self, project_id: int) -> Project:
1920
return Project(**response)
2021

2122
def create(self, project_name: str) -> Project:
23+
ProjectInput(name=project_name)
2224
response = self.client.post(
2325
f"/api/accounts/{self.account_id}/projects",
2426
json={"project": {"name": project_name}},
2527
)
2628
return Project(**response)
2729

2830
def update(self, project_id: int, project_name: str) -> Project:
31+
ProjectInput(name=project_name)
2932
response = self.client.patch(
3033
f"/api/accounts/{self.account_id}/projects/{project_id}",
3134
json={"project": {"name": project_name}},

mailtrap/http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ def _handle_failed_response(self, response: Response) -> NoReturn:
3232
status_code = response.status_code
3333
try:
3434
data = response.json()
35-
except ValueError:
36-
raise APIError(status_code, errors=["Unknown Error"])
35+
except ValueError as exc:
36+
raise APIError(status_code, errors=["Unknown Error"]) from exc
3737

3838
errors = _extract_errors(data)
3939

mailtrap/schemas/inboxes.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
21
from typing import Optional
2+
33
from pydantic import BaseModel
4+
45
from mailtrap.schemas.permissions import Permissions
56

67

@@ -26,5 +27,7 @@ class Inbox(BaseModel):
2627
pop3_ports: list[int]
2728
max_message_size: int
2829
permissions: Permissions
29-
password: Optional[str] = None # Password is only available if you have admin permissions for the inbox.
30+
password: Optional[str] = (
31+
None # Password is only available if you have admin permissions for the inbox.
32+
)
3033
last_message_sent_at: Optional[str] = None

mailtrap/schemas/projects.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from pydantic import BaseModel
2+
from pydantic import Field
3+
24
from mailtrap.schemas.inboxes import Inbox
35
from mailtrap.schemas.permissions import Permissions
46

@@ -14,3 +16,7 @@ class Project(BaseModel):
1416
share_links: ShareLinks
1517
inboxes: list[Inbox]
1618
permissions: Permissions
19+
20+
21+
class ProjectInput(BaseModel):
22+
name: str = Field(min_length=2, max_length=100)

tests/unit/api/test_projects.py

Lines changed: 152 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
import responses
5+
from pydantic import ValidationError
56

67
from mailtrap.api.resources.projects import ProjectsApi
78
from mailtrap.config import MAILTRAP_HOST
@@ -40,6 +41,34 @@ def sample_project_dict() -> dict[str, Any]:
4041

4142

4243
class TestProjectsApi:
44+
45+
@pytest.mark.parametrize(
46+
"status_code,response_json,expected_error_message",
47+
[
48+
(401, {"error": "Incorrect API token"}, "Incorrect API token"),
49+
(403, {"errors": "Access forbidden"}, "Access forbidden"),
50+
],
51+
)
52+
@responses.activate
53+
def test_get_list_should_raise_api_errors(
54+
self,
55+
client: ProjectsApi,
56+
status_code: int,
57+
response_json: dict,
58+
expected_error_message: str,
59+
) -> None:
60+
responses.add(
61+
responses.GET,
62+
BASE_PROJECTS_URL,
63+
status=status_code,
64+
json=response_json,
65+
)
66+
67+
with pytest.raises(APIError) as exc_info:
68+
client.get_list()
69+
70+
assert expected_error_message in str(exc_info.value)
71+
4372
@responses.activate
4473
def test_get_list_should_return_project_list(
4574
self, client: ProjectsApi, sample_project_dict: dict
@@ -57,20 +86,34 @@ def test_get_list_should_return_project_list(
5786
assert all(isinstance(p, Project) for p in projects)
5887
assert projects[0].id == PROJECT_ID
5988

89+
@pytest.mark.parametrize(
90+
"status_code,response_json,expected_error_message",
91+
[
92+
(401, {"error": "Incorrect API token"}, "Incorrect API token"),
93+
(403, {"errors": "Access forbidden"}, "Access forbidden"),
94+
(404, {"error": "Not Found"}, "Not Found"),
95+
],
96+
)
6097
@responses.activate
61-
def test_get_by_id_should_raise_not_found_error(self, client: ProjectsApi) -> None:
98+
def test_get_by_id_should_raise_api_errors(
99+
self,
100+
client: ProjectsApi,
101+
status_code: int,
102+
response_json: dict,
103+
expected_error_message: str,
104+
) -> None:
62105
url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}"
63106
responses.add(
64107
responses.GET,
65108
url,
66-
status=404,
67-
json={"error": "Not Found"},
109+
status=status_code,
110+
json=response_json,
68111
)
69112

70113
with pytest.raises(APIError) as exc_info:
71114
client.get_by_id(PROJECT_ID)
72115

73-
assert "Not Found" in str(exc_info)
116+
assert expected_error_message in str(exc_info.value)
74117

75118
@responses.activate
76119
def test_get_by_id_should_return_single_project(
@@ -89,6 +132,54 @@ def test_get_by_id_should_return_single_project(
89132
assert isinstance(project, Project)
90133
assert project.id == PROJECT_ID
91134

135+
@pytest.mark.parametrize(
136+
"status_code,response_json,expected_error_message",
137+
[
138+
(401, {"error": "Incorrect API token"}, "Incorrect API token"),
139+
(403, {"errors": "Access forbidden"}, "Access forbidden"),
140+
],
141+
)
142+
@responses.activate
143+
def test_create_should_raise_api_errors(
144+
self,
145+
client: ProjectsApi,
146+
status_code: int,
147+
response_json: dict,
148+
expected_error_message: str,
149+
) -> None:
150+
responses.add(
151+
responses.POST,
152+
BASE_PROJECTS_URL,
153+
status=status_code,
154+
json=response_json,
155+
)
156+
157+
with pytest.raises(APIError) as exc_info:
158+
client.create(project_name="New Project")
159+
160+
assert expected_error_message in str(exc_info.value)
161+
162+
@pytest.mark.parametrize(
163+
"project_name, expected_errors",
164+
[
165+
(None, ["Input should be a valid string"]),
166+
("", ["String should have at least 2 characters"]),
167+
("a", ["String should have at least 2 characters"]),
168+
("a" * 101, ["String should have at most 100 characters"]),
169+
],
170+
)
171+
def test_create_should_raise_validation_error_on_pydantic_validation(
172+
self, client: ProjectsApi, project_name: str, expected_errors: list[str]
173+
) -> None:
174+
with pytest.raises(ValidationError) as exc_info:
175+
client.create(project_name=project_name)
176+
177+
errors = exc_info.value.errors()
178+
error_messages = [err["msg"] for err in errors]
179+
180+
for expected_msg in expected_errors:
181+
assert any(expected_msg in actual_msg for actual_msg in error_messages)
182+
92183
@responses.activate
93184
def test_create_should_return_new_project(
94185
self, client: ProjectsApi, sample_project_dict: dict
@@ -105,20 +196,55 @@ def test_create_should_return_new_project(
105196
assert isinstance(project, Project)
106197
assert project.name == "Test Project"
107198

199+
@pytest.mark.parametrize(
200+
"status_code,response_json,expected_error_message",
201+
[
202+
(401, {"error": "Incorrect API token"}, "Incorrect API token"),
203+
(403, {"errors": "Access forbidden"}, "Access forbidden"),
204+
(404, {"error": "Not Found"}, "Not Found"),
205+
],
206+
)
108207
@responses.activate
109-
def test_update_should_raise_not_found_error(self, client: ProjectsApi) -> None:
208+
def test_update_should_raise_api_errors(
209+
self,
210+
client: ProjectsApi,
211+
status_code: int,
212+
response_json: dict,
213+
expected_error_message: str,
214+
) -> None:
110215
url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}"
111216
responses.add(
112217
responses.PATCH,
113218
url,
114-
status=404,
115-
json={"error": "Not Found"},
219+
status=status_code,
220+
json=response_json,
116221
)
117222

118223
with pytest.raises(APIError) as exc_info:
119224
client.update(PROJECT_ID, project_name="Update Project Name")
120225

121-
assert "Not Found" in str(exc_info)
226+
assert expected_error_message in str(exc_info.value)
227+
228+
@pytest.mark.parametrize(
229+
"project_name, expected_errors",
230+
[
231+
(None, ["Input should be a valid string"]),
232+
("", ["String should have at least 2 characters"]),
233+
("a", ["String should have at least 2 characters"]),
234+
("a" * 101, ["String should have at most 100 characters"]),
235+
],
236+
)
237+
def test_update_should_raise_validation_error_on_pydantic_validation(
238+
self, client: ProjectsApi, project_name: str, expected_errors: list[str]
239+
) -> None:
240+
with pytest.raises(ValidationError) as exc_info:
241+
client.update(project_id=PROJECT_ID, project_name=project_name)
242+
243+
errors = exc_info.value.errors()
244+
error_messages = [err["msg"] for err in errors]
245+
246+
for expected_msg in expected_errors:
247+
assert any(expected_msg in actual_msg for actual_msg in error_messages)
122248

123249
@responses.activate
124250
def test_update_should_return_updated_project(
@@ -141,20 +267,34 @@ def test_update_should_return_updated_project(
141267
assert isinstance(project, Project)
142268
assert project.name == updated_name
143269

270+
@pytest.mark.parametrize(
271+
"status_code,response_json,expected_error_message",
272+
[
273+
(401, {"error": "Incorrect API token"}, "Incorrect API token"),
274+
(403, {"errors": "Access forbidden"}, "Access forbidden"),
275+
(404, {"error": "Not Found"}, "Not Found"),
276+
],
277+
)
144278
@responses.activate
145-
def test_delete_should_raise_not_found_error(self, client: ProjectsApi) -> None:
279+
def test_delete_should_raise_api_errors(
280+
self,
281+
client: ProjectsApi,
282+
status_code: int,
283+
response_json: dict,
284+
expected_error_message: str,
285+
) -> None:
146286
url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}"
147287
responses.add(
148288
responses.DELETE,
149289
url,
150-
status=404,
151-
json={"error": "Not Found"},
290+
status=status_code,
291+
json=response_json,
152292
)
153293

154294
with pytest.raises(APIError) as exc_info:
155295
client.delete(PROJECT_ID)
156296

157-
assert "Not Found" in str(exc_info)
297+
assert expected_error_message in str(exc_info.value)
158298

159299
@responses.activate
160300
def test_delete_should_return_deleted_object(self, client: ProjectsApi) -> None:

0 commit comments

Comments
 (0)