From c3d42bd9b4b585655173b9fa6c29f18376241b9b Mon Sep 17 00:00:00 2001 From: David Grayston Date: Mon, 7 Jul 2025 22:29:03 +0100 Subject: [PATCH 1/3] feat: Client Token Support --- paddle_billing/Client.py | 2 + paddle_billing/Entities/ClientToken.py | 33 ++ .../ClientTokens/ClientTokenStatus.py | 6 + .../Entities/ClientTokens/__init__.py | 1 + .../Collections/ClientTokenCollection.py | 14 + .../Entities/Collections/__init__.py | 1 + .../Entities/Events/EventTypeName.py | 3 + .../Notifications/Entities/ClientToken.py | 33 ++ .../ClientTokens/ClientTokenStatus.py | 6 + .../Entities/ClientTokens/__init__.py | 1 + .../Entities/Simulations/ClientToken.py | 38 +++ .../Entities/Simulations/__init__.py | 1 + .../Events/ClientTokenCreated.py | 17 + .../Events/ClientTokenRevoked.py | 17 + .../Events/ClientTokenUpdated.py | 17 + .../ClientTokens/ClientTokensClient.py | 50 +++ .../Operations/CreateClientToken.py | 10 + .../Operations/ListClientTokens.py | 35 ++ .../Operations/UpdateClientToken.py | 10 + .../ClientTokens/Operations/__init__.py | 3 + .../Resources/ClientTokens/__init__.py | 0 .../Resources/ClientToken/__init__.py | 0 .../_fixtures/request/create_basic.json | 4 + .../_fixtures/request/create_full.json | 4 + .../request/create_null_description.json | 4 + .../ClientToken/_fixtures/request/revoke.json | 3 + .../_fixtures/response/full_entity.json | 15 + .../_fixtures/response/list_page_one.json | 43 +++ .../_fixtures/response/list_page_two.json | 43 +++ .../_fixtures/response/minimal_entity.json | 15 + .../_fixtures/response/revoked_entity.json | 15 + .../ClientToken/test_ClientTokensClient.py | 298 ++++++++++++++++++ .../entity/client_token.created.json | 10 + .../entity/client_token.revoked.json | 10 + .../entity/client_token.updated.json | 10 + tests/Unit/Entities/test_Event.py | 6 + 36 files changed, 778 insertions(+) create mode 100644 paddle_billing/Entities/ClientToken.py create mode 100644 paddle_billing/Entities/ClientTokens/ClientTokenStatus.py create mode 100644 paddle_billing/Entities/ClientTokens/__init__.py create mode 100644 paddle_billing/Entities/Collections/ClientTokenCollection.py create mode 100644 paddle_billing/Notifications/Entities/ClientToken.py create mode 100644 paddle_billing/Notifications/Entities/ClientTokens/ClientTokenStatus.py create mode 100644 paddle_billing/Notifications/Entities/ClientTokens/__init__.py create mode 100644 paddle_billing/Notifications/Entities/Simulations/ClientToken.py create mode 100644 paddle_billing/Notifications/Events/ClientTokenCreated.py create mode 100644 paddle_billing/Notifications/Events/ClientTokenRevoked.py create mode 100644 paddle_billing/Notifications/Events/ClientTokenUpdated.py create mode 100644 paddle_billing/Resources/ClientTokens/ClientTokensClient.py create mode 100644 paddle_billing/Resources/ClientTokens/Operations/CreateClientToken.py create mode 100644 paddle_billing/Resources/ClientTokens/Operations/ListClientTokens.py create mode 100644 paddle_billing/Resources/ClientTokens/Operations/UpdateClientToken.py create mode 100644 paddle_billing/Resources/ClientTokens/Operations/__init__.py create mode 100644 paddle_billing/Resources/ClientTokens/__init__.py create mode 100644 tests/Functional/Resources/ClientToken/__init__.py create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/request/create_basic.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/request/create_full.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/request/create_null_description.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/request/revoke.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/response/full_entity.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/response/list_page_one.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/response/list_page_two.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/response/minimal_entity.json create mode 100644 tests/Functional/Resources/ClientToken/_fixtures/response/revoked_entity.json create mode 100644 tests/Functional/Resources/ClientToken/test_ClientTokensClient.py create mode 100644 tests/Unit/Entities/_fixtures/notification/entity/client_token.created.json create mode 100644 tests/Unit/Entities/_fixtures/notification/entity/client_token.revoked.json create mode 100644 tests/Unit/Entities/_fixtures/notification/entity/client_token.updated.json diff --git a/paddle_billing/Client.py b/paddle_billing/Client.py index 2331ac33..b9233874 100644 --- a/paddle_billing/Client.py +++ b/paddle_billing/Client.py @@ -18,6 +18,7 @@ from paddle_billing.Resources.Addresses.AddressesClient import AddressesClient from paddle_billing.Resources.Adjustments.AdjustmentsClient import AdjustmentsClient from paddle_billing.Resources.Businesses.BusinessesClient import BusinessesClient +from paddle_billing.Resources.ClientTokens.ClientTokensClient import ClientTokensClient from paddle_billing.Resources.Customers.CustomersClient import CustomersClient from paddle_billing.Resources.CustomerPortalSessions.CustomerPortalSessionsClient import CustomerPortalSessionsClient from paddle_billing.Resources.DiscountGroups.DiscountGroupsClient import DiscountGroupsClient @@ -71,6 +72,7 @@ def __init__( self.addresses = AddressesClient(self) self.adjustments = AdjustmentsClient(self) self.businesses = BusinessesClient(self) + self.client_tokens = ClientTokensClient(self) self.customers = CustomersClient(self) self.customer_portal_sessions = CustomerPortalSessionsClient(self) self.discounts = DiscountsClient(self) diff --git a/paddle_billing/Entities/ClientToken.py b/paddle_billing/Entities/ClientToken.py new file mode 100644 index 00000000..9dca575c --- /dev/null +++ b/paddle_billing/Entities/ClientToken.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.ClientTokens import ClientTokenStatus + + +@dataclass +class ClientToken(Entity): + id: str + status: ClientTokenStatus + token: str + name: str + description: str | None + created_at: datetime + updated_at: datetime + revoked_at: datetime | None + + @staticmethod + def from_dict(data: dict[str, Any]) -> ClientToken: + return ClientToken( + id=data["id"], + status=ClientTokenStatus(data["status"]), + token=data["token"], + name=data["name"], + description=data.get("description"), + created_at=datetime.fromisoformat(data["created_at"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + revoked_at=datetime.fromisoformat(data["revoked_at"]) if data.get("revoked_at") else None, + ) diff --git a/paddle_billing/Entities/ClientTokens/ClientTokenStatus.py b/paddle_billing/Entities/ClientTokens/ClientTokenStatus.py new file mode 100644 index 00000000..f1a75b41 --- /dev/null +++ b/paddle_billing/Entities/ClientTokens/ClientTokenStatus.py @@ -0,0 +1,6 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class ClientTokenStatus(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + Active: "ClientTokenStatus" = "active" + Revoked: "ClientTokenStatus" = "revoked" diff --git a/paddle_billing/Entities/ClientTokens/__init__.py b/paddle_billing/Entities/ClientTokens/__init__.py new file mode 100644 index 00000000..b82c1cf1 --- /dev/null +++ b/paddle_billing/Entities/ClientTokens/__init__.py @@ -0,0 +1 @@ +from paddle_billing.Entities.ClientTokens.ClientTokenStatus import ClientTokenStatus diff --git a/paddle_billing/Entities/Collections/ClientTokenCollection.py b/paddle_billing/Entities/Collections/ClientTokenCollection.py new file mode 100644 index 00000000..39820b08 --- /dev/null +++ b/paddle_billing/Entities/Collections/ClientTokenCollection.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from typing import Any + +from paddle_billing.Entities.Collections.Collection import Collection +from paddle_billing.Entities.Collections.Paginator import Paginator +from paddle_billing.Entities.ClientToken import ClientToken + + +class ClientTokenCollection(Collection[ClientToken]): + @classmethod + def from_list(cls, items_data: list[dict[str, Any]], paginator: Paginator | None = None) -> ClientTokenCollection: + items: list[ClientToken] = [ClientToken.from_dict(item) for item in items_data] + + return ClientTokenCollection(items, paginator) diff --git a/paddle_billing/Entities/Collections/__init__.py b/paddle_billing/Entities/Collections/__init__.py index d8bd9ad6..e6d335d2 100644 --- a/paddle_billing/Entities/Collections/__init__.py +++ b/paddle_billing/Entities/Collections/__init__.py @@ -1,6 +1,7 @@ from paddle_billing.Entities.Collections.AddressCollection import AddressCollection from paddle_billing.Entities.Collections.AdjustmentCollection import AdjustmentCollection from paddle_billing.Entities.Collections.BusinessCollection import BusinessCollection +from paddle_billing.Entities.Collections.ClientTokenCollection import ClientTokenCollection from paddle_billing.Entities.Collections.Collection import Collection from paddle_billing.Entities.Collections.CreditBalanceCollection import CreditBalanceCollection from paddle_billing.Entities.Collections.CustomerCollection import CustomerCollection diff --git a/paddle_billing/Entities/Events/EventTypeName.py b/paddle_billing/Entities/Events/EventTypeName.py index 723cf7bc..01f39b9f 100644 --- a/paddle_billing/Entities/Events/EventTypeName.py +++ b/paddle_billing/Entities/Events/EventTypeName.py @@ -15,6 +15,9 @@ class EventTypeName(PaddleStrEnum, metaclass=PaddleStrEnumMeta): BusinessCreated: "EventTypeName" = "business.created" BusinessImported: "EventTypeName" = "business.imported" BusinessUpdated: "EventTypeName" = "business.updated" + ClientTokenCreated: "EventTypeName" = "client_token.created" + ClientTokenRevoked: "EventTypeName" = "client_token.revoked" + ClientTokenUpdated: "EventTypeName" = "client_token.updated" CustomerCreated: "EventTypeName" = "customer.created" CustomerImported: "EventTypeName" = "customer.imported" CustomerUpdated: "EventTypeName" = "customer.updated" diff --git a/paddle_billing/Notifications/Entities/ClientToken.py b/paddle_billing/Notifications/Entities/ClientToken.py new file mode 100644 index 00000000..01ce8fbc --- /dev/null +++ b/paddle_billing/Notifications/Entities/ClientToken.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Notifications.Entities.Entity import Entity +from paddle_billing.Notifications.Entities.ClientTokens import ClientTokenStatus + + +@dataclass +class ClientToken(Entity): + id: str + status: ClientTokenStatus + token: str + name: str + description: str | None + created_at: datetime + updated_at: datetime + revoked_at: datetime | None + + @staticmethod + def from_dict(data: dict[str, Any]) -> ClientToken: + return ClientToken( + id=data["id"], + status=ClientTokenStatus(data["status"]), + token=data["token"], + name=data["name"], + description=data.get("description"), + created_at=datetime.fromisoformat(data["created_at"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + revoked_at=datetime.fromisoformat(data["revoked_at"]) if data.get("revoked_at") else None, + ) diff --git a/paddle_billing/Notifications/Entities/ClientTokens/ClientTokenStatus.py b/paddle_billing/Notifications/Entities/ClientTokens/ClientTokenStatus.py new file mode 100644 index 00000000..f1a75b41 --- /dev/null +++ b/paddle_billing/Notifications/Entities/ClientTokens/ClientTokenStatus.py @@ -0,0 +1,6 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class ClientTokenStatus(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + Active: "ClientTokenStatus" = "active" + Revoked: "ClientTokenStatus" = "revoked" diff --git a/paddle_billing/Notifications/Entities/ClientTokens/__init__.py b/paddle_billing/Notifications/Entities/ClientTokens/__init__.py new file mode 100644 index 00000000..08a49de9 --- /dev/null +++ b/paddle_billing/Notifications/Entities/ClientTokens/__init__.py @@ -0,0 +1 @@ +from paddle_billing.Notifications.Entities.ClientTokens.ClientTokenStatus import ClientTokenStatus diff --git a/paddle_billing/Notifications/Entities/Simulations/ClientToken.py b/paddle_billing/Notifications/Entities/Simulations/ClientToken.py new file mode 100644 index 00000000..ce903d0f --- /dev/null +++ b/paddle_billing/Notifications/Entities/Simulations/ClientToken.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Notifications.Entities.Simulations.SimulationEntity import SimulationEntity +from paddle_billing.Notifications.Entities.ClientTokens import ClientTokenStatus +from paddle_billing.Undefined import Undefined + + +@dataclass +class ClientToken(SimulationEntity): + id: str | Undefined = Undefined() + status: ClientTokenStatus | Undefined = Undefined() + token: str | Undefined = Undefined() + name: str | Undefined = Undefined() + description: str | None | Undefined = Undefined() + created_at: datetime | Undefined = Undefined() + updated_at: datetime | Undefined = Undefined() + revoked_at: datetime | None | Undefined = Undefined() + + @staticmethod + def from_dict(data: dict[str, Any]) -> ClientToken: + return ClientToken( + id=data.get("id", Undefined()), + status=ClientTokenStatus(data["status"]) if data.get("status") else Undefined(), + token=data.get("token", Undefined()), + name=data.get("name", Undefined()), + description=data.get("description", Undefined()), + created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else Undefined(), + updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else Undefined(), + revoked_at=( + datetime.fromisoformat(data["revoked_at"]) + if data.get("revoked_at") + else data.get("revoked_at", Undefined()) + ), + ) diff --git a/paddle_billing/Notifications/Entities/Simulations/__init__.py b/paddle_billing/Notifications/Entities/Simulations/__init__.py index d4a03f99..0f15e6d4 100644 --- a/paddle_billing/Notifications/Entities/Simulations/__init__.py +++ b/paddle_billing/Notifications/Entities/Simulations/__init__.py @@ -1,6 +1,7 @@ from paddle_billing.Notifications.Entities.Simulations.Address import Address from paddle_billing.Notifications.Entities.Simulations.Adjustment import Adjustment from paddle_billing.Notifications.Entities.Simulations.Business import Business +from paddle_billing.Notifications.Entities.Simulations.ClientToken import ClientToken from paddle_billing.Notifications.Entities.Simulations.Customer import Customer from paddle_billing.Notifications.Entities.Simulations.Discount import Discount from paddle_billing.Notifications.Entities.Simulations.PaymentMethod import PaymentMethod diff --git a/paddle_billing/Notifications/Events/ClientTokenCreated.py b/paddle_billing/Notifications/Events/ClientTokenCreated.py new file mode 100644 index 00000000..447a5113 --- /dev/null +++ b/paddle_billing/Notifications/Events/ClientTokenCreated.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from paddle_billing.Entities.Event import Event +from paddle_billing.Entities.Events import EventTypeName + +from paddle_billing.Notifications.Entities.ClientToken import ClientToken + + +class ClientTokenCreated(Event): + def __init__( + self, + event_id: str, + event_type: EventTypeName, + occurred_at: datetime, + data: ClientToken, + ): + super().__init__(event_id, event_type, occurred_at, data) diff --git a/paddle_billing/Notifications/Events/ClientTokenRevoked.py b/paddle_billing/Notifications/Events/ClientTokenRevoked.py new file mode 100644 index 00000000..074b1680 --- /dev/null +++ b/paddle_billing/Notifications/Events/ClientTokenRevoked.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from paddle_billing.Entities.Event import Event +from paddle_billing.Entities.Events import EventTypeName + +from paddle_billing.Notifications.Entities.ClientToken import ClientToken + + +class ClientTokenRevoked(Event): + def __init__( + self, + event_id: str, + event_type: EventTypeName, + occurred_at: datetime, + data: ClientToken, + ): + super().__init__(event_id, event_type, occurred_at, data) diff --git a/paddle_billing/Notifications/Events/ClientTokenUpdated.py b/paddle_billing/Notifications/Events/ClientTokenUpdated.py new file mode 100644 index 00000000..5f280866 --- /dev/null +++ b/paddle_billing/Notifications/Events/ClientTokenUpdated.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from paddle_billing.Entities.Event import Event +from paddle_billing.Entities.Events import EventTypeName + +from paddle_billing.Notifications.Entities.ClientToken import ClientToken + + +class ClientTokenUpdated(Event): + def __init__( + self, + event_id: str, + event_type: EventTypeName, + occurred_at: datetime, + data: ClientToken, + ): + super().__init__(event_id, event_type, occurred_at, data) diff --git a/paddle_billing/Resources/ClientTokens/ClientTokensClient.py b/paddle_billing/Resources/ClientTokens/ClientTokensClient.py new file mode 100644 index 00000000..380f0610 --- /dev/null +++ b/paddle_billing/Resources/ClientTokens/ClientTokensClient.py @@ -0,0 +1,50 @@ +from paddle_billing.ResponseParser import ResponseParser + +from paddle_billing.Entities.Collections import Paginator, ClientTokenCollection +from paddle_billing.Entities.ClientToken import ClientToken +from paddle_billing.Entities.ClientTokens import ClientTokenStatus + +from paddle_billing.Resources.ClientTokens.Operations import CreateClientToken, ListClientTokens, UpdateClientToken + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from paddle_billing.Client import Client + + +class ClientTokensClient: + def __init__(self, client: "Client"): + self.client = client + self.response = None + + def list(self, operation: ListClientTokens | None = None) -> ClientTokenCollection: + if operation is None: + operation = ListClientTokens() + + self.response = self.client.get_raw("/client-tokens", operation.get_parameters()) + parser = ResponseParser(self.response) + + return ClientTokenCollection.from_list( + parser.get_list(), Paginator(self.client, parser.get_pagination(), ClientTokenCollection) + ) + + def get(self, client_token_id: str) -> ClientToken: + self.response = self.client.get_raw(f"/client-tokens/{client_token_id}") + parser = ResponseParser(self.response) + + return ClientToken.from_dict(parser.get_dict()) + + def create(self, operation: CreateClientToken) -> ClientToken: + self.response = self.client.post_raw("/client-tokens", operation) + parser = ResponseParser(self.response) + + return ClientToken.from_dict(parser.get_dict()) + + def update(self, client_token_id: str, operation: UpdateClientToken) -> ClientToken: + self.response = self.client.patch_raw(f"/client-tokens/{client_token_id}", operation) + parser = ResponseParser(self.response) + + return ClientToken.from_dict(parser.get_dict()) + + def revoke(self, client_token_id: str) -> ClientToken: + return self.update(client_token_id, UpdateClientToken(status=ClientTokenStatus.Revoked)) diff --git a/paddle_billing/Resources/ClientTokens/Operations/CreateClientToken.py b/paddle_billing/Resources/ClientTokens/Operations/CreateClientToken.py new file mode 100644 index 00000000..735d5578 --- /dev/null +++ b/paddle_billing/Resources/ClientTokens/Operations/CreateClientToken.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from paddle_billing.Operation import Operation +from paddle_billing.Undefined import Undefined + + +@dataclass +class CreateClientToken(Operation): + name: str + description: str | None | Undefined = Undefined() diff --git a/paddle_billing/Resources/ClientTokens/Operations/ListClientTokens.py b/paddle_billing/Resources/ClientTokens/Operations/ListClientTokens.py new file mode 100644 index 00000000..5bdf8b6b --- /dev/null +++ b/paddle_billing/Resources/ClientTokens/Operations/ListClientTokens.py @@ -0,0 +1,35 @@ +from paddle_billing.Entities.ClientToken import ClientTokenStatus +from paddle_billing.EnumStringify import enum_stringify +from paddle_billing.HasParameters import HasParameters + +from paddle_billing.Exceptions.SdkExceptions.InvalidArgumentException import InvalidArgumentException +from paddle_billing.Resources.Shared.Operations import Pager + + +class ListClientTokens(HasParameters): + def __init__( + self, + pager: Pager | None = None, + statuses: list[ClientTokenStatus] | None = None, + ): + self.pager = pager + self.statuses = statuses if statuses is not None else [] + + # Validation + for field_name, field_value, field_type in [ + ("statuses", self.statuses, ClientTokenStatus), + ]: + invalid_items = [item for item in field_value if not isinstance(item, field_type)] + if invalid_items: + raise InvalidArgumentException.array_contains_invalid_types( + field_name, field_type.__name__, invalid_items + ) + + def get_parameters(self) -> dict[str, str]: + parameters = {} + if self.pager: + parameters.update(self.pager.get_parameters()) + if self.statuses: + parameters["status"] = ",".join(map(enum_stringify, self.statuses)) + + return parameters diff --git a/paddle_billing/Resources/ClientTokens/Operations/UpdateClientToken.py b/paddle_billing/Resources/ClientTokens/Operations/UpdateClientToken.py new file mode 100644 index 00000000..e483ab9a --- /dev/null +++ b/paddle_billing/Resources/ClientTokens/Operations/UpdateClientToken.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from paddle_billing.Operation import Operation +from paddle_billing.Undefined import Undefined +from paddle_billing.Entities.ClientTokens import ClientTokenStatus + + +@dataclass +class UpdateClientToken(Operation): + status: ClientTokenStatus | Undefined = Undefined() diff --git a/paddle_billing/Resources/ClientTokens/Operations/__init__.py b/paddle_billing/Resources/ClientTokens/Operations/__init__.py new file mode 100644 index 00000000..010c48ad --- /dev/null +++ b/paddle_billing/Resources/ClientTokens/Operations/__init__.py @@ -0,0 +1,3 @@ +from paddle_billing.Resources.ClientTokens.Operations.CreateClientToken import CreateClientToken +from paddle_billing.Resources.ClientTokens.Operations.ListClientTokens import ListClientTokens +from paddle_billing.Resources.ClientTokens.Operations.UpdateClientToken import UpdateClientToken diff --git a/paddle_billing/Resources/ClientTokens/__init__.py b/paddle_billing/Resources/ClientTokens/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/Functional/Resources/ClientToken/__init__.py b/tests/Functional/Resources/ClientToken/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/Functional/Resources/ClientToken/_fixtures/request/create_basic.json b/tests/Functional/Resources/ClientToken/_fixtures/request/create_basic.json new file mode 100644 index 00000000..c3105161 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/request/create_basic.json @@ -0,0 +1,4 @@ +{ + "name": "Pricing page integration", + "description": null +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/request/create_full.json b/tests/Functional/Resources/ClientToken/_fixtures/request/create_full.json new file mode 100644 index 00000000..8d46b4a1 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/request/create_full.json @@ -0,0 +1,4 @@ +{ + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain." +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/request/create_null_description.json b/tests/Functional/Resources/ClientToken/_fixtures/request/create_null_description.json new file mode 100644 index 00000000..c3105161 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/request/create_null_description.json @@ -0,0 +1,4 @@ +{ + "name": "Pricing page integration", + "description": null +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/request/revoke.json b/tests/Functional/Resources/ClientToken/_fixtures/request/revoke.json new file mode 100644 index 00000000..9cd1a213 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/request/revoke.json @@ -0,0 +1,3 @@ +{ + "status": "revoked" +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/response/full_entity.json b/tests/Functional/Resources/ClientToken/_fixtures/response/full_entity.json new file mode 100644 index 00000000..e5bf72da --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/response/full_entity.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "active", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-26T14:36:14.695000Z", + "revoked_at": null + }, + "meta": { + "request_id": "badf3eb7-3eb8-4d4a-92f9-bc798790b7bc" + } +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/response/list_page_one.json b/tests/Functional/Resources/ClientToken/_fixtures/response/list_page_one.json new file mode 100644 index 00000000..5edc4674 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/response/list_page_one.json @@ -0,0 +1,43 @@ +{ + "data": [ + { + "id": "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "active", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-26T14:36:14.695000Z", + "revoked_at": null + }, + { + "id": "ctkn_02ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_8d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "revoked", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-27T11:26:10.253000Z", + "revoked_at": "2025-06-27T11:26:10.253000Z" + }, + { + "id": "ctkn_03ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_9d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": null, + "status": "active", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-26T14:36:14.695000Z", + "revoked_at": null + } + ], + "meta": { + "request_id": "f802da6d-096f-4cab-abd1-6c98330d4b09", + "pagination": { + "per_page": 50, + "next": "https://sandbox-api.paddle.com/client-tokens?after=ctkn_03ghbkd0frb9k95cnhwd1bxpvk", + "has_more": true, + "estimated_total": 6 + } + } +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/response/list_page_two.json b/tests/Functional/Resources/ClientToken/_fixtures/response/list_page_two.json new file mode 100644 index 00000000..96e7e028 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/response/list_page_two.json @@ -0,0 +1,43 @@ +{ + "data": [ + { + "id": "ctkn_04ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "active", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-26T14:36:14.695000Z", + "revoked_at": null + }, + { + "id": "ctkn_04ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_8d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "revoked", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-27T11:26:10.253000Z", + "revoked_at": "2025-06-27T11:26:10.253000Z" + }, + { + "id": "ctkn_06ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_9d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": null, + "status": "active", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-26T14:36:14.695000Z", + "revoked_at": null + } + ], + "meta": { + "request_id": "f802da6d-096f-4cab-abd1-6c98330d4b09", + "pagination": { + "per_page": 50, + "next": "https://sandbox-api.paddle.com/client-tokens?after=ctkn_06ghbkd0frb9k95cnhwd1bxpvk", + "has_more": false, + "estimated_total": 6 + } + } +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/response/minimal_entity.json b/tests/Functional/Resources/ClientToken/_fixtures/response/minimal_entity.json new file mode 100644 index 00000000..e09994de --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/response/minimal_entity.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": null, + "status": "active", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-26T14:36:14.695000Z", + "revoked_at": null + }, + "meta": { + "request_id": "badf3eb7-3eb8-4d4a-92f9-bc798790b7bc" + } +} diff --git a/tests/Functional/Resources/ClientToken/_fixtures/response/revoked_entity.json b/tests/Functional/Resources/ClientToken/_fixtures/response/revoked_entity.json new file mode 100644 index 00000000..511b4c96 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/_fixtures/response/revoked_entity.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "revoked", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-27T11:26:10.253000Z", + "revoked_at": "2025-06-27T11:26:10.253000Z" + }, + "meta": { + "request_id": "badf3eb7-3eb8-4d4a-92f9-bc798790b7bc" + } +} diff --git a/tests/Functional/Resources/ClientToken/test_ClientTokensClient.py b/tests/Functional/Resources/ClientToken/test_ClientTokensClient.py new file mode 100644 index 00000000..122175f3 --- /dev/null +++ b/tests/Functional/Resources/ClientToken/test_ClientTokensClient.py @@ -0,0 +1,298 @@ +from json import loads +from pytest import mark +from urllib.parse import unquote + +from build.lib.paddle_billing.Json import PayloadEncoder +from paddle_billing.Entities.Collections import ClientTokenCollection +from paddle_billing.Entities.ClientToken import ClientToken +from paddle_billing.Entities.ClientTokens import ClientTokenStatus + +from paddle_billing.Resources.ClientTokens.Operations import CreateClientToken, ListClientTokens, UpdateClientToken +from paddle_billing.Resources.Shared.Operations import Pager + +from tests.Utils.ReadsFixture import ReadsFixtures + + +class TestClientTokensClient: + @mark.parametrize( + "operation, expected_request_body, expected_response_status, expected_response_body, expected_url", + [ + ( + CreateClientToken( + "Pricing page integration", + None, + ), + ReadsFixtures.read_raw_json_fixture("request/create_basic"), + 200, + ReadsFixtures.read_raw_json_fixture("response/minimal_entity"), + "/client-tokens", + ), + ( + CreateClientToken( + "Pricing page integration", + None, + ), + ReadsFixtures.read_raw_json_fixture("request/create_null_description"), + 200, + ReadsFixtures.read_raw_json_fixture("response/minimal_entity"), + "/client-tokens", + ), + ( + CreateClientToken( + "Pricing page integration", + "Used to display prices and open checkout within our pricing page on our marketing domain.", + ), + ReadsFixtures.read_raw_json_fixture("request/create_full"), + 200, + ReadsFixtures.read_raw_json_fixture("response/full_entity"), + "/client-tokens", + ), + ], + ids=[ + "Create client token with basic data", + "Create client token with basic data - null description", + "Create client token with full data", + ], + ) + def test_create_client_token_uses_expected_payload( + self, + test_client, + mock_requests, + operation, + expected_request_body, + expected_response_status, + expected_response_body, + expected_url, + ): + expected_url = f"{test_client.base_url}{expected_url}" + mock_requests.post(expected_url, status_code=expected_response_status, text=expected_response_body) + + response = test_client.client.client_tokens.create(operation) + response_json = test_client.client.client_tokens.response.json() + request_json = test_client.client.payload + last_request = mock_requests.last_request + + assert isinstance(response, ClientToken) + assert last_request is not None + assert last_request.method == "POST" + assert test_client.client.status_code == expected_response_status + assert ( + unquote(last_request.url) == expected_url + ), "The URL does not match the expected URL, verify the query string is correct" + assert loads(request_json) == loads( + expected_request_body + ), "The request JSON doesn't match the expected fixture JSON" + assert response_json == loads( + str(expected_response_body) + ), "The response JSON doesn't match the expected fixture JSON" + + @mark.parametrize( + "client_token_id, operation, expected_request_body, expected_response_status, expected_response_body, expected_url", + [ + ( + "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + UpdateClientToken(status=ClientTokenStatus.Revoked), + ReadsFixtures.read_raw_json_fixture("request/revoke"), + 200, + ReadsFixtures.read_raw_json_fixture("response/revoked_entity"), + "/client-tokens/ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + ), + ], + ids=["Revoke client token"], + ) + def test_update_client_token_uses_expected_payload( + self, + test_client, + mock_requests, + client_token_id, + operation, + expected_request_body, + expected_response_status, + expected_response_body, + expected_url, + ): + expected_url = f"{test_client.base_url}{expected_url}" + mock_requests.patch(expected_url, status_code=expected_response_status, text=expected_response_body) + + response = test_client.client.client_tokens.update(client_token_id, operation) + response_json = test_client.client.client_tokens.response.json() + request_json = test_client.client.payload + last_request = mock_requests.last_request + + assert isinstance(response, ClientToken) + assert last_request is not None + assert last_request.method == "PATCH" + assert test_client.client.status_code == expected_response_status + assert ( + unquote(last_request.url) == expected_url + ), "The URL does not match the expected URL, verify the query string is correct" + assert loads(request_json) == loads( + expected_request_body + ), "The request JSON doesn't match the expected fixture JSON" + assert response_json == loads( + str(expected_response_body) + ), "The response JSON doesn't match the expected fixture JSON" + + def test_revoke_client_token_uses_expected_payload( + self, + test_client, + mock_requests, + ): + client_token_id = "ctkn_01ghbkd0frb9k95cnhwd1bxpvk" + expected_request_body = ReadsFixtures.read_raw_json_fixture("request/revoke") + expected_response_body = ReadsFixtures.read_raw_json_fixture("response/revoked_entity") + expected_url = "/client-tokens/ctkn_01ghbkd0frb9k95cnhwd1bxpvk" + + expected_url = f"{test_client.base_url}{expected_url}" + mock_requests.patch(expected_url, status_code=200, text=expected_response_body) + + response = test_client.client.client_tokens.revoke(client_token_id) + response_json = test_client.client.client_tokens.response.json() + request_json = test_client.client.payload + last_request = mock_requests.last_request + + assert isinstance(response, ClientToken) + assert last_request is not None + assert last_request.method == "PATCH" + assert test_client.client.status_code == 200 + assert ( + unquote(last_request.url) == expected_url + ), "The URL does not match the expected URL, verify the query string is correct" + assert loads(request_json) == loads( + expected_request_body + ), "The request JSON doesn't match the expected fixture JSON" + assert response_json == loads( + str(expected_response_body) + ), "The response JSON doesn't match the expected fixture JSON" + + @mark.parametrize( + "operation, expected_response_status, expected_response_body, expected_url", + [ + ( + ListClientTokens(), + 200, + ReadsFixtures.read_raw_json_fixture("response/list_page_one"), + "/client-tokens", + ), + ( + ListClientTokens(Pager()), + 200, + ReadsFixtures.read_raw_json_fixture("response/list_page_one"), + "/client-tokens?order_by=id[asc]&per_page=50", + ), + ( + ListClientTokens(Pager(after="ctkn_01ghbkd0frb9k95cnhwd1bxpvk")), + 200, + ReadsFixtures.read_raw_json_fixture("response/list_page_one"), + "/client-tokens?after=ctkn_01ghbkd0frb9k95cnhwd1bxpvk&order_by=id[asc]&per_page=50", + ), + ( + ListClientTokens(statuses=[ClientTokenStatus.Active]), + 200, + ReadsFixtures.read_raw_json_fixture("response/list_page_one"), + "/client-tokens?status=active", + ), + ( + ListClientTokens(statuses=[ClientTokenStatus.Revoked]), + 200, + ReadsFixtures.read_raw_json_fixture("response/list_page_one"), + "/client-tokens?status=revoked", + ), + ], + ids=[ + "List client tokens without pagination", + "List client tokens with default pagination", + "List paginated client tokens after specified client token id", + "List client tokens filtered by active status", + "List client tokens filtered by revoked status", + ], + ) + def test_list_client_tokens_returns_expected_response( + self, + test_client, + mock_requests, + operation, + expected_response_status, + expected_response_body, + expected_url, + ): + expected_url = f"{test_client.base_url}{expected_url}" + mock_requests.get(expected_url, status_code=expected_response_status, text=expected_response_body) + + response = test_client.client.client_tokens.list(operation) + last_request = mock_requests.last_request + + assert isinstance(response, ClientTokenCollection) + assert last_request is not None + assert last_request.method == "GET" + assert test_client.client.status_code == expected_response_status + assert ( + unquote(last_request.url) == expected_url + ), "The URL does not match the expected URL, verify the query string is correct" + assert loads(PayloadEncoder().encode(response.items[0])) == loads(expected_response_body)["data"][0] + + def test_list_client_tokens_can_paginate( + self, + test_client, + mock_requests, + ): + mock_requests.get( + f"{test_client.base_url}/client-tokens", + status_code=200, + text=ReadsFixtures.read_raw_json_fixture("response/list_page_one"), + ) + + mock_requests.get( + f"{test_client.base_url}/client-tokens?after=ctkn_03ghbkd0frb9k95cnhwd1bxpvk", + status_code=200, + text=ReadsFixtures.read_raw_json_fixture("response/list_page_two"), + ) + + response = test_client.client.client_tokens.list() + + assert isinstance(response, ClientTokenCollection) + + all = [] + for client_token in response: + all.append(client_token) + + assert len(all) == 6 + + @mark.parametrize( + "client_token_id, expected_response_status, expected_response_body, expected_url", + [ + ( + "dsc_01h83xenpcfjyhkqr4x214m02x", + 200, + ReadsFixtures.read_raw_json_fixture("response/full_entity"), + "/client-tokens/dsc_01h83xenpcfjyhkqr4x214m02x", + ) + ], + ids=["Get client token"], + ) + def test_get_client_tokens_returns_expected_response( + self, + test_client, + mock_requests, + client_token_id, + expected_response_status, + expected_response_body, + expected_url, + ): + expected_url = f"{test_client.base_url}{expected_url}" + mock_requests.get(expected_url, status_code=expected_response_status, text=expected_response_body) + + response = test_client.client.client_tokens.get(client_token_id) + response_json = test_client.client.client_tokens.response.json() + last_request = mock_requests.last_request + + assert isinstance(response, ClientToken) + assert last_request is not None + assert last_request.method == "GET" + assert test_client.client.status_code == expected_response_status + assert ( + unquote(last_request.url) == expected_url + ), "The URL does not match the expected URL, verify the query string is correct" + assert response_json == loads( + str(expected_response_body) + ), "The response JSON generated by ResponseParser() doesn't match the expected fixture JSON" diff --git a/tests/Unit/Entities/_fixtures/notification/entity/client_token.created.json b/tests/Unit/Entities/_fixtures/notification/entity/client_token.created.json new file mode 100644 index 00000000..bbb7101f --- /dev/null +++ b/tests/Unit/Entities/_fixtures/notification/entity/client_token.created.json @@ -0,0 +1,10 @@ +{ + "id": "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "active", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-26T14:36:14.695000Z", + "revoked_at": null +} \ No newline at end of file diff --git a/tests/Unit/Entities/_fixtures/notification/entity/client_token.revoked.json b/tests/Unit/Entities/_fixtures/notification/entity/client_token.revoked.json new file mode 100644 index 00000000..449277e8 --- /dev/null +++ b/tests/Unit/Entities/_fixtures/notification/entity/client_token.revoked.json @@ -0,0 +1,10 @@ +{ + "id": "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "revoked", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-27T11:26:10.253000Z", + "revoked_at": "2025-06-27T11:26:10.253000Z" +} \ No newline at end of file diff --git a/tests/Unit/Entities/_fixtures/notification/entity/client_token.updated.json b/tests/Unit/Entities/_fixtures/notification/entity/client_token.updated.json new file mode 100644 index 00000000..449277e8 --- /dev/null +++ b/tests/Unit/Entities/_fixtures/notification/entity/client_token.updated.json @@ -0,0 +1,10 @@ +{ + "id": "ctkn_01ghbkd0frb9k95cnhwd1bxpvk", + "token": "live_7d279f61a3499fed520f7cd8c08", + "name": "Pricing page integration", + "description": "Used to display prices and open checkout within our pricing page on our marketing domain.", + "status": "revoked", + "created_at": "2025-06-26T14:36:14.695000Z", + "updated_at": "2025-06-27T11:26:10.253000Z", + "revoked_at": "2025-06-27T11:26:10.253000Z" +} \ No newline at end of file diff --git a/tests/Unit/Entities/test_Event.py b/tests/Unit/Entities/test_Event.py index f63d2166..8a1490cd 100644 --- a/tests/Unit/Entities/test_Event.py +++ b/tests/Unit/Entities/test_Event.py @@ -35,6 +35,9 @@ class TestEvent: ("business.created", "Business"), ("business.imported", "Business"), ("business.updated", "Business"), + ("client_token.created", "ClientToken"), + ("client_token.revoked", "ClientToken"), + ("client_token.updated", "ClientToken"), ("customer.created", "Customer"), ("customer.imported", "Customer"), ("customer.updated", "Customer"), @@ -87,6 +90,9 @@ class TestEvent: "business.created", "business.imported", "business.updated", + "client_token.created", + "client_token.revoked", + "client_token.updated", "customer.created", "customer.imported", "customer.updated", From f943df3aa54b65146c8d659c24beb9d9deca4835 Mon Sep 17 00:00:00 2001 From: David Grayston Date: Tue, 8 Jul 2025 14:58:53 +0100 Subject: [PATCH 2/3] docs: Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c02d40..44aff889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Check our main [developer changelog](https://developer.paddle.com/?utm_source=dx&utm_medium=paddle-python-sdk) for information about changes to the Paddle Billing platform, the Paddle API, and other developer tools. +## [Unreleased] + +### Added + +- Added support for client tokens + ## 1.7.0 - 2025-06-27 ### Added From c83761f95863f03959d2a9355813ef7f0e0cba11 Mon Sep 17 00:00:00 2001 From: David Grayston Date: Tue, 8 Jul 2025 18:47:00 +0100 Subject: [PATCH 3/3] docs: Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44aff889..30336dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Check our main [developer changelog](https://developer.paddle.com/?utm_source=dx ### Added -- Added support for client tokens +- Added support for client tokens see [related changelog](https://developer.paddle.com/changelog/2025/client-side-token-api?utm_source=dx&utm_medium=paddle-python-sdk) ## 1.7.0 - 2025-06-27