diff --git a/.coveragerc b/.coveragerc index 462857c..5cf38fb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,6 @@ [run] source = pyseventeentrack - omit = + tests/* + examples/* pyseventeentrack/track.py diff --git a/README.md b/README.md index a5802e6..33fa6ae 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,20 @@ async def main() -> None: # Add new packages by tracking number await client.profile.add_package('', '') + # Archive / activate / delete packages + await client.profile.archive_package('') + await client.profile.activate_package('') + await client.profile.delete_package('') + + # Set tag type or carriers (internal tracking ID required) + await client.profile.set_tag_type('', '0') + await client.profile.set_carrier('', '', '') + + # Fetch and update order info (internal tracking ID required) + order_info = await client.profile.order_info_by_id('') + # >>> {'opn': 'Acme', 'ptoid': '123', 'pt': '01', 'otime': '2024-12-01'} + await client.profile.save_order_info('', opn='Acme', ptoid='123', pt='01', otime='2024-12-01') + asyncio.run(main()) ``` @@ -116,4 +130,3 @@ Each `Package` object has the following info: 9. Update `README.md` with any new documentation. 10. Add yourself to `AUTHORS.md`. 11. Submit a pull request! - diff --git a/pyseventeentrack/profile.py b/pyseventeentrack/profile.py index 1a4309e..eaa9544 100644 --- a/pyseventeentrack/profile.py +++ b/pyseventeentrack/profile.py @@ -22,6 +22,41 @@ def __init__(self, request: Callable[..., Coroutine]) -> None: self._request: Callable[..., Coroutine] = request self.account_id: Optional[str] = None + async def _buyer_api_call( + self, method: str, param: dict, log_message: str + ) -> dict: + """Call the buyer API and validate the response.""" + response: dict = await self._request( + "post", + API_URL_BUYER, + json={ + "version": "1.0", + "method": method, + "param": param, + }, + ) + + _LOGGER.debug(log_message, response) + + code = response.get("Code") + if code != 0: + raise RequestError(f"Non-zero status code in response: {code}") + + return response + + async def _get_package_by_tracking_number( + self, tracking_number: str + ) -> Package: + """Get a package by tracking number.""" + packages = await self.packages() + + try: + return next(p for p in packages if p.tracking_number == tracking_number) + except StopIteration as err: + raise InvalidTrackingNumberError( + f"Package not found by tracking number: {tracking_number}" + ) from err + async def login(self, email: str, password: str) -> bool: """Login to the profile.""" login_resp: dict = await self._request( @@ -121,22 +156,12 @@ async def add_package( self, tracking_number: str, friendly_name: Optional[str] = None ): """Add a package by tracking number to the tracking list.""" - add_resp: dict = await self._request( - "post", - API_URL_BUYER, - json={ - "version": "1.0", - "method": "AddTrackNo", - "param": {"TrackNos": [tracking_number]}, - }, + add_resp = await self._buyer_api_call( + "AddTrackNo", + {"TrackNos": [tracking_number]}, + "Add package response: %s", ) - _LOGGER.debug("Add package response: %s", add_resp) - - code = add_resp.get("Code") - if code != 0: - raise RequestError(f"Non-zero status code in response: {code}") - if not friendly_name: return @@ -159,49 +184,121 @@ async def set_friendly_name(self, internal_id: str, friendly_name: str): internal_id is not the tracking number, it's the ID of an existing package. """ - remark_resp: dict = await self._request( - "post", - API_URL_BUYER, - json={ - "version": "1.0", - "method": "SetTrackRemark", - "param": {"TrackInfoId": internal_id, "Remark": friendly_name}, - }, + await self._buyer_api_call( + "SetTrackRemark", + {"TrackInfoId": internal_id, "Remark": friendly_name}, + "Set friendly name response: %s", ) - _LOGGER.debug("Set friendly name response: %s", remark_resp) - - code = remark_resp.get("Code") - if code != 0: - raise RequestError(f"Non-zero status code in response: {code}") - async def archive_package(self, tracking_number: str): """Archive a package by tracking number.""" - packages = await self.packages() + package = await self._get_package_by_tracking_number(tracking_number) - try: - package = next(p for p in packages if p.tracking_number == tracking_number) - except StopIteration as err: - raise InvalidTrackingNumberError( - f"Package not found by tracking number: {tracking_number}" - ) from err + internal_id = package.id + + _LOGGER.debug("Found internal ID of package: %s", internal_id) + + await self._buyer_api_call( + "SetTrackArchived", + {"TrackInfoIds": [internal_id]}, + "Archive package response: %s", + ) + + async def activate_package(self, tracking_number: str): + """Activate (unarchive) a package by tracking number.""" + package = await self._get_package_by_tracking_number(tracking_number) internal_id = package.id _LOGGER.debug("Found internal ID of package: %s", internal_id) - archive_resp: dict = await self._request( - "post", - API_URL_BUYER, - json={ - "version": "1.0", - "method": "SetTrackArchived", - "param": {"TrackInfoIds": [internal_id]}, + await self._buyer_api_call( + "SetTrackActivate", + {"TrackInfoIds": [internal_id]}, + "Activate package response: %s", + ) + + async def delete_package(self, tracking_number: str): + """Delete a package by tracking number.""" + package = await self._get_package_by_tracking_number(tracking_number) + + internal_id = package.id + + _LOGGER.debug("Found internal ID of package: %s", internal_id) + + await self._buyer_api_call( + "DelTrackNo", + {"TrackInfoIds": [internal_id]}, + "Delete package response: %s", + ) + + async def set_tag_type(self, internal_id: str, tag: str): + """Set the tag type for an existing package.""" + await self._buyer_api_call( + "SetTrackTagType", + {"tid": internal_id, "tag": tag}, + "Set tag type response: %s", + ) + + async def set_carrier( + self, internal_id: str, first_carrier: str, second_carrier: str = "0" + ): + """Set the carrier(s) for an existing package.""" + await self._buyer_api_call( + "SetTrackCarrier", + { + "TrackInfoId": internal_id, + "FirstCarrier": first_carrier, + "SecondCarrier": second_carrier, }, + "Set carrier response: %s", ) - _LOGGER.debug("Archive package response: %s", archive_resp) + async def track_info_by_id(self, *track_info_ids: str, isa: bool = False) -> list: + """Get tracking info by internal tracking IDs.""" + if not track_info_ids: + return [] - code = archive_resp.get("Code") - if code != 0: - raise RequestError(f"Non-zero status code in response: {code}") + track_resp = await self._buyer_api_call( + "GetTrackInfoById", + {"isa": isa, "tids": list(track_info_ids)}, + "Track info by ID response: %s", + ) + + return track_resp.get("Json", []) + + async def order_info_by_id(self, internal_id: str) -> dict: + """Get order info by internal tracking ID.""" + order_resp = await self._buyer_api_call( + "GetOrderInfoById", + {"tid": internal_id}, + "Order info response: %s", + ) + + return order_resp.get("Json", {}).get("order", {}) + + async def save_order_info( + self, + internal_id: str, + *, + opn: Optional[str] = None, + ptoid: Optional[str] = None, + pt: Optional[str] = None, + otime: Optional[str] = None, + ): + """Save order info for an existing package.""" + param: dict = {"tid": internal_id} + optional_params = {"opn": opn, "ptoid": ptoid, "pt": pt, "otime": otime} + param.update( + { + key: value + for key, value in optional_params.items() + if value is not None + } + ) + + await self._buyer_api_call( + "SaveOrderInfo", + param, + "Save order info response: %s", + ) diff --git a/tests/fixtures/activate_package_response.json b/tests/fixtures/activate_package_response.json new file mode 100644 index 0000000..b3ca7b3 --- /dev/null +++ b/tests/fixtures/activate_package_response.json @@ -0,0 +1 @@ +{"Code": 0, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/activate_package_response_failure_response.json b/tests/fixtures/activate_package_response_failure_response.json new file mode 100644 index 0000000..227234b --- /dev/null +++ b/tests/fixtures/activate_package_response_failure_response.json @@ -0,0 +1 @@ +{"Code": 1, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/delete_package_response.json b/tests/fixtures/delete_package_response.json new file mode 100644 index 0000000..b3ca7b3 --- /dev/null +++ b/tests/fixtures/delete_package_response.json @@ -0,0 +1 @@ +{"Code": 0, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/delete_package_response_failure_response.json b/tests/fixtures/delete_package_response_failure_response.json new file mode 100644 index 0000000..227234b --- /dev/null +++ b/tests/fixtures/delete_package_response_failure_response.json @@ -0,0 +1 @@ +{"Code": 1, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/order_info_by_id_failure_response.json b/tests/fixtures/order_info_by_id_failure_response.json new file mode 100644 index 0000000..227234b --- /dev/null +++ b/tests/fixtures/order_info_by_id_failure_response.json @@ -0,0 +1 @@ +{"Code": 1, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/order_info_by_id_response.json b/tests/fixtures/order_info_by_id_response.json new file mode 100644 index 0000000..70f08b5 --- /dev/null +++ b/tests/fixtures/order_info_by_id_response.json @@ -0,0 +1 @@ +{"Code": 0, "Json": {"order": {"opn": "Acme", "ptoid": "123", "pt": "01", "otime": "2024-12-01"}}} \ No newline at end of file diff --git a/tests/fixtures/save_order_info_failure_response.json b/tests/fixtures/save_order_info_failure_response.json new file mode 100644 index 0000000..227234b --- /dev/null +++ b/tests/fixtures/save_order_info_failure_response.json @@ -0,0 +1 @@ +{"Code": 1, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/save_order_info_response.json b/tests/fixtures/save_order_info_response.json new file mode 100644 index 0000000..b3ca7b3 --- /dev/null +++ b/tests/fixtures/save_order_info_response.json @@ -0,0 +1 @@ +{"Code": 0, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/set_carrier_response.json b/tests/fixtures/set_carrier_response.json new file mode 100644 index 0000000..b3ca7b3 --- /dev/null +++ b/tests/fixtures/set_carrier_response.json @@ -0,0 +1 @@ +{"Code": 0, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/set_carrier_response_failure_response.json b/tests/fixtures/set_carrier_response_failure_response.json new file mode 100644 index 0000000..227234b --- /dev/null +++ b/tests/fixtures/set_carrier_response_failure_response.json @@ -0,0 +1 @@ +{"Code": 1, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/set_tag_type_response.json b/tests/fixtures/set_tag_type_response.json new file mode 100644 index 0000000..b3ca7b3 --- /dev/null +++ b/tests/fixtures/set_tag_type_response.json @@ -0,0 +1 @@ +{"Code": 0, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/set_tag_type_response_failure_response.json b/tests/fixtures/set_tag_type_response_failure_response.json new file mode 100644 index 0000000..227234b --- /dev/null +++ b/tests/fixtures/set_tag_type_response_failure_response.json @@ -0,0 +1 @@ +{"Code": 1, "Json": {}} \ No newline at end of file diff --git a/tests/fixtures/track_info_by_id_failure_response.json b/tests/fixtures/track_info_by_id_failure_response.json new file mode 100644 index 0000000..554b635 --- /dev/null +++ b/tests/fixtures/track_info_by_id_failure_response.json @@ -0,0 +1 @@ +{"Code": 1, "Json": []} \ No newline at end of file diff --git a/tests/fixtures/track_info_by_id_response.json b/tests/fixtures/track_info_by_id_response.json new file mode 100644 index 0000000..8a099a4 --- /dev/null +++ b/tests/fixtures/track_info_by_id_response.json @@ -0,0 +1 @@ +{"Code": 0, "Json": [{"FTrackInfoId": "1234567890987654321", "FPackageState": "10", "FTrackStateType": 2}]} \ No newline at end of file diff --git a/tests/test_encrypt.py b/tests/test_encrypt.py new file mode 100644 index 0000000..cd27bc1 --- /dev/null +++ b/tests/test_encrypt.py @@ -0,0 +1,22 @@ +"""Define tests for encryption utilities.""" + +import pytest + +from pyseventeentrack import encrypt + + +def test_rsa_encrypt_invalid_key(monkeypatch): + """Test rsa_encrypt raises when key is not RSA.""" + + class DummyKey: + """Non-RSA key placeholder.""" + + def fake_load_pem_public_key(*_args, **_kwargs): + return DummyKey() + + monkeypatch.setattr( + encrypt.serialization, "load_pem_public_key", fake_load_pem_public_key + ) + + with pytest.raises(TypeError): + encrypt.rsa_encrypt("password") diff --git a/tests/test_profile.py b/tests/test_profile.py index f3d0400..c30d45c 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -2,6 +2,7 @@ import aiohttp import pytest +import pytest_asyncio from pyseventeentrack import Client from pyseventeentrack.errors import InvalidTrackingNumberError, RequestError @@ -44,6 +45,24 @@ async def test_login_success(aresponses): assert login_result is True +@pytest_asyncio.fixture +async def authenticated_client(aresponses): + """Return an authenticated client.""" + aresponses.add( + "user.17track.net", + "/user-api/v1/sign-in-by-password", + "post", + aresponses.Response( + text=load_fixture("authentication_success_response.json"), status=200 + ), + ) + + async with aiohttp.ClientSession() as session: + client = Client(session=session) + await client.profile.login(TEST_EMAIL, TEST_PASSWORD) + yield client + + @pytest.mark.asyncio async def test_no_explicit_session(aresponses): """Test not providing an explicit aiohttp ClientSession.""" @@ -473,3 +492,312 @@ async def test_archive_package_error_response(aresponses): client = Client(session=session) await client.profile.login(TEST_EMAIL, TEST_PASSWORD) await client.profile.archive_package("1234567890987654321") + + +@pytest.mark.asyncio +async def test_activate_package(aresponses, authenticated_client): + """Test activating a package.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("packages_response.json"), status=200), + ) + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("activate_package_response.json"), status=200 + ), + ) + + res = await authenticated_client.profile.activate_package("1234567890987654321") + assert res is None + + +@pytest.mark.asyncio +async def test_activate_package_non_existing(aresponses, authenticated_client): + """Test activating a non existing package.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("packages_response.json"), status=200), + ) + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("activate_package_response.json"), status=200 + ), + ) + + with pytest.raises(InvalidTrackingNumberError): + await authenticated_client.profile.activate_package("1234567890987654321111") + + +@pytest.mark.asyncio +async def test_activate_package_error_response(aresponses, authenticated_client): + """Test activating a package with failed response.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("packages_response.json"), status=200), + ) + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("activate_package_response_failure_response.json"), + status=200, + ), + ) + + with pytest.raises(RequestError): + await authenticated_client.profile.activate_package("1234567890987654321") + + +@pytest.mark.asyncio +async def test_delete_package(aresponses, authenticated_client): + """Test deleting a package.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("packages_response.json"), status=200), + ) + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("delete_package_response.json"), status=200), + ) + + res = await authenticated_client.profile.delete_package("1234567890987654321") + assert res is None + + +@pytest.mark.asyncio +async def test_delete_package_non_existing(aresponses, authenticated_client): + """Test deleting a non existing package.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("packages_response.json"), status=200), + ) + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("delete_package_response.json"), status=200), + ) + + with pytest.raises(InvalidTrackingNumberError): + await authenticated_client.profile.delete_package("1234567890987654321111") + + +@pytest.mark.asyncio +async def test_delete_package_error_response(aresponses, authenticated_client): + """Test deleting a package with failed response.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("packages_response.json"), status=200), + ) + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("delete_package_response_failure_response.json"), + status=200, + ), + ) + + with pytest.raises(RequestError): + await authenticated_client.profile.delete_package("1234567890987654321") + + +@pytest.mark.asyncio +async def test_set_tag_type(aresponses, authenticated_client): + """Test setting tag type.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("set_tag_type_response.json"), status=200), + ) + + res = await authenticated_client.profile.set_tag_type("1234567890987654321", "0") + assert res is None + + +@pytest.mark.asyncio +async def test_set_tag_type_error_response(aresponses, authenticated_client): + """Test setting tag type with failed response.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("set_tag_type_response_failure_response.json"), + status=200, + ), + ) + + with pytest.raises(RequestError): + await authenticated_client.profile.set_tag_type("1234567890987654321", "0") + + +@pytest.mark.asyncio +async def test_set_carrier(aresponses, authenticated_client): + """Test setting carrier.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("set_carrier_response.json"), status=200), + ) + + res = await authenticated_client.profile.set_carrier( + "1234567890987654321", "100001", "0" + ) + assert res is None + + +@pytest.mark.asyncio +async def test_set_carrier_error_response(aresponses, authenticated_client): + """Test setting carrier with failed response.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("set_carrier_response_failure_response.json"), status=200 + ), + ) + + with pytest.raises(RequestError): + await authenticated_client.profile.set_carrier( + "1234567890987654321", "100001", "0" + ) + + +@pytest.mark.asyncio +async def test_track_info_by_id(aresponses, authenticated_client): + """Test getting track info by internal ID.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("track_info_by_id_response.json"), status=200 + ), + ) + + items = await authenticated_client.profile.track_info_by_id("1234567890987654321") + assert len(items) == 1 + assert items[0]["FTrackInfoId"] == "1234567890987654321" + + +@pytest.mark.asyncio +async def test_track_info_by_id_empty(): + """Test getting track info with no IDs.""" + client = Client() + items = await client.profile.track_info_by_id() + assert items == [] + + +@pytest.mark.asyncio +async def test_track_info_by_id_error_response(aresponses, authenticated_client): + """Test getting track info with failed response.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("track_info_by_id_failure_response.json"), status=200 + ), + ) + + with pytest.raises(RequestError): + await authenticated_client.profile.track_info_by_id("1234567890987654321") + + +@pytest.mark.asyncio +async def test_order_info_by_id(aresponses, authenticated_client): + """Test getting order info by internal ID.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("order_info_by_id_response.json"), status=200 + ), + ) + + order = await authenticated_client.profile.order_info_by_id("1234567890987654321") + assert order["opn"] == "Acme" + assert order["ptoid"] == "123" + assert order["pt"] == "01" + assert order["otime"] == "2024-12-01" + + +@pytest.mark.asyncio +async def test_order_info_by_id_error_response(aresponses, authenticated_client): + """Test getting order info with failed response.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("order_info_by_id_failure_response.json"), status=200 + ), + ) + + with pytest.raises(RequestError): + await authenticated_client.profile.order_info_by_id("1234567890987654321") + + +@pytest.mark.asyncio +async def test_save_order_info(aresponses, authenticated_client): + """Test saving order info.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response(text=load_fixture("save_order_info_response.json"), status=200), + ) + + res = await authenticated_client.profile.save_order_info( + "1234567890987654321", + opn="Acme", + ptoid="123", + pt="01", + otime="2024-12-01", + ) + assert res is None + + +@pytest.mark.asyncio +async def test_save_order_info_error_response(aresponses, authenticated_client): + """Test saving order info with failed response.""" + aresponses.add( + "buyer.17track.net", + "/orderapi/call", + "post", + aresponses.Response( + text=load_fixture("save_order_info_failure_response.json"), status=200 + ), + ) + + with pytest.raises(RequestError): + await authenticated_client.profile.save_order_info( + "1234567890987654321", opn="Acme" + )