Skip to content

Commit c191030

Browse files
authored
Merge pull request #33 from foarsitter/development
feat: tests with mocked API responses
2 parents 49587d7 + 462b6ad commit c191030

File tree

9 files changed

+894
-771
lines changed

9 files changed

+894
-771
lines changed

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def coverage(session: Session) -> None:
184184

185185
@session(python=python_versions[0])
186186
def typeguard(session: Session) -> None:
187-
"""Runtime type checking using Typeguard."""
187+
"""Runtime model checking using Typeguard."""
188188
session.install(".")
189189
session.install("pytest", "typeguard", "pygments")
190190
session.run("pytest", f"--typeguard-packages={package}", *session.posargs)

poetry.lock

Lines changed: 701 additions & 701 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/checkedid/client.py

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
11
from json import JSONDecodeError
2+
from typing import Dict
23
from typing import List
34
from typing import Optional
4-
from typing import Union
5+
from typing import Type
6+
from typing import TypeVar
57

68
import httpx
79
from httpx import Request
810
from httpx import Response
911

1012
from . import models
13+
from .errors import CheckedIDAuthenticationError
14+
from .errors import CheckedIDError
15+
from .errors import CheckedIDNotFoundError
16+
from .errors import CheckedIDValidationError
17+
18+
19+
_T = TypeVar("_T")
1120

1221

1322
class Client:
23+
ERROR_RESPONSE_MAPPING: Dict[int, Type[CheckedIDError]] = {
24+
422: CheckedIDValidationError,
25+
403: CheckedIDAuthenticationError,
26+
404: CheckedIDNotFoundError,
27+
}
28+
1429
def __init__(self, customer_code: str, base_url: str = "https://api.checkedid.eu/"):
1530
self.httpx = httpx.Client(base_url=base_url, auth=self.authenticate_request)
1631
self.access_token: Optional[str] = None
@@ -21,39 +36,43 @@ def authenticate_request(self, request: Request) -> Request:
2136
request.headers["Authorization"] = f"Bearer {self.access_token}"
2237
return request
2338

39+
def process_response(
40+
self, response: Response, model: Type[_T], status_code_success: int = 200
41+
) -> Optional[_T]:
42+
if response.status_code == status_code_success:
43+
return model(**response.json())
44+
45+
self.handle_error_response(response)
46+
47+
return None
48+
2449
def oauth_token(
2550
self, grant_type: str, username: str, password: str
26-
) -> Union[models.OAuthToken, models.ErrorResponse]:
51+
) -> Optional[models.OAuthToken]:
2752
response = self.httpx.post(
2853
"/oauth/token",
2954
data={"grant_type": grant_type, "username": username, "password": password},
3055
)
3156

32-
if response.status_code == 200:
33-
typed_response = models.OAuthToken(**response.json())
57+
typed_response = self.process_response(response, models.OAuthToken)
3458

59+
if typed_response:
3560
self.access_token = typed_response.access_token
3661

3762
return typed_response
38-
else:
39-
return self.handle_error_response(response)
63+
return None
4064

41-
def invitation_status(
42-
self, invitation_code: str
43-
) -> Union[models.Invitation, models.ErrorResponse]:
65+
def invitation_status(self, invitation_code: str) -> Optional[models.Invitation]:
4466
response: Response = self.httpx.get(
4567
f"/result/status/{invitation_code}",
4668
headers={"Accept": "application/json"},
4769
)
4870

49-
if response.status_code == 200:
50-
return models.Invitation(**response.json())
51-
else:
52-
return self.handle_error_response(response)
71+
return self.process_response(response, models.Invitation)
5372

5473
def invitations_create(
5574
self, invitations: List[models.CreateInvitationRequest]
56-
) -> Union[models.CustomerDetails, models.ErrorResponse]:
75+
) -> Optional[models.CustomerDetails]:
5776
obj = models.CreateInvitationDetails(
5877
CustomerCode=self.customer_code, Invitations=invitations
5978
)
@@ -64,47 +83,53 @@ def invitations_create(
6483
headers={"Accept": "application/json", "Content-Type": "application/json"},
6584
)
6685

67-
if response.status_code == 200:
68-
return models.CustomerDetails(**response.json())
69-
else:
70-
return self.handle_error_response(response)
86+
return self.process_response(response, models.CustomerDetails)
7187

72-
def invitation_delete(
73-
self, invitation_code: str
74-
) -> Union[models.ErrorResponse, bool]:
88+
def invitation_delete(self, invitation_code: str) -> bool:
7589
response: Response = self.httpx.delete(
7690
f"/invitation/{self.customer_code}/{invitation_code}",
7791
headers={"Accept": "application/json"},
7892
)
7993

8094
if response.status_code == 200:
8195
return True
82-
else:
83-
return self.handle_error_response(response)
8496

85-
def dossier(
86-
self, dossier_number: str
87-
) -> Union[models.ReportResponse, models.ErrorResponse]:
97+
self.handle_error_response(response)
98+
99+
return False
100+
101+
def dossier(self, dossier_number: str) -> Optional[models.ReportResponse]:
88102
response = self.httpx.get(f"/report/{dossier_number}")
89103

90-
if response.status_code == 200:
91-
return models.ReportResponse(**response.json())
92-
return self.handle_error_response(response)
104+
return self.process_response(response, models.ReportResponse)
93105

94106
def dossier_with_scope(
95107
self, dossier_number: str, scope: str
96-
) -> Union[models.ReportDataV3, models.ErrorResponse]:
108+
) -> Optional[models.ReportDataV3]:
97109
response = self.httpx.get(f"/reportdata/{dossier_number}/{scope}")
98110

99-
if response.status_code == 200:
100-
return models.ReportDataV3(**response.json())
101-
return self.handle_error_response(response)
111+
return self.process_response(response, models.ReportDataV3)
112+
113+
def handle_error_response(self, response: Response) -> None:
114+
if response.status_code == 400:
115+
raise CheckedIDValidationError(
116+
response.text, status_code=response.status_code
117+
)
102118

103-
def handle_error_response(self, response: Response) -> models.ErrorResponse:
104119
try:
105120
json = response.json()
106121
except JSONDecodeError:
107122
json = {"message": response.text}
108123

109124
json["status_code"] = response.status_code
110-
return models.ErrorResponse(**json)
125+
126+
exception_type = self.map_exception(response)
127+
raise exception_type(
128+
status_code=response.status_code, json=json, message="Error from server"
129+
)
130+
131+
def map_exception(self, response: Response) -> Type[CheckedIDError]:
132+
exception_type = self.ERROR_RESPONSE_MAPPING.get(
133+
response.status_code, CheckedIDError
134+
)
135+
return exception_type

src/checkedid/errors.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from typing import Any
2+
from typing import Dict
3+
from typing import Optional
4+
5+
6+
class CheckedIDError(Exception):
7+
status_code: int
8+
json: Optional[Dict[str, Any]] = None
9+
10+
def __init__(
11+
self,
12+
message: str,
13+
status_code: int,
14+
json: Optional[Dict[str, Any]] = None,
15+
*args: str
16+
):
17+
super().__init__(message, *args)
18+
self.status_code = status_code
19+
self.json = json
20+
21+
22+
class CheckedIDValidationError(CheckedIDError):
23+
pass
24+
25+
26+
class CheckedIDNotFoundError(CheckedIDError):
27+
pass
28+
29+
30+
class CheckedIDAuthenticationError(CheckedIDError):
31+
pass

tests/conftest.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
import pytest
4+
from httpx import Response
45

56
from checkedid.client import Client
67
from checkedid.models import ErrorResponse
@@ -22,8 +23,23 @@ def client(customer_code) -> Client:
2223
return client
2324

2425

26+
@pytest.fixture
27+
def access_token_mock(respx_mock):
28+
respx_mock.post("").mock(
29+
return_value=Response(
30+
status_code=200,
31+
json={
32+
"access_token": "abc",
33+
"expires_in": 3600,
34+
"token_type": "Bearer",
35+
"refresh_token": "def",
36+
},
37+
)
38+
)
39+
40+
2541
@pytest.fixture()
26-
def auth_client(client: Client) -> Client:
42+
def auth_client(client: Client, access_token_mock) -> Client:
2743
response = client.oauth_token(
2844
"password", os.getenv("CHECKEDID_USERNAME"), os.getenv("CHECKEDID_PASSWORD")
2945
)

tests/test_dossiers.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,47 @@
1+
import pytest
12
from httpx import Response
23

4+
from checkedid import errors
35

4-
def test_dossier(auth_client):
5-
response = auth_client.dossier("100029-0000031")
66

7-
assert response.DossierNumber == "100029-0000031"
7+
def test_dossier(auth_client, respx_mock):
8+
dossier_number = "999999-8888800"
9+
respx_mock.get("").mock(
10+
return_value=Response(
11+
status_code=200, json={"DossierNumber": dossier_number, "ReportPDF": ""}
12+
)
13+
)
14+
response = auth_client.dossier(dossier_number)
15+
16+
assert response.DossierNumber == dossier_number
817

918

1019
def test_dossier_with_error(auth_client, respx_mock):
1120
respx_mock.get("").mock(return_value=Response(status_code=404))
12-
response = auth_client.dossier("does-not-exist")
13-
14-
assert response.status_code == 404
15-
16-
17-
def test_dossier_with_scope(auth_client):
21+
with pytest.raises(errors.CheckedIDError):
22+
auth_client.dossier("does-not-exist")
23+
24+
25+
def test_dossier_with_scope(auth_client, respx_mock):
26+
dossier_number = "999999-8888800"
27+
respx_mock.get("").mock(
28+
return_value=Response(
29+
status_code=200,
30+
json={
31+
"DossierNumber": dossier_number,
32+
"ReportPDF": "",
33+
"Authority": "Burg. van Groningen",
34+
},
35+
)
36+
)
1837
response = auth_client.dossier_with_scope("100029-0000031", "10")
1938

20-
assert response.DossierNumber == "100029-0000031"
39+
assert response.DossierNumber == dossier_number
2140
assert response.Authority == "Burg. van Groningen"
2241

2342

2443
def test_dossier_with_scope_with_error(auth_client, respx_mock):
2544
respx_mock.get("").mock(return_value=Response(status_code=404))
26-
response = auth_client.dossier_with_scope("does-not-exist", "10")
2745

28-
assert response.status_code == 404
46+
with pytest.raises(errors.CheckedIDError):
47+
auth_client.dossier_with_scope("does-not-exist", "10")

tests/test_invitation_status.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
1+
import pytest
12
from httpx import Response
23

4+
from checkedid import errors
35

4-
def test_invitation_status(auth_client):
5-
response = auth_client.invitation_status("15IMN4")
6+
7+
def test_invitation_status(auth_client, respx_mock):
8+
invitation_code = "4ZCNXF"
9+
10+
respx_mock.get("").mock(
11+
return_value=Response(
12+
status_code=200,
13+
json={"CustomerCode": 100029, "InvitationCode": invitation_code},
14+
)
15+
)
16+
17+
response = auth_client.invitation_status(invitation_code)
618

719
assert response.CustomerCode == 100029
20+
assert response.InvitationCode == invitation_code
821

922

1023
def test_invitation_status_not_found(auth_client, respx_mock):
1124
respx_mock.get("").mock(return_value=Response(status_code=404))
12-
response = auth_client.invitation_status("15IMN4")
1325

14-
assert response.status_code == 404
26+
with pytest.raises(errors.CheckedIDNotFoundError):
27+
auth_client.invitation_status("15IMN4")

tests/test_invitations.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
import pytest
2+
from httpx import Response
3+
4+
from checkedid import errors
5+
from checkedid.models import CreateInvitationDetails
16
from checkedid.models import CreateInvitationRequest
27

38

4-
def test_invitations_create_and_delete(auth_client, employee_code):
9+
def test_invitations_create_and_delete(
10+
auth_client, employee_code, respx_mock, customer_code
11+
):
512
details: CreateInvitationRequest = CreateInvitationRequest.construct()
613
details.EmployeeCode = employee_code
714
details.InviteeEmail = "[email protected]"
@@ -11,6 +18,17 @@ def test_invitations_create_and_delete(auth_client, employee_code):
1118
details.AppFlow = "10"
1219
details.PreferredLanguage = "nl"
1320

21+
respx_mock.post("").mock(
22+
return_value=Response(
23+
status_code=200,
24+
json=CreateInvitationDetails(
25+
CustomerCode=customer_code, Invitations=[details]
26+
).dict(),
27+
)
28+
)
29+
30+
respx_mock.delete("").mock(return_value=Response(status_code=200))
31+
1432
response = auth_client.invitations_create([details])
1533

1634
assert response.CustomerCode == 100029
@@ -19,14 +37,14 @@ def test_invitations_create_and_delete(auth_client, employee_code):
1937
assert auth_client.invitation_delete(response.Invitations[0].InvitationCode) is True
2038

2139

22-
def test_invitations_create_with_error(auth_client):
23-
response = auth_client.invitations_create([CreateInvitationRequest.construct()])
24-
25-
assert response.status_code == 422
26-
assert len(response.Errors) == 4
40+
def test_invitations_create_with_error(auth_client, respx_mock):
41+
respx_mock.post("").mock(return_value=Response(status_code=422))
2742

43+
with pytest.raises(errors.CheckedIDValidationError):
44+
auth_client.invitations_create([CreateInvitationRequest.construct()])
2845

29-
def test_invitation_delete_with_error(auth_client):
30-
response = auth_client.invitation_delete("xyz")
3146

32-
assert response.status_code == 404
47+
def test_invitation_delete_with_error(auth_client, respx_mock):
48+
respx_mock.delete("").mock(return_value=Response(status_code=404))
49+
with pytest.raises(errors.CheckedIDNotFoundError):
50+
auth_client.invitation_delete("xyz")

0 commit comments

Comments
 (0)