Skip to content

Commit 330428c

Browse files
authored
Feat: update funding source expiry from concession group (#36)
2 parents aaa4252 + 7bc81b9 commit 330428c

File tree

6 files changed

+233
-10
lines changed

6 files changed

+233
-10
lines changed

littlepay/api/__init__.py

+19
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,22 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa
9999
A TResponse instance of the JSON response.
100100
"""
101101
pass
102+
103+
def _put(self, endpoint: str, data: dict, response_cls: TResponse = ListResponse, **kwargs) -> TResponse:
104+
"""Make a PUT request to a JSON endpoint.
105+
106+
Args:
107+
self (ClientProtocol): The current ClientProtocol reference.
108+
109+
endpoint (str): The fully-formed endpoint where the PUT request should be made.
110+
111+
data (dict): Data to send as JSON in the PUT body.
112+
113+
response_cls (TResponse): A dataclass representing the JSON response to the PUT. By default, returns a ListResponse. # noqa
114+
115+
Extra kwargs are passed to requests.put(...)
116+
117+
Returns (TResponse):
118+
A TResponse instance of the PUT response.
119+
"""
120+
pass

littlepay/api/client.py

+10
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,13 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa
158158
except json.JSONDecodeError:
159159
data = {"status_code": response.status_code}
160160
return response_cls(**data)
161+
162+
def _put(self, endpoint: str, data: dict, response_cls: TResponse = ListResponse, **kwargs) -> TResponse:
163+
response = self.oauth.put(endpoint, headers=self.headers, json=data, **kwargs)
164+
response.raise_for_status()
165+
try:
166+
# response body may be empty, cannot be decoded
167+
data = response.json()
168+
except json.JSONDecodeError:
169+
data = {"status_code": response.status_code}
170+
return response_cls(**data)

littlepay/api/groups.py

+42-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime, timezone
33
from typing import Generator
44

5-
from littlepay.api import ClientProtocol
5+
from littlepay.api import ClientProtocol, ListResponse
66
from littlepay.api.funding_sources import FundingSourcesMixin
77

88

@@ -25,6 +25,37 @@ def csv_header() -> str:
2525
return ",".join(vars(instance).keys())
2626

2727

28+
@dataclass
29+
class GroupFundingSourceResponse:
30+
id: str
31+
participant_id: str
32+
concession_expiry: datetime | None = None
33+
concession_created_at: datetime | None = None
34+
concession_updated_at: datetime | None = None
35+
36+
def __post_init__(self):
37+
"""Parses any date parameters into Python datetime objects.
38+
39+
Includes a workaround for Python 3.10 where datetime.fromisoformat() can only parse the format output
40+
by datetime.isoformat(), i.e. without a trailing 'Z' offset character and with UTC offset expressed
41+
as +/-HH:mm
42+
43+
https://docs.python.org/3.11/library/datetime.html#datetime.datetime.fromisoformat
44+
"""
45+
if self.concession_expiry:
46+
self.concession_expiry = datetime.fromisoformat(self.concession_expiry.replace("Z", "+00:00", 1))
47+
else:
48+
self.concession_expiry = None
49+
if self.concession_created_at:
50+
self.concession_created_at = datetime.fromisoformat(self.concession_created_at.replace("Z", "+00:00", 1))
51+
else:
52+
self.concession_created_at = None
53+
if self.concession_updated_at:
54+
self.concession_updated_at = datetime.fromisoformat(self.concession_updated_at.replace("Z", "+00:00", 1))
55+
else:
56+
self.concession_updated_at = None
57+
58+
2859
class GroupsMixin(ClientProtocol):
2960
"""Mixin implements APIs for concession groups."""
3061

@@ -86,4 +117,13 @@ def link_concession_group_funding_source(
86117

87118
return self._post(endpoint, data, dict)
88119

89-
return self._post(endpoint, data, dict)
120+
def update_concession_group_funding_source_expiry(
121+
self, group_id: str, funding_source_id: str, concession_expiry: datetime
122+
) -> GroupFundingSourceResponse:
123+
"""Update the expiry of a funding source already linked to a concession group."""
124+
endpoint = self.concession_group_funding_source_endpoint(group_id)
125+
data = {"id": funding_source_id, "concession_expiry": self._format_concession_expiry(concession_expiry)}
126+
127+
response = self._put(endpoint, data, ListResponse)
128+
129+
return GroupFundingSourceResponse(**response.list[0])

tests/api/test_client.py

+72-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from dataclasses import dataclass
1+
import dataclasses
22
from json import JSONDecodeError
33
import time
44
from typing import Callable, Generator, TypeAlias
@@ -42,7 +42,7 @@ def mock_active_Config(mocker, credentials, token, url):
4242
return config
4343

4444

45-
@dataclass
45+
@dataclasses.dataclass
4646
class SampleResponse:
4747
one: str
4848
two: str
@@ -54,11 +54,6 @@ def SampleResponse_json():
5454
return {"one": "single", "two": "double", "three": 3}
5555

5656

57-
@pytest.fixture
58-
def ListResponse_sample():
59-
return ListResponse(list=[{"one": 1}, {"two": 2}, {"three": 3}], total_count=3)
60-
61-
6257
@pytest.fixture
6358
def default_list_params():
6459
return dict(page=1, perPage=100)
@@ -372,3 +367,73 @@ def test_Client_post_error_status(mocker, make_client: ClientFunc, url):
372367
client._post(url, data, dict)
373368

374369
req_spy.assert_called_once_with(url, headers=client.headers, json=data)
370+
371+
372+
def test_Client_put(mocker, make_client: ClientFunc, url, SampleResponse_json):
373+
client = make_client()
374+
mock_response = mocker.Mock(
375+
raise_for_status=mocker.Mock(return_value=False), json=mocker.Mock(return_value=SampleResponse_json)
376+
)
377+
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)
378+
379+
data = {"data": "123"}
380+
result = client._put(url, data, SampleResponse)
381+
382+
req_spy.assert_called_once_with(url, headers=client.headers, json=data)
383+
assert isinstance(result, SampleResponse)
384+
assert result.one == "single"
385+
assert result.two == "double"
386+
assert result.three == 3
387+
388+
389+
def test_Client_put_default_cls(mocker, make_client: ClientFunc, url, ListResponse_sample):
390+
client = make_client()
391+
mock_response = mocker.Mock(
392+
raise_for_status=mocker.Mock(return_value=False),
393+
json=mocker.Mock(return_value=dataclasses.asdict(ListResponse_sample)),
394+
)
395+
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)
396+
397+
data = {"data": "123"}
398+
result = client._put(url, data)
399+
400+
req_spy.assert_called_once_with(url, headers=client.headers, json=data)
401+
assert isinstance(result, ListResponse)
402+
assert result.total_count == ListResponse_sample.total_count
403+
assert len(result.list) == len(ListResponse_sample.list)
404+
405+
for list_item in result.list:
406+
assert list_item == ListResponse_sample.list[result.list.index(list_item)]
407+
408+
409+
def test_Client_put_empty_response(mocker, make_client: ClientFunc, url):
410+
client = make_client()
411+
mock_response = mocker.Mock(
412+
# json() throws a JSONDecodeError, simulating an empty response
413+
json=mocker.Mock(side_effect=JSONDecodeError("msg", "doc", 0)),
414+
# raise_for_status() returns None
415+
raise_for_status=mocker.Mock(return_value=False),
416+
# fake a 201 status_code
417+
status_code=201,
418+
)
419+
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)
420+
421+
data = {"data": "123"}
422+
423+
result = client._put(url, data, dict)
424+
425+
req_spy.assert_called_once_with(url, headers=client.headers, json=data)
426+
assert result == {"status_code": 201}
427+
428+
429+
def test_Client_put_error_status(mocker, make_client: ClientFunc, url):
430+
client = make_client()
431+
mock_response = mocker.Mock(raise_for_status=mocker.Mock(side_effect=HTTPError))
432+
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)
433+
434+
data = {"data": "123"}
435+
436+
with pytest.raises(HTTPError):
437+
client._put(url, data, dict)
438+
439+
req_spy.assert_called_once_with(url, headers=client.headers, json=data)

tests/api/test_groups.py

+84-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,30 @@
33

44
import pytest
55

6-
from littlepay.api.groups import GroupResponse, GroupsMixin
6+
from littlepay.api import ListResponse
7+
from littlepay.api.groups import GroupFundingSourceResponse, GroupResponse, GroupsMixin
8+
9+
10+
@pytest.fixture
11+
def ListResponse_GroupFundingSources():
12+
items = [
13+
dict(
14+
id="0",
15+
participant_id="zero_0",
16+
concession_expiry="2024-03-19T20:00:00Z",
17+
concession_created_at="2024-03-19T20:00:00Z",
18+
concession_updated_at="2024-03-19T20:00:00Z",
19+
),
20+
dict(
21+
id="1",
22+
participant_id="one_1",
23+
concession_expiry="2024-03-19T20:00:00Z",
24+
concession_created_at="2024-03-19T20:00:00Z",
25+
concession_updated_at="2024-03-19T20:00:00Z",
26+
),
27+
dict(id="2", participant_id="two_2", concession_expiry="", concession_created_at=""),
28+
]
29+
return ListResponse(list=items, total_count=3)
730

831

932
@pytest.fixture
@@ -28,6 +51,13 @@ def mock_ClientProtocol_post_link_concession_group_funding_source(mocker):
2851
return mocker.patch("littlepay.api.ClientProtocol._post", side_effect=lambda *args, **kwargs: response)
2952

3053

54+
@pytest.fixture
55+
def mock_ClientProtocol_put_update_concession_group_funding_source(mocker, ListResponse_GroupFundingSources):
56+
return mocker.patch(
57+
"littlepay.api.ClientProtocol._put", side_effect=lambda *args, **kwargs: ListResponse_GroupFundingSources
58+
)
59+
60+
3161
def test_GroupResponse_csv():
3262
group = GroupResponse("id", "label", "participant")
3363
assert group.csv() == "id,label,participant"
@@ -40,6 +70,42 @@ def test_GroupResponse_csv_header():
4070
assert GroupResponse.csv_header() == "id,label,participant_id"
4171

4272

73+
def test_GroupFundingSourceResponse_no_dates():
74+
response = GroupFundingSourceResponse("id", "participant_id")
75+
76+
assert response.id == "id"
77+
assert response.participant_id == "participant_id"
78+
assert response.concession_expiry is None
79+
assert response.concession_created_at is None
80+
assert response.concession_updated_at is None
81+
82+
83+
def test_GroupFundingSourceResponse_empty_dates():
84+
response = GroupFundingSourceResponse("id", "participant_id", "", "", "")
85+
86+
assert response.id == "id"
87+
assert response.participant_id == "participant_id"
88+
assert response.concession_expiry is None
89+
assert response.concession_created_at is None
90+
assert response.concession_updated_at is None
91+
92+
93+
def test_GroupFundingSourceResponse_with_dates():
94+
str_date = "2024-03-19T20:00:00Z"
95+
expected_date = datetime(2024, 3, 19, 20, 0, 0, tzinfo=timezone.utc)
96+
97+
response = GroupFundingSourceResponse("id", "participant_id", str_date, str_date, str_date)
98+
99+
assert response.id == "id"
100+
assert response.participant_id == "participant_id"
101+
assert response.concession_expiry == expected_date
102+
assert response.concession_expiry.tzinfo == timezone.utc
103+
assert response.concession_created_at == expected_date
104+
assert response.concession_created_at.tzinfo == timezone.utc
105+
assert response.concession_updated_at == expected_date
106+
assert response.concession_updated_at.tzinfo == timezone.utc
107+
108+
43109
def test_GroupsMixin_concession_groups_endpoint(url):
44110
client = GroupsMixin()
45111

@@ -171,3 +237,20 @@ def test_GroupsMixin_link_concession_group_funding_source_expiry(
171237
endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, dict
172238
)
173239
assert result == {"status_code": 201}
240+
241+
242+
def test_GroupsMixin_update_concession_group_funding_source_expiry(
243+
mock_ClientProtocol_put_update_concession_group_funding_source, ListResponse_GroupFundingSources, mocker
244+
):
245+
client = GroupsMixin()
246+
mocker.patch.object(client, "_format_concession_expiry", return_value="formatted concession expiry")
247+
248+
result = client.update_concession_group_funding_source_expiry("group-1234", "funding-source-1234", datetime.now())
249+
250+
endpoint = client.concession_group_funding_source_endpoint("group-1234")
251+
mock_ClientProtocol_put_update_concession_group_funding_source.assert_called_once_with(
252+
endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, ListResponse
253+
)
254+
255+
expected = GroupFundingSourceResponse(**ListResponse_GroupFundingSources.list[0])
256+
assert result == expected

tests/conftest.py

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pytest_socket import disable_socket
55

66
from littlepay import __version__
7+
from littlepay.api import ListResponse
78
import littlepay.config
89
from littlepay.commands import RESULT_SUCCESS
910

@@ -140,3 +141,8 @@ def mock_ClientProtocol_make_endpoint(mocker, url):
140141
mocker.patch(
141142
"littlepay.api.ClientProtocol._make_endpoint", side_effect=lambda *args: f"{url}/{'/'.join([a for a in args if a])}"
142143
)
144+
145+
146+
@pytest.fixture
147+
def ListResponse_sample():
148+
return ListResponse(list=[{"one": 1}, {"two": 2}, {"three": 3}], total_count=3)

0 commit comments

Comments
 (0)