Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: update funding source expiry from concession group #36

Merged
merged 7 commits into from
Mar 21, 2024
19 changes: 19 additions & 0 deletions littlepay/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,22 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa
A TResponse instance of the JSON response.
"""
pass

def _put(self, endpoint: str, data: dict, response_cls: TResponse = ListResponse, **kwargs) -> TResponse:
"""Make a PUT request to a JSON endpoint.

Args:
self (ClientProtocol): The current ClientProtocol reference.

endpoint (str): The fully-formed endpoint where the PUT request should be made.

data (dict): Data to send as JSON in the PUT body.

response_cls (TResponse): A dataclass representing the JSON response to the PUT. By default, returns a ListResponse. # noqa

Extra kwargs are passed to requests.put(...)

Returns (TResponse):
A TResponse instance of the PUT response.
"""
pass
10 changes: 10 additions & 0 deletions littlepay/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,13 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa
except json.JSONDecodeError:
data = {"status_code": response.status_code}
return response_cls(**data)

def _put(self, endpoint: str, data: dict, response_cls: TResponse = ListResponse, **kwargs) -> TResponse:
response = self.oauth.put(endpoint, headers=self.headers, json=data, **kwargs)
response.raise_for_status()
try:
# response body may be empty, cannot be decoded
data = response.json()
except json.JSONDecodeError:
data = {"status_code": response.status_code}
return response_cls(**data)
44 changes: 42 additions & 2 deletions littlepay/api/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime, timezone
from typing import Generator

from littlepay.api import ClientProtocol
from littlepay.api import ClientProtocol, ListResponse
from littlepay.api.funding_sources import FundingSourcesMixin


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


@dataclass
class GroupFundingSourceResponse:
id: str
participant_id: str
concession_expiry: datetime | None = None
concession_created_at: datetime | None = None
concession_updated_at: datetime | None = None

def __post_init__(self):
"""Parses any date parameters into Python datetime objects.

Includes a workaround for Python 3.10 where datetime.fromisoformat() can only parse the format output
by datetime.isoformat(), i.e. without a trailing 'Z' offset character and with UTC offset expressed
as +/-HH:mm

https://docs.python.org/3.11/library/datetime.html#datetime.datetime.fromisoformat
"""
if self.concession_expiry:
self.concession_expiry = datetime.fromisoformat(self.concession_expiry.replace("Z", "+00:00", 1))
else:
self.concession_expiry = None
if self.concession_created_at:
self.concession_created_at = datetime.fromisoformat(self.concession_created_at.replace("Z", "+00:00", 1))
else:
self.concession_created_at = None
if self.concession_updated_at:
self.concession_updated_at = datetime.fromisoformat(self.concession_updated_at.replace("Z", "+00:00", 1))
else:
self.concession_updated_at = None


class GroupsMixin(ClientProtocol):
"""Mixin implements APIs for concession groups."""

Expand Down Expand Up @@ -86,4 +117,13 @@ def link_concession_group_funding_source(

return self._post(endpoint, data, dict)

return self._post(endpoint, data, dict)
def update_concession_group_funding_source_expiry(
self, group_id: str, funding_source_id: str, concession_expiry: datetime
) -> GroupFundingSourceResponse:
"""Update the expiry of a funding source already linked to a concession group."""
endpoint = self.concession_group_funding_source_endpoint(group_id)
data = {"id": funding_source_id, "concession_expiry": self._format_concession_expiry(concession_expiry)}

response = self._put(endpoint, data, ListResponse)

return GroupFundingSourceResponse(**response.list[0])
79 changes: 72 additions & 7 deletions tests/api/test_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
import dataclasses
from json import JSONDecodeError
import time
from typing import Callable, Generator, TypeAlias
Expand Down Expand Up @@ -42,7 +42,7 @@ def mock_active_Config(mocker, credentials, token, url):
return config


@dataclass
@dataclasses.dataclass
class SampleResponse:
one: str
two: str
Expand All @@ -54,11 +54,6 @@ def SampleResponse_json():
return {"one": "single", "two": "double", "three": 3}


@pytest.fixture
def ListResponse_sample():
return ListResponse(list=[{"one": 1}, {"two": 2}, {"three": 3}], total_count=3)


@pytest.fixture
def default_list_params():
return dict(page=1, perPage=100)
Expand Down Expand Up @@ -372,3 +367,73 @@ def test_Client_post_error_status(mocker, make_client: ClientFunc, url):
client._post(url, data, dict)

req_spy.assert_called_once_with(url, headers=client.headers, json=data)


def test_Client_put(mocker, make_client: ClientFunc, url, SampleResponse_json):
client = make_client()
mock_response = mocker.Mock(
raise_for_status=mocker.Mock(return_value=False), json=mocker.Mock(return_value=SampleResponse_json)
)
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)

data = {"data": "123"}
result = client._put(url, data, SampleResponse)

req_spy.assert_called_once_with(url, headers=client.headers, json=data)
assert isinstance(result, SampleResponse)
assert result.one == "single"
assert result.two == "double"
assert result.three == 3


def test_Client_put_default_cls(mocker, make_client: ClientFunc, url, ListResponse_sample):
client = make_client()
mock_response = mocker.Mock(
raise_for_status=mocker.Mock(return_value=False),
json=mocker.Mock(return_value=dataclasses.asdict(ListResponse_sample)),
)
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)

data = {"data": "123"}
result = client._put(url, data)

req_spy.assert_called_once_with(url, headers=client.headers, json=data)
assert isinstance(result, ListResponse)
assert result.total_count == ListResponse_sample.total_count
assert len(result.list) == len(ListResponse_sample.list)

for list_item in result.list:
assert list_item == ListResponse_sample.list[result.list.index(list_item)]


def test_Client_put_empty_response(mocker, make_client: ClientFunc, url):
client = make_client()
mock_response = mocker.Mock(
# json() throws a JSONDecodeError, simulating an empty response
json=mocker.Mock(side_effect=JSONDecodeError("msg", "doc", 0)),
# raise_for_status() returns None
raise_for_status=mocker.Mock(return_value=False),
# fake a 201 status_code
status_code=201,
)
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)

data = {"data": "123"}

result = client._put(url, data, dict)

req_spy.assert_called_once_with(url, headers=client.headers, json=data)
assert result == {"status_code": 201}


def test_Client_put_error_status(mocker, make_client: ClientFunc, url):
client = make_client()
mock_response = mocker.Mock(raise_for_status=mocker.Mock(side_effect=HTTPError))
req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response)

data = {"data": "123"}

with pytest.raises(HTTPError):
client._put(url, data, dict)

req_spy.assert_called_once_with(url, headers=client.headers, json=data)
85 changes: 84 additions & 1 deletion tests/api/test_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,30 @@

import pytest

from littlepay.api.groups import GroupResponse, GroupsMixin
from littlepay.api import ListResponse
from littlepay.api.groups import GroupFundingSourceResponse, GroupResponse, GroupsMixin


@pytest.fixture
def ListResponse_GroupFundingSources():
items = [
dict(
id="0",
participant_id="zero_0",
concession_expiry="2024-03-19T20:00:00Z",
concession_created_at="2024-03-19T20:00:00Z",
concession_updated_at="2024-03-19T20:00:00Z",
),
dict(
id="1",
participant_id="one_1",
concession_expiry="2024-03-19T20:00:00Z",
concession_created_at="2024-03-19T20:00:00Z",
concession_updated_at="2024-03-19T20:00:00Z",
),
dict(id="2", participant_id="two_2", concession_expiry="", concession_created_at=""),
]
return ListResponse(list=items, total_count=3)


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


@pytest.fixture
def mock_ClientProtocol_put_update_concession_group_funding_source(mocker, ListResponse_GroupFundingSources):
return mocker.patch(
"littlepay.api.ClientProtocol._put", side_effect=lambda *args, **kwargs: ListResponse_GroupFundingSources
)


def test_GroupResponse_csv():
group = GroupResponse("id", "label", "participant")
assert group.csv() == "id,label,participant"
Expand All @@ -40,6 +70,42 @@ def test_GroupResponse_csv_header():
assert GroupResponse.csv_header() == "id,label,participant_id"


def test_GroupFundingSourceResponse_no_dates():
response = GroupFundingSourceResponse("id", "participant_id")

assert response.id == "id"
assert response.participant_id == "participant_id"
assert response.concession_expiry is None
assert response.concession_created_at is None
assert response.concession_updated_at is None


def test_GroupFundingSourceResponse_empty_dates():
response = GroupFundingSourceResponse("id", "participant_id", "", "", "")

assert response.id == "id"
assert response.participant_id == "participant_id"
assert response.concession_expiry is None
assert response.concession_created_at is None
assert response.concession_updated_at is None


def test_GroupFundingSourceResponse_with_dates():
str_date = "2024-03-19T20:00:00Z"
expected_date = datetime(2024, 3, 19, 20, 0, 0, tzinfo=timezone.utc)

response = GroupFundingSourceResponse("id", "participant_id", str_date, str_date, str_date)

assert response.id == "id"
assert response.participant_id == "participant_id"
assert response.concession_expiry == expected_date
assert response.concession_expiry.tzinfo == timezone.utc
assert response.concession_created_at == expected_date
assert response.concession_created_at.tzinfo == timezone.utc
assert response.concession_updated_at == expected_date
assert response.concession_updated_at.tzinfo == timezone.utc


def test_GroupsMixin_concession_groups_endpoint(url):
client = GroupsMixin()

Expand Down Expand Up @@ -171,3 +237,20 @@ def test_GroupsMixin_link_concession_group_funding_source_expiry(
endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, dict
)
assert result == {"status_code": 201}


def test_GroupsMixin_update_concession_group_funding_source_expiry(
mock_ClientProtocol_put_update_concession_group_funding_source, ListResponse_GroupFundingSources, mocker
):
client = GroupsMixin()
mocker.patch.object(client, "_format_concession_expiry", return_value="formatted concession expiry")

result = client.update_concession_group_funding_source_expiry("group-1234", "funding-source-1234", datetime.now())

endpoint = client.concession_group_funding_source_endpoint("group-1234")
mock_ClientProtocol_put_update_concession_group_funding_source.assert_called_once_with(
endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, ListResponse
)

expected = GroupFundingSourceResponse(**ListResponse_GroupFundingSources.list[0])
assert result == expected
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pytest_socket import disable_socket

from littlepay import __version__
from littlepay.api import ListResponse
import littlepay.config
from littlepay.commands import RESULT_SUCCESS

Expand Down Expand Up @@ -140,3 +141,8 @@ def mock_ClientProtocol_make_endpoint(mocker, url):
mocker.patch(
"littlepay.api.ClientProtocol._make_endpoint", side_effect=lambda *args: f"{url}/{'/'.join([a for a in args if a])}"
)


@pytest.fixture
def ListResponse_sample():
return ListResponse(list=[{"one": 1}, {"two": 2}, {"three": 3}], total_count=3)
Loading