diff --git a/checkout_sdk/identities/addressdocumentverification/addressdocumentverification.py b/checkout_sdk/identities/addressdocumentverification/addressdocumentverification.py index 5f52f070..a7356141 100644 --- a/checkout_sdk/identities/addressdocumentverification/addressdocumentverification.py +++ b/checkout_sdk/identities/addressdocumentverification/addressdocumentverification.py @@ -2,14 +2,41 @@ class DeclaredData: + """The personal details provided by the applicant. + + Maps IdvDeclaredData. The identity verification requests take the larger + IdvIdentityDeclaredData shape and keep their own copy of this class. + """ + # The applicant's name. + # [Required] + # min 2 characters, max 255 characters + # Example: Hannah Bret name: str + # The applicant's birth date. + # [Optional] + # Format: yyyy-MM-dd + # Example: 1994-10-15 + birth_date: str class AddressDocumentVerificationRequest: + """Request body for POST /address-document-verifications.""" + # The applicant's unique identifier. + # [Required] + # ^aplt_\w+$ applicant_id: str + # Your configuration ID. + # [Required] + # ^usj_[a-z2-7]{26}$ user_journey_id: str + # The personal details provided by the applicant. + # [Optional] declared_data: DeclaredData class AddressDocumentVerificationAttemptRequest: + """Request body for POST /address-document-verifications/{id}/attempts.""" + # The address document image to verify, as a binary upload. + # [Required] + # Format: binary document: str diff --git a/checkout_sdk/identities/addressdocumentverification/addressdocumentverification_client.py b/checkout_sdk/identities/addressdocumentverification/addressdocumentverification_client.py index 2a0c0740..ae23aba1 100644 --- a/checkout_sdk/identities/addressdocumentverification/addressdocumentverification_client.py +++ b/checkout_sdk/identities/addressdocumentverification/addressdocumentverification_client.py @@ -4,6 +4,7 @@ from checkout_sdk.authorization_type import AuthorizationType from checkout_sdk.checkout_configuration import CheckoutConfiguration from checkout_sdk.client import Client +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter from checkout_sdk.identities.addressdocumentverification.addressdocumentverification import ( AddressDocumentVerificationRequest, AddressDocumentVerificationAttemptRequest ) @@ -14,6 +15,7 @@ class AddressDocumentVerificationClient(Client): __ANONYMIZE_PATH = 'anonymize' __ATTEMPTS_PATH = 'attempts' __PDF_REPORT_PATH = 'pdf-report' + __ASSETS_PATH = 'assets' def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): super().__init__(api_client=api_client, @@ -21,16 +23,40 @@ def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): authorization_type=AuthorizationType.SECRET_KEY_OR_OAUTH) def create_address_document_verification(self, request: AddressDocumentVerificationRequest): + """Create an address document verification. + Beta. + + Args: + request: The address document verification to create. + Returns: + ResponseWrapper with the created verification. + """ return self._api_client.post(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, self._sdk_authorization(), request) def get_address_document_verification(self, address_document_verification_id: str): + """Get the details of an address document verification. + Beta. + + Args: + address_document_verification_id: The address document verification's unique identifier. + Returns: + ResponseWrapper with the verification details. + """ return self._api_client.get( self.build_path(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, address_document_verification_id), self._sdk_authorization()) def anonymize_address_document_verification(self, address_document_verification_id: str): + """Anonymize an address document verification and its attempts. + Beta. + + Args: + address_document_verification_id: The address document verification's unique identifier. + Returns: + ResponseWrapper with the anonymized verification. + """ return self._api_client.post( self.build_path(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, address_document_verification_id, self.__ANONYMIZE_PATH), @@ -38,26 +64,84 @@ def anonymize_address_document_verification(self, address_document_verification_ def create_address_document_verification_attempt(self, address_document_verification_id: str, request: AddressDocumentVerificationAttemptRequest): + """Create an attempt for an address document verification, uploading the document image. + Beta. + + Args: + address_document_verification_id: The address document verification's unique identifier. + request: The attempt to create, carrying the document image. + Returns: + ResponseWrapper with the created attempt. + """ return self._api_client.post( self.build_path(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, address_document_verification_id, self.__ATTEMPTS_PATH), self._sdk_authorization(), request) - def get_address_document_verification_attempts(self, address_document_verification_id: str): + def get_address_document_verification_attempts(self, address_document_verification_id: str, + query: AttemptsQueryFilter = None): + """Get the details of all attempts for a specific address document verification. + + Results are paginated. Beta. + + Args: + address_document_verification_id: The address document verification's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated attempt list. + """ return self._api_client.get( self.build_path(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, address_document_verification_id, self.__ATTEMPTS_PATH), - self._sdk_authorization()) + self._sdk_authorization(), + query) def get_address_document_verification_attempt(self, address_document_verification_id: str, attempt_id: str): + """Get the details of a single address document verification attempt. + Beta. + + Args: + address_document_verification_id: The address document verification's unique identifier. + attempt_id: The attempt's unique identifier. + Returns: + ResponseWrapper with the attempt details. + """ return self._api_client.get( self.build_path(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, address_document_verification_id, self.__ATTEMPTS_PATH, attempt_id), self._sdk_authorization()) def get_address_document_verification_report(self, address_document_verification_id: str): + """Get the PDF report for an address document verification. + Beta. + + Args: + address_document_verification_id: The address document verification's unique identifier. + Returns: + ResponseWrapper carrying pdf_report, the pre-signed URL to the PDF. + """ return self._api_client.get( self.build_path(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, address_document_verification_id, self.__PDF_REPORT_PATH), self._sdk_authorization()) + + def get_address_document_verification_attempt_assets(self, address_document_verification_id: str, + attempt_id: str, + query: AttemptAssetsQueryFilter = None): + """Get the assets (the document image) uploaded for an address document verification attempt. + + Results are paginated. Beta. + + Args: + address_document_verification_id: The address document verification's unique identifier. + attempt_id: The attempt's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated asset list. + """ + return self._api_client.get( + self.build_path(self.__ADDRESS_DOCUMENT_VERIFICATIONS_PATH, address_document_verification_id, + self.__ATTEMPTS_PATH, attempt_id, self.__ASSETS_PATH), + self._sdk_authorization(), + query) diff --git a/checkout_sdk/identities/entities.py b/checkout_sdk/identities/entities.py index f25a9930..08e4bb75 100644 --- a/checkout_sdk/identities/entities.py +++ b/checkout_sdk/identities/entities.py @@ -1,3 +1,93 @@ +from __future__ import absolute_import + +from enum import Enum + + +class IdvDocumentType(str, Enum): + """The type of identity document, for the identity verification endpoints. + + Deliberately separate from checkout_sdk.common.enums.DocumentType, which the accounts + endpoints use. The two share no values and even spell the licence differently: accounts sends + 'driving_license', identity verification sends 'Driving licence'. Modelling them as one type + would send values the API rejects. + """ + DRIVING_LICENCE = 'Driving licence' + ID = 'ID' + OTHER = 'Other' + PASSPORT = 'Passport' + RESIDENCE_PERMIT = 'Residence Permit' + TRAVEL_DOCUMENT = 'Travel Document' + VISA = 'Visa' + + +class AttemptsQueryFilter: + """Pagination for the list-attempts endpoints.""" + # The number of attempts to skip. + # [Optional] + # Default: 0 + skip: int + # The maximum number of attempts to return. + # [Optional] + # Default: 10 + limit: int + + class AttemptAssetsQueryFilter: + """Pagination for the attempt-assets endpoints.""" + # The number of assets to skip. + # [Optional] + # Default: 0 skip: int + # The maximum number of assets to return. + # [Optional] + # Default: 10 limit: int + + +class PhoneNumber: + """The applicant's mobile phone number, if sharing the attempt URL via SMS.""" + # The international phone country code. This is a dialling prefix, not an ISO country code. + # [Required] + # ^\+(\d+)$ + # Example: +33 + country_code: str + # The applicant's mobile number, without the country code. + # [Required] + # ^\d{1,14}$ + # Example: 5555550102 + number: str + + +class IdvAddress: + """The applicant's address.""" + # The first line of the address. + # [Optional] + # max 250 characters + # Example: 123 Main Street + address_line1: str + # The second line of the address. + # [Optional] + # max 250 characters + # Example: Apt 4B + address_line2: str + # The city or town. + # [Optional] + # max 50 characters + # Example: London + city: str + # The state, county, or province. + # [Optional] + # max 50 characters + # Example: Greater London + state: str + # The postal or ZIP code. + # [Optional] + # max 50 characters + # Example: SW1A 1AA + zip: str + # The two-letter ISO country code of the address. + # [Optional] + # Standard: ISO 3166-1 alpha-2 country code + # max 2 characters + # Example: GB + country: str diff --git a/checkout_sdk/identities/faceauthentication/faceauthentication.py b/checkout_sdk/identities/faceauthentication/faceauthentication.py index 8252414b..1b0ad8db 100644 --- a/checkout_sdk/identities/faceauthentication/faceauthentication.py +++ b/checkout_sdk/identities/faceauthentication/faceauthentication.py @@ -1,16 +1,48 @@ from __future__ import absolute_import +from checkout_sdk.identities.entities import PhoneNumber + class ClientInformation: + """The applicant's details for a face authentication attempt. + + Maps FavClientInformation. Deliberately smaller than the identity verification copy in + checkout_sdk.identities.identityverification: the face authentication attempt schema does not + declare pre_selected_document_issuing_country or pre_selected_document_type, so sending them + here would be a request the API rejects. + """ + # The applicant's residence country. + # [Optional] + # Standard: ISO 3166-1 alpha-2 country code + # ^[A-Z]{2} + # Example: FR pre_selected_residence_country: str + # The language you want to use for the user interface. + # [Optional] + # Format: IETF BCP 47 language tag + # Example: en-US pre_selected_language: str class FaceAuthenticationRequest: + """Request body for POST /face-authentications.""" + # The applicant's unique identifier. + # [Required] applicant_id: str + # Your configuration ID. + # [Optional] user_journey_id: str class FaceAuthenticationAttemptRequest: + """Request body for POST /face-authentications/{id}/attempts.""" + # The URL to redirect the applicant to after the attempt. + # [Required] + # Format: uri redirect_url: str + # The applicant's mobile phone number, if sharing the attempt URL via SMS. + # [Optional] + phone_number: PhoneNumber + # The applicant's details. + # [Optional] client_information: ClientInformation diff --git a/checkout_sdk/identities/faceauthentication/faceauthentication_client.py b/checkout_sdk/identities/faceauthentication/faceauthentication_client.py index 36b36f27..d3e4638e 100644 --- a/checkout_sdk/identities/faceauthentication/faceauthentication_client.py +++ b/checkout_sdk/identities/faceauthentication/faceauthentication_client.py @@ -4,7 +4,7 @@ from checkout_sdk.authorization_type import AuthorizationType from checkout_sdk.checkout_configuration import CheckoutConfiguration from checkout_sdk.client import Client -from checkout_sdk.identities.entities import AttemptAssetsQueryFilter +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter from checkout_sdk.identities.faceauthentication.faceauthentication import ( FaceAuthenticationRequest, FaceAuthenticationAttemptRequest ) @@ -22,32 +22,86 @@ def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): authorization_type=AuthorizationType.SECRET_KEY_OR_OAUTH) def create_face_authentication(self, request: FaceAuthenticationRequest): + """Create a face authentication. + Beta. + + Args: + request: The face authentication to create. + Returns: + ResponseWrapper with the created face authentication. + """ return self._api_client.post(self.__FACE_AUTHENTICATIONS_PATH, self._sdk_authorization(), request) def get_face_authentication(self, face_authentication_id: str): + """Get the details of a face authentication. + Beta. + + Args: + face_authentication_id: The face authentication's unique identifier. + Returns: + ResponseWrapper with the face authentication details. + """ return self._api_client.get(self.build_path(self.__FACE_AUTHENTICATIONS_PATH, face_authentication_id), self._sdk_authorization()) def anonymize_face_authentication(self, face_authentication_id: str): + """Anonymize a face authentication and its attempts. + Beta. + + Args: + face_authentication_id: The face authentication's unique identifier. + Returns: + ResponseWrapper with the anonymized face authentication. + """ return self._api_client.post( self.build_path(self.__FACE_AUTHENTICATIONS_PATH, face_authentication_id, self.__ANONYMIZE_PATH), self._sdk_authorization()) def create_face_authentication_attempt(self, face_authentication_id: str, request: FaceAuthenticationAttemptRequest): + """Create an attempt for a face authentication. + Beta. + + Args: + face_authentication_id: The face authentication's unique identifier. + request: The attempt to create. + Returns: + ResponseWrapper with the created attempt, including redirect_url. + """ return self._api_client.post( self.build_path(self.__FACE_AUTHENTICATIONS_PATH, face_authentication_id, self.__ATTEMPTS_PATH), self._sdk_authorization(), request) - def get_face_authentication_attempts(self, face_authentication_id: str): + def get_face_authentication_attempts(self, face_authentication_id: str, + query: AttemptsQueryFilter = None): + """Get the details of all attempts for a specific face authentication. + + Results are paginated. Beta. + + Args: + face_authentication_id: The face authentication's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated attempt list. + """ return self._api_client.get( self.build_path(self.__FACE_AUTHENTICATIONS_PATH, face_authentication_id, self.__ATTEMPTS_PATH), - self._sdk_authorization()) + self._sdk_authorization(), + query) def get_face_authentication_attempt(self, face_authentication_id: str, attempt_id: str): + """Get the details of a single face authentication attempt. + Beta. + + Args: + face_authentication_id: The face authentication's unique identifier. + attempt_id: The attempt's unique identifier. + Returns: + ResponseWrapper with the attempt details. + """ return self._api_client.get( self.build_path(self.__FACE_AUTHENTICATIONS_PATH, face_authentication_id, self.__ATTEMPTS_PATH, attempt_id), @@ -55,6 +109,17 @@ def get_face_authentication_attempt(self, face_authentication_id: str, attempt_i def get_face_authentication_attempt_assets(self, face_authentication_id: str, attempt_id: str, query: AttemptAssetsQueryFilter = None): + """Get the assets (face images and videos) captured during a face authentication attempt. + Videos are not exposed by default; contact your account manager to enable them. + Results are paginated. Beta. + + Args: + face_authentication_id: The face authentication's unique identifier. + attempt_id: The attempt's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated asset list. + """ return self._api_client.get( self.build_path(self.__FACE_AUTHENTICATIONS_PATH, face_authentication_id, self.__ATTEMPTS_PATH, attempt_id, self.__ASSETS_PATH), diff --git a/checkout_sdk/identities/iddocumentverification/iddocumentverification.py b/checkout_sdk/identities/iddocumentverification/iddocumentverification.py index 3cebf3cd..787d449c 100644 --- a/checkout_sdk/identities/iddocumentverification/iddocumentverification.py +++ b/checkout_sdk/identities/iddocumentverification/iddocumentverification.py @@ -2,15 +2,43 @@ class DeclaredData: + """The personal details provided by the applicant. + + Maps IdvDeclaredData. The identity verification requests take the larger + IdvIdentityDeclaredData shape and keep their own copy of this class. + """ + # The applicant's name. + # [Required] + # min 2 characters, max 255 characters + # Example: Hannah Bret name: str + # The applicant's birth date. + # [Optional] + # Format: yyyy-MM-dd + # Example: 1994-10-15 + birth_date: str class IdDocumentVerificationRequest: + """Request body for POST /id-document-verifications.""" + # The applicant's unique identifier. + # [Required] applicant_id: str + # Your configuration ID. + # [Optional] user_journey_id: str + # The personal details provided by the applicant. + # [Optional] declared_data: DeclaredData class IdDocumentVerificationAttemptRequest: + """Request body for POST /id-document-verifications/{id}/attempts.""" + # The front image of the identity document, as a binary upload. + # [Required] + # Format: binary document_front: str + # The back image of the identity document, as a binary upload. + # [Optional] + # Format: binary document_back: str diff --git a/checkout_sdk/identities/iddocumentverification/iddocumentverification_client.py b/checkout_sdk/identities/iddocumentverification/iddocumentverification_client.py index e873caec..9a52e1c0 100644 --- a/checkout_sdk/identities/iddocumentverification/iddocumentverification_client.py +++ b/checkout_sdk/identities/iddocumentverification/iddocumentverification_client.py @@ -4,6 +4,7 @@ from checkout_sdk.authorization_type import AuthorizationType from checkout_sdk.checkout_configuration import CheckoutConfiguration from checkout_sdk.client import Client +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter from checkout_sdk.identities.iddocumentverification.iddocumentverification import ( IdDocumentVerificationRequest, IdDocumentVerificationAttemptRequest ) @@ -14,6 +15,7 @@ class IdDocumentVerificationClient(Client): __ANONYMIZE_PATH = 'anonymize' __ATTEMPTS_PATH = 'attempts' __PDF_REPORT_PATH = 'pdf-report' + __ASSETS_PATH = 'assets' def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): super().__init__(api_client=api_client, @@ -21,16 +23,40 @@ def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): authorization_type=AuthorizationType.SECRET_KEY_OR_OAUTH) def create_id_document_verification(self, request: IdDocumentVerificationRequest): + """Create an ID document verification. + Beta. + + Args: + request: The ID document verification to create. + Returns: + ResponseWrapper with the created verification. + """ return self._api_client.post(self.__ID_DOCUMENT_VERIFICATIONS_PATH, self._sdk_authorization(), request) def get_id_document_verification(self, id_document_verification_id: str): + """Get the details of an ID document verification. + Beta. + + Args: + id_document_verification_id: The ID document verification's unique identifier. + Returns: + ResponseWrapper with the verification details. + """ return self._api_client.get( self.build_path(self.__ID_DOCUMENT_VERIFICATIONS_PATH, id_document_verification_id), self._sdk_authorization()) def anonymize_id_document_verification(self, id_document_verification_id: str): + """Anonymize an ID document verification and its attempts. + Beta. + + Args: + id_document_verification_id: The ID document verification's unique identifier. + Returns: + ResponseWrapper with the anonymized verification. + """ return self._api_client.post( self.build_path(self.__ID_DOCUMENT_VERIFICATIONS_PATH, id_document_verification_id, self.__ANONYMIZE_PATH), @@ -38,26 +64,84 @@ def anonymize_id_document_verification(self, id_document_verification_id: str): def create_id_document_verification_attempt(self, id_document_verification_id: str, request: IdDocumentVerificationAttemptRequest): + """Create an attempt for an ID document verification, uploading the document images. + Beta. + + Args: + id_document_verification_id: The ID document verification's unique identifier. + request: The attempt to create, carrying the front and back document images. + Returns: + ResponseWrapper with the created attempt. + """ return self._api_client.post( self.build_path(self.__ID_DOCUMENT_VERIFICATIONS_PATH, id_document_verification_id, self.__ATTEMPTS_PATH), self._sdk_authorization(), request) - def get_id_document_verification_attempts(self, id_document_verification_id: str): + def get_id_document_verification_attempts(self, id_document_verification_id: str, + query: AttemptsQueryFilter = None): + """Get the details of all attempts for a specific ID document verification. + + Results are paginated. Beta. + + Args: + id_document_verification_id: The ID document verification's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated attempt list. + """ return self._api_client.get( self.build_path(self.__ID_DOCUMENT_VERIFICATIONS_PATH, id_document_verification_id, self.__ATTEMPTS_PATH), - self._sdk_authorization()) + self._sdk_authorization(), + query) def get_id_document_verification_attempt(self, id_document_verification_id: str, attempt_id: str): + """Get the details of a single ID document verification attempt. + Beta. + + Args: + id_document_verification_id: The ID document verification's unique identifier. + attempt_id: The attempt's unique identifier. + Returns: + ResponseWrapper with the attempt details. + """ return self._api_client.get( self.build_path(self.__ID_DOCUMENT_VERIFICATIONS_PATH, id_document_verification_id, self.__ATTEMPTS_PATH, attempt_id), self._sdk_authorization()) def get_id_document_verification_report(self, id_document_verification_id: str): + """Get the PDF report for an ID document verification. + Beta. + + Args: + id_document_verification_id: The ID document verification's unique identifier. + Returns: + ResponseWrapper carrying pdf_report, the pre-signed URL to the PDF. + """ return self._api_client.get( self.build_path(self.__ID_DOCUMENT_VERIFICATIONS_PATH, id_document_verification_id, self.__PDF_REPORT_PATH), self._sdk_authorization()) + + def get_id_document_verification_attempt_assets(self, id_document_verification_id: str, attempt_id: str, + query: AttemptAssetsQueryFilter = None): + """Get the assets (the front and back images of the document) uploaded for an ID document + verification attempt. + + Results are paginated. Beta. + + Args: + id_document_verification_id: The ID document verification's unique identifier. + attempt_id: The attempt's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated asset list. + """ + return self._api_client.get( + self.build_path(self.__ID_DOCUMENT_VERIFICATIONS_PATH, id_document_verification_id, + self.__ATTEMPTS_PATH, attempt_id, self.__ASSETS_PATH), + self._sdk_authorization(), + query) diff --git a/checkout_sdk/identities/identityverification/identityverification.py b/checkout_sdk/identities/identityverification/identityverification.py index cb62341e..bbabd172 100644 --- a/checkout_sdk/identities/identityverification/identityverification.py +++ b/checkout_sdk/identities/identityverification/identityverification.py @@ -1,28 +1,109 @@ from __future__ import absolute_import +from checkout_sdk.identities.entities import IdvAddress, IdvDocumentType, PhoneNumber + class DeclaredData: + """The personal details provided by the applicant for an identity verification. + + Maps IdvIdentityDeclaredData, which is the shape the identity verification requests accept. + The address document and ID document verification requests take the smaller IdvDeclaredData + shape instead, and keep their own copies of this class. + """ + # The applicant's name. + # [Required] + # min 2 characters, max 255 characters + # Example: Hannah Bret name: str + # The applicant's birth date. + # [Optional] + # Format: yyyy-MM-dd + # Example: 1994-10-15 + birth_date: str + # The applicant's mobile phone number, if sharing the attempt URL via SMS. + # [Optional] + phone_number: PhoneNumber + # The applicant's email address. Explicitly nullable in the spec, so the API may return null + # for it rather than omitting it. + # [Optional] + # Format: email + # Nullable: true + # Example: hannah.bret@example.com + email: str + # The applicant's address. + # [Optional] + address: IdvAddress class ClientInformation: + """The applicant's details for an identity verification attempt. + + Maps IdvClientInformation. The face authentication attempt takes the smaller + FavClientInformation shape and keeps its own copy of this class, so the two document fields + below cannot leak onto a face authentication request. + """ + # The applicant's residence country. + # [Optional] + # Standard: ISO 3166-1 alpha-2 country code + # ^[A-Z]{2} + # Example: FR pre_selected_residence_country: str + # The country that issued the applicant's identity document. + # [Optional] + # Standard: ISO 3166-1 alpha-2 country code + # ^[A-Z]{2} + # Example: FR + pre_selected_document_issuing_country: str + # The type of identity document the applicant uses for the attempt. + # [Optional] + # Enum: "Driving licence" "ID" "Other" "Passport" "Residence Permit" "Travel Document" "Visa" + pre_selected_document_type: IdvDocumentType + # The language you want to use for the user interface. + # [Optional] + # Format: IETF BCP 47 language tag + # Example: en-US pre_selected_language: str class IdentityVerificationRequest: + """Request body for POST /identity-verifications.""" + # The applicant's unique identifier. + # [Required] applicant_id: str + # The personal details provided by the applicant. + # [Required] declared_data: DeclaredData + # Your configuration ID. + # [Optional] user_journey_id: str class IdentityVerificationAndAttemptRequest: + """Request body for POST /create-and-open-idv.""" + # The personal details provided by the applicant. + # [Required] declared_data: DeclaredData + # The URL to redirect the applicant to after the attempt. + # [Required] + # Format: uri redirect_url: str + # Your configuration ID. + # [Optional] user_journey_id: str + # The applicant's unique identifier. + # [Optional] applicant_id: str class IdentityVerificationAttemptRequest: + """Request body for POST /identity-verifications/{id}/attempts.""" + # The URL to redirect the applicant to after the attempt. + # [Required] + # Format: uri redirect_url: str + # The applicant's mobile phone number, if sharing the attempt URL via SMS. + # [Optional] + phone_number: PhoneNumber + # The applicant's details. + # [Optional] client_information: ClientInformation diff --git a/checkout_sdk/identities/identityverification/identityverification_client.py b/checkout_sdk/identities/identityverification/identityverification_client.py index 1e4741a7..f359ea03 100644 --- a/checkout_sdk/identities/identityverification/identityverification_client.py +++ b/checkout_sdk/identities/identityverification/identityverification_client.py @@ -4,7 +4,7 @@ from checkout_sdk.authorization_type import AuthorizationType from checkout_sdk.checkout_configuration import CheckoutConfiguration from checkout_sdk.client import Client -from checkout_sdk.identities.entities import AttemptAssetsQueryFilter +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter from checkout_sdk.identities.identityverification.identityverification import ( IdentityVerificationRequest, IdentityVerificationAndAttemptRequest, @@ -26,37 +26,99 @@ def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): authorization_type=AuthorizationType.SECRET_KEY_OR_OAUTH) def create_identity_verification_and_attempt(self, request: IdentityVerificationAndAttemptRequest): + """Create an identity verification and open its first attempt in one call. + Beta. + + Args: + request: The identity verification and attempt to create. + Returns: + ResponseWrapper with the created verification, including redirect_url. + """ return self._api_client.post(self.__CREATE_AND_OPEN_PATH, self._sdk_authorization(), request) def create_identity_verification(self, request: IdentityVerificationRequest): + """Create an identity verification. + Beta. + + Args: + request: The identity verification to create. + Returns: + ResponseWrapper with the created verification. + """ return self._api_client.post(self.__IDENTITY_VERIFICATIONS_PATH, self._sdk_authorization(), request) def get_identity_verification(self, identity_verification_id: str): + """Get the details of an identity verification. + Beta. + + Args: + identity_verification_id: The identity verification's unique identifier. + Returns: + ResponseWrapper with the verification details. + """ return self._api_client.get(self.build_path(self.__IDENTITY_VERIFICATIONS_PATH, identity_verification_id), self._sdk_authorization()) def anonymize_identity_verification(self, identity_verification_id: str): + """Anonymize an identity verification and its attempts. + Beta. + + Args: + identity_verification_id: The identity verification's unique identifier. + Returns: + ResponseWrapper with the anonymized verification. + """ return self._api_client.post( self.build_path(self.__IDENTITY_VERIFICATIONS_PATH, identity_verification_id, self.__ANONYMIZE_PATH), self._sdk_authorization()) def create_identity_verification_attempt(self, identity_verification_id: str, request: IdentityVerificationAttemptRequest): + """Create an attempt for an identity verification. + Beta. + + Args: + identity_verification_id: The identity verification's unique identifier. + request: The attempt to create. + Returns: + ResponseWrapper with the created attempt, including redirect_url. + """ return self._api_client.post( self.build_path(self.__IDENTITY_VERIFICATIONS_PATH, identity_verification_id, self.__ATTEMPTS_PATH), self._sdk_authorization(), request) - def get_identity_verification_attempts(self, identity_verification_id: str): + def get_identity_verification_attempts(self, identity_verification_id: str, + query: AttemptsQueryFilter = None): + """Get all the attempts for a specific identity verification. + + Results are paginated. Beta. + + Args: + identity_verification_id: The identity verification's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated attempt list. + """ return self._api_client.get( self.build_path(self.__IDENTITY_VERIFICATIONS_PATH, identity_verification_id, self.__ATTEMPTS_PATH), - self._sdk_authorization()) + self._sdk_authorization(), + query) def get_identity_verification_attempt(self, identity_verification_id: str, attempt_id: str): + """Get the details of a single identity verification attempt. + Beta. + + Args: + identity_verification_id: The identity verification's unique identifier. + attempt_id: The attempt's unique identifier. + Returns: + ResponseWrapper with the attempt details. + """ return self._api_client.get( self.build_path(self.__IDENTITY_VERIFICATIONS_PATH, identity_verification_id, self.__ATTEMPTS_PATH, attempt_id), @@ -64,6 +126,18 @@ def get_identity_verification_attempt(self, identity_verification_id: str, attem def get_identity_verification_attempt_assets(self, identity_verification_id: str, attempt_id: str, query: AttemptAssetsQueryFilter = None): + """Get the assets (face images, videos, and document images) captured during an identity + verification attempt. Videos are not exposed by default; contact your account manager + to enable them. + Results are paginated. Beta. + + Args: + identity_verification_id: The identity verification's unique identifier. + attempt_id: The attempt's unique identifier. + query: Optional skip and limit pagination parameters. + Returns: + ResponseWrapper with the paginated asset list. + """ return self._api_client.get( self.build_path(self.__IDENTITY_VERIFICATIONS_PATH, identity_verification_id, self.__ATTEMPTS_PATH, attempt_id, self.__ASSETS_PATH), @@ -71,6 +145,14 @@ def get_identity_verification_attempt_assets(self, identity_verification_id: str query) def get_identity_verification_report(self, identity_verification_id: str): + """Get the PDF report for an identity verification. + Beta. + + Args: + identity_verification_id: The identity verification's unique identifier. + Returns: + ResponseWrapper carrying pdf_report, the pre-signed URL to the PDF. + """ return self._api_client.get( self.build_path(self.__IDENTITY_VERIFICATIONS_PATH, identity_verification_id, self.__PDF_REPORT_PATH), self._sdk_authorization()) diff --git a/checkout_sdk/issuing/cards.py b/checkout_sdk/issuing/cards.py index 2aa8f3ba..0fe90575 100644 --- a/checkout_sdk/issuing/cards.py +++ b/checkout_sdk/issuing/cards.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Dict from checkout_sdk.common.common import Address, Phone @@ -53,16 +54,59 @@ class CardMetadata: class CardRequest: + """Shared base for the card creation requests. The concrete type is chosen by the subclass, + which sets the discriminator in its constructor.""" + # The card type. + # [Required] + # Enum: "virtual" "physical" + # Example: virtual type: CardType + # The cardholder's unique identifier. + # [Required] + # ^crh_[a-z0-9]{26}$ + # min 30 characters, max 30 characters + # Example: crh_d3ozhf43pcq2xbldn2g45qnb44 cardholder_id: str + # The duration of time during which the card will accept incoming authorizations. The unit + # and value combination determines the card's expiry date. + # [Optional] lifetime: CardLifetime + # Your reference. + # [Optional] + # max 256 characters + # Example: X-123456-N11 reference: str + # The card product's unique identifier. Required if more than one card product is associated + # with the entity. + # [Required] card_product_id: str + # The name to display on the card. + # [Optional] + # ^[0-9a-zA-Z.\- ]{2,26}$ + # min 2 characters, max 26 characters + # Example: JOHN KENNEDY display_name: str + # Sets whether to activate the newly created card upon creation. If false, the cardholder + # cannot process transactions until you activate the card. + # [Optional] + # Default: true activate_card: bool + # User's metadata. + # [Optional] metadata: CardMetadata + # Date scheduling the card's automatic revocation. + # [Optional] + # Format: yyyy-MM-dd (time is midnight UTC) + # Example: 2027-03-12 revocation_date: str - activation_date: str # ISO-8601 (IssuingActivationDate) + # Date scheduling the card's first activation. Only applies to the initial activation of a + # card. Two formats are supported: date only (YYYY-MM-DD, treated as midnight UTC), or date + # with round hour (YYYY-MM-DDTHH:mmZ in UTC, or YYYY-MM-DDTHH:mm+HH:mm with offset). Only + # round hours are allowed when a time is provided (HH:00). The value must be at least the + # next round hour after the request time. + # [Optional] + # Example: 2026-06-01T10:00Z + scheduled_activation_date: str def __init__(self, type_p: CardType): self.type = type_p @@ -86,12 +130,68 @@ def __init__(self): class UpdateCardRequest: + """Request body for PATCH /issuing/cards/{cardId}.""" + # Your reference. + # [Optional] + # max 256 characters + # Example: X-123456-N11 reference: str + # User's metadata. + # [Optional] metadata: CardMetadata + # The card's expiration month. + # [Optional] + # Format: int32 + # min 1, max 12 + # Example: 5 expiry_month: int + # The card's expiration year. + # [Optional] + # Format: int32 + # min 4 characters, max 4 characters + # Example: 2025 expiry_year: int - activation_date: str # ISO-8601 (IssuingActivationDate) - revocation_date: str # yyyy-mm-dd (IssuingRevocationDate) + # Date scheduling the card's first activation. Only applies to the initial activation of a + # card. Two formats are supported: date only (YYYY-MM-DD, treated as midnight UTC), or date + # with round hour (YYYY-MM-DDTHH:mmZ in UTC, or YYYY-MM-DDTHH:mm+HH:mm with offset). Only + # round hours are allowed when a time is provided (HH:00). The value must be at least the + # next round hour after the request time. + # [Optional] + # Example: 2026-06-01T10:00Z + scheduled_activation_date: str + # Date scheduling the card's automatic revocation. + # [Optional] + # Format: yyyy-MM-dd (time is midnight UTC) + # Example: 2027-03-12 + revocation_date: str + + +class CardUpdateHeaders: + """The optional HTTP headers accepted when updating a card's details. + + Header values are stringified by ApiClient, so declare the boolean header as the string + "true" rather than a Python bool: str(True) is "True", which is not the value the spec + shows. All three existing header classes in this SDK use str for the same reason. + """ + # Set to "true" to retrieve the card's encrypted credentials in the response. Requires an RSA + # public key to be provided in the Encryption-Key header. + # [Optional] + # Maps to HTTP header return-encrypted-cvv. + # Example: "true" + return_encrypted_cvv: str + # The RSA public key used to encrypt returned credentials. Required when the + # return-encrypted-cvv header is set to "true". Provide the public key with the + # BEGIN PUBLIC KEY and END PUBLIC KEY headers and any newline characters removed, encoded as + # Base64. + # [Optional] + # Maps to HTTP header Encryption-Key. + encryption_key: str + + def get_header_mappings(self) -> Dict[str, str]: + return { + 'return_encrypted_cvv': 'return-encrypted-cvv', + 'encryption_key': 'Encryption-Key' + } class RenewCardRequest: diff --git a/checkout_sdk/issuing/issuing_client.py b/checkout_sdk/issuing/issuing_client.py index 92c46c1c..d9cfdadb 100644 --- a/checkout_sdk/issuing/issuing_client.py +++ b/checkout_sdk/issuing/issuing_client.py @@ -5,8 +5,9 @@ from checkout_sdk.checkout_configuration import CheckoutConfiguration from checkout_sdk.client import Client from checkout_sdk.issuing.cardholders import CardholderRequest -from checkout_sdk.issuing.cards import CardRequest, ThreeDsEnrollmentRequest, UpdateThreeDsEnrollmentRequest, \ - CardCredentialsQuery, RevokeRequest, SuspendRequest, UpdateCardRequest, RenewCardRequest +from checkout_sdk.issuing.cards import CardRequest, CardUpdateHeaders, ThreeDsEnrollmentRequest, \ + UpdateThreeDsEnrollmentRequest, CardCredentialsQuery, RevokeRequest, SuspendRequest, UpdateCardRequest, \ + RenewCardRequest from checkout_sdk.issuing.controls import CardControlRequest, CardControlsQuery, UpdateCardControlRequest, \ CreateControlGroupRequest, ControlGroupQueryTarget, ControlProfileRequest from checkout_sdk.issuing.disputes import CreateDisputeRequest, EscalateDisputeRequest, AmendDisputeRequest, \ @@ -78,10 +79,25 @@ def get_card_details(self, card_id: str): return self._api_client.get(self.build_path(self.__ISSUING, self.__CARDS, card_id), self._sdk_authorization()) - def update_card(self, card_id: str, update_card_request: UpdateCardRequest): + def update_card(self, card_id: str, update_card_request: UpdateCardRequest, + headers: CardUpdateHeaders = None): + """Update the details of an issued card. + + Only the fields for which you provide values are updated. + + Args: + card_id: The card's unique identifier. + update_card_request: The card fields to update. + headers: Optional return-encrypted-cvv and Encryption-Key HTTP headers. Setting + return-encrypted-cvv to "true" without Encryption-Key returns a 422 with error + code encryption_key_required. + Returns: + ResponseWrapper with the update response, including encrypted_cvv when requested. + """ return self._api_client.patch(self.build_path(self.__ISSUING, self.__CARDS, card_id), self._sdk_authorization(), - update_card_request) + update_card_request, + headers=headers) def enroll_three_ds(self, card_id: str, three_ds_enrollment_request: ThreeDsEnrollmentRequest): return self._api_client.post(self.build_path(self.__ISSUING, self.__CARDS, card_id, self.__THREE_DS), diff --git a/tests/identities/addressdocumentverification/addressdocumentverification_client_test.py b/tests/identities/addressdocumentverification/addressdocumentverification_client_test.py index 56cd913d..0eb319b7 100644 --- a/tests/identities/addressdocumentverification/addressdocumentverification_client_test.py +++ b/tests/identities/addressdocumentverification/addressdocumentverification_client_test.py @@ -1,6 +1,7 @@ import pytest from tests._assertions import assert_api_call +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter from checkout_sdk.identities.addressdocumentverification.addressdocumentverification import ( AddressDocumentVerificationRequest, AddressDocumentVerificationAttemptRequest ) @@ -62,3 +63,34 @@ def test_should_get_address_document_verification_report(self, mocker, assert client.get_address_document_verification_report('adv_12345') == 'response' assert_api_call(mock, 'address-document-verifications/adv_12345/pdf-report') + + def test_should_get_address_document_verification_attempts_with_pagination( + self, mocker, client: AddressDocumentVerificationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + query = AttemptsQueryFilter() + query.skip = 5 + query.limit = 25 + + assert client.get_address_document_verification_attempts('adv_12345', query) == 'response' + assert_api_call(mock, 'address-document-verifications/adv_12345/attempts') + assert mock.call_args.args[2] is query + + def test_should_get_address_document_verification_attempt_assets( + self, mocker, client: AddressDocumentVerificationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + query = AttemptAssetsQueryFilter() + query.limit = 10 + + assert client.get_address_document_verification_attempt_assets( + 'adv_12345', 'adva_67890', query) == 'response' + assert_api_call(mock, 'address-document-verifications/adv_12345/attempts/adva_67890/assets') + assert mock.call_args.args[2] is query + + def test_should_get_address_document_verification_attempt_assets_without_a_filter( + self, mocker, client: AddressDocumentVerificationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_address_document_verification_attempt_assets( + 'adv_12345', 'adva_67890') == 'response' + assert_api_call(mock, 'address-document-verifications/adv_12345/attempts/adva_67890/assets') + assert mock.call_args.args[2] is None diff --git a/tests/identities/addressdocumentverification/addressdocumentverification_integration_test.py b/tests/identities/addressdocumentverification/addressdocumentverification_integration_test.py new file mode 100644 index 00000000..0100374c --- /dev/null +++ b/tests/identities/addressdocumentverification/addressdocumentverification_integration_test.py @@ -0,0 +1,113 @@ +import pytest + +from checkout_sdk.identities.addressdocumentverification.addressdocumentverification import ( + AddressDocumentVerificationRequest, AddressDocumentVerificationAttemptRequest, DeclaredData +) +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter +from tests.checkout_test_utils import assert_response, new_uuid + + +# tests + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_create_address_document_verification(default_api): + response = default_api.address_document_verification.create_address_document_verification( + address_document_verification_request()) + assert_address_document_verification_response(response) + + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_address_document_verification(default_api): + created = default_api.address_document_verification.create_address_document_verification( + address_document_verification_request()) + retrieved = default_api.address_document_verification.get_address_document_verification(created.id) + assert_address_document_verification_response(retrieved) + assert retrieved.id == created.id + + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_address_document_verification_attempts(default_api): + created = default_api.address_document_verification.create_address_document_verification( + address_document_verification_request()) + created_attempt = default_api.address_document_verification.create_address_document_verification_attempt( + created.id, address_document_verification_attempt_request()) + + attempts = default_api.address_document_verification.get_address_document_verification_attempts(created.id) + assert_response(attempts, 'http_metadata', 'total_count', 'skip', 'limit', 'data') + assert any(a.id == created_attempt.id for a in attempts.data) + + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_address_document_verification_attempts_with_pagination(default_api): + created = default_api.address_document_verification.create_address_document_verification( + address_document_verification_request()) + default_api.address_document_verification.create_address_document_verification_attempt( + created.id, address_document_verification_attempt_request()) + + query = AttemptsQueryFilter() + query.skip = 0 + query.limit = 1 + + attempts = default_api.address_document_verification.get_address_document_verification_attempts( + created.id, query) + assert_response(attempts, 'http_metadata', 'total_count', 'skip', 'limit', 'data') + assert attempts.limit == 1 + assert len(attempts.data) <= 1 + + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_address_document_verification_attempt_assets(default_api): + created = default_api.address_document_verification.create_address_document_verification( + address_document_verification_request()) + created_attempt = default_api.address_document_verification.create_address_document_verification_attempt( + created.id, address_document_verification_attempt_request()) + + query = AttemptAssetsQueryFilter() + query.limit = 10 + + assets = default_api.address_document_verification.get_address_document_verification_attempt_assets( + created.id, created_attempt.id, query) + assert_response(assets, 'http_metadata', 'total_count', 'skip', 'limit', 'data') + for asset in assets.data: + assert asset.type == 'document' + assert asset._links.asset_url.href is not None + + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_address_document_verification_report(default_api): + created = default_api.address_document_verification.create_address_document_verification( + address_document_verification_request()) + report = default_api.address_document_verification.get_address_document_verification_report(created.id) + assert_response(report, 'http_metadata', 'pdf_report') + + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_anonymize_address_document_verification(default_api): + created = default_api.address_document_verification.create_address_document_verification( + address_document_verification_request()) + response = default_api.address_document_verification.anonymize_address_document_verification(created.id) + assert_response(response, 'http_metadata', 'id') + + +# common methods + +def address_document_verification_request() -> AddressDocumentVerificationRequest: + declared_data = DeclaredData() + declared_data.name = 'Hannah Bret' + declared_data.birth_date = '1994-10-15' + + request = AddressDocumentVerificationRequest() + request.applicant_id = new_uuid() + request.user_journey_id = new_uuid() + request.declared_data = declared_data + return request + + +def address_document_verification_attempt_request() -> AddressDocumentVerificationAttemptRequest: + request = AddressDocumentVerificationAttemptRequest() + request.document = 'base64-encoded-document-image-data' + return request + + +def assert_address_document_verification_response(response): + assert_response(response, 'http_metadata', 'id', 'applicant_id', 'status') diff --git a/tests/identities/faceauthentication/faceauthentication_client_test.py b/tests/identities/faceauthentication/faceauthentication_client_test.py index 3c34716f..1b19be9b 100644 --- a/tests/identities/faceauthentication/faceauthentication_client_test.py +++ b/tests/identities/faceauthentication/faceauthentication_client_test.py @@ -1,6 +1,7 @@ import pytest from tests._assertions import assert_api_call +from checkout_sdk.identities.entities import AttemptsQueryFilter from checkout_sdk.identities.entities import AttemptAssetsQueryFilter from checkout_sdk.identities.faceauthentication.faceauthentication import ( FaceAuthenticationRequest, FaceAuthenticationAttemptRequest @@ -65,3 +66,14 @@ def test_should_get_face_authentication_attempt_assets(self, mocker, client: Fac assert client.get_face_authentication_attempt_assets(_FAV_ID, _ATTEMPT_ID, query) == 'response' assert_api_call(mock, f'face-authentications/{_FAV_ID}/attempts/{_ATTEMPT_ID}/assets') + + def test_should_get_face_authentication_attempts_with_pagination( + self, mocker, client: FaceAuthenticationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + query = AttemptsQueryFilter() + query.skip = 5 + query.limit = 25 + + assert client.get_face_authentication_attempts('fav_12345', query) == 'response' + assert_api_call(mock, 'face-authentications/fav_12345/attempts') + assert mock.call_args.args[2] is query diff --git a/tests/identities/faceauthentication/faceauthentication_integration_test.py b/tests/identities/faceauthentication/faceauthentication_integration_test.py index cd0e94ed..3b2037ea 100644 --- a/tests/identities/faceauthentication/faceauthentication_integration_test.py +++ b/tests/identities/faceauthentication/faceauthentication_integration_test.py @@ -4,6 +4,7 @@ from checkout_sdk.identities.faceauthentication.faceauthentication import ( FaceAuthenticationRequest, FaceAuthenticationAttemptRequest, ClientInformation ) +from checkout_sdk.identities.entities import AttemptsQueryFilter, PhoneNumber from tests.checkout_test_utils import assert_response, new_uuid @@ -95,6 +96,22 @@ def test_should_perform_face_authentication_workflow(default_api): assert_response(anonymized, 'http_metadata', 'id') +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_face_authentication_attempts_with_pagination(default_api): + created = default_api.face_authentication.create_face_authentication(face_authentication_request()) + default_api.face_authentication.create_face_authentication_attempt( + created.id, face_authentication_attempt_request()) + + query = AttemptsQueryFilter() + query.skip = 0 + query.limit = 1 + + attempts = default_api.face_authentication.get_face_authentication_attempts(created.id, query) + assert_response(attempts, 'http_metadata', 'total_count', 'skip', 'limit', 'data') + assert attempts.limit == 1 + assert len(attempts.data) <= 1 + + # common methods def face_authentication_request() -> FaceAuthenticationRequest: @@ -109,8 +126,13 @@ def face_authentication_attempt_request() -> FaceAuthenticationAttemptRequest: client_information.pre_selected_residence_country = 'US' client_information.pre_selected_language = 'en-US' + phone_number = PhoneNumber() + phone_number.country_code = '+1' + phone_number.number = '5555550102' + request = FaceAuthenticationAttemptRequest() request.redirect_url = 'https://example.com/redirect' + request.phone_number = phone_number request.client_information = client_information return request diff --git a/tests/identities/iddocumentverification/iddocumentverification_client_test.py b/tests/identities/iddocumentverification/iddocumentverification_client_test.py index 7d0f64d7..9e599630 100644 --- a/tests/identities/iddocumentverification/iddocumentverification_client_test.py +++ b/tests/identities/iddocumentverification/iddocumentverification_client_test.py @@ -1,6 +1,7 @@ import pytest from tests._assertions import assert_api_call +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter from checkout_sdk.identities.iddocumentverification.iddocumentverification import ( IdDocumentVerificationRequest, IdDocumentVerificationAttemptRequest ) @@ -57,3 +58,34 @@ def test_should_get_id_document_verification_report(self, mocker, client: IdDocu assert client.get_id_document_verification_report('iddoc_12345') == 'response' assert_api_call(mock, 'id-document-verifications/iddoc_12345/pdf-report') + + def test_should_get_id_document_verification_attempts_with_pagination( + self, mocker, client: IdDocumentVerificationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + query = AttemptsQueryFilter() + query.skip = 5 + query.limit = 25 + + assert client.get_id_document_verification_attempts('iddv_12345', query) == 'response' + assert_api_call(mock, 'id-document-verifications/iddv_12345/attempts') + assert mock.call_args.args[2] is query + + def test_should_get_id_document_verification_attempt_assets( + self, mocker, client: IdDocumentVerificationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + query = AttemptAssetsQueryFilter() + query.limit = 10 + + assert client.get_id_document_verification_attempt_assets( + 'iddv_12345', 'datp_67890', query) == 'response' + assert_api_call(mock, 'id-document-verifications/iddv_12345/attempts/datp_67890/assets') + assert mock.call_args.args[2] is query + + def test_should_get_id_document_verification_attempt_assets_without_a_filter( + self, mocker, client: IdDocumentVerificationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_id_document_verification_attempt_assets( + 'iddv_12345', 'datp_67890') == 'response' + assert_api_call(mock, 'id-document-verifications/iddv_12345/attempts/datp_67890/assets') + assert mock.call_args.args[2] is None diff --git a/tests/identities/iddocumentverification/iddocumentverification_integration_test.py b/tests/identities/iddocumentverification/iddocumentverification_integration_test.py index 309b3724..02f40e5a 100644 --- a/tests/identities/iddocumentverification/iddocumentverification_integration_test.py +++ b/tests/identities/iddocumentverification/iddocumentverification_integration_test.py @@ -3,6 +3,7 @@ from checkout_sdk.identities.iddocumentverification.iddocumentverification import ( IdDocumentVerificationRequest, IdDocumentVerificationAttemptRequest, DeclaredData ) +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter from tests.checkout_test_utils import assert_response, new_uuid @@ -69,7 +70,7 @@ def test_should_get_id_document_verification_report(default_api): created = default_api.id_document_verification.create_id_document_verification( id_document_verification_request()) report = default_api.id_document_verification.get_id_document_verification_report(created.id) - assert_response(report, 'http_metadata', 'signed_url') + assert_response(report, 'http_metadata', 'pdf_report') @pytest.mark.skip(reason='Requires valid test environment setup') @@ -96,12 +97,47 @@ def test_should_perform_id_document_verification_workflow(default_api): assert retrieved_attempt.id == created_attempt.id report = default_api.id_document_verification.get_id_document_verification_report(created.id) - assert_response(report, 'http_metadata', 'signed_url') + assert_response(report, 'http_metadata', 'pdf_report') anonymized = default_api.id_document_verification.anonymize_id_document_verification(created.id) assert_response(anonymized, 'http_metadata', 'id') +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_id_document_verification_attempts_with_pagination(default_api): + created = default_api.id_document_verification.create_id_document_verification( + id_document_verification_request()) + default_api.id_document_verification.create_id_document_verification_attempt( + created.id, id_document_verification_attempt_request()) + + query = AttemptsQueryFilter() + query.skip = 0 + query.limit = 1 + + attempts = default_api.id_document_verification.get_id_document_verification_attempts(created.id, query) + assert_response(attempts, 'http_metadata', 'total_count', 'skip', 'limit', 'data') + assert attempts.limit == 1 + assert len(attempts.data) <= 1 + + +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_id_document_verification_attempt_assets(default_api): + created = default_api.id_document_verification.create_id_document_verification( + id_document_verification_request()) + created_attempt = default_api.id_document_verification.create_id_document_verification_attempt( + created.id, id_document_verification_attempt_request()) + + query = AttemptAssetsQueryFilter() + query.limit = 10 + + assets = default_api.id_document_verification.get_id_document_verification_attempt_assets( + created.id, created_attempt.id, query) + assert_response(assets, 'http_metadata', 'total_count', 'skip', 'limit', 'data') + for asset in assets.data: + assert asset.type in ('document_front_image', 'document_back_image') + assert asset._links.asset_url.href is not None + + # common methods def id_document_verification_request() -> IdDocumentVerificationRequest: diff --git a/tests/identities/identities_response_shape_test.py b/tests/identities/identities_response_shape_test.py new file mode 100644 index 00000000..44c7c5d7 --- /dev/null +++ b/tests/identities/identities_response_shape_test.py @@ -0,0 +1,200 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from checkout_sdk.api_client import ApiClient +from checkout_sdk.identities.addressdocumentverification.addressdocumentverification_client import \ + AddressDocumentVerificationClient +from checkout_sdk.identities.iddocumentverification.iddocumentverification_client import \ + IdDocumentVerificationClient +from checkout_sdk.identities.identityverification.identityverification_client import \ + IdentityVerificationClient + + +# The four identities client tests mock ApiClient and return the bare string 'response', so none of +# them exercises a response shape. These feed the swagger examples verbatim through the real +# ApiClient and ResponseWrapper, which is what actually maps the payload for a caller. +# +# The examples below are copied verbatim from shared/swagger-latest.json components.examples so a +# key renamed in the spec fails the test rather than being carried forward by a hand-written +# fixture. asset_url in particular is the only link the asset schemas declare, and it is required. +# +# Every KEY below is verbatim. The href VALUES are abbreviated to keep lines under the line +# length limit: the assertions only read the filename fragment, and the keys are what the tests +# exist to protect. + +ADV_ATTEMPT_ASSETS = """ +{ + "total_count": 1, + "skip": 0, + "limit": 10, + "data": [ + { + "type": "document", + "_links": { + "asset_url": { + "href": "https://storage-b.env.ubble.ai/ubble-ai/NDY/a54/bb6/address_document.png?X-Amz-Expires=3600" + } + } + } + ], + "_links": { + "self": {"href": "https://idv.checkout.com/address-document-verifications/adv_1/attempts/adva_1/assets"}, + "next": {"href": "https://idv.checkout.com/address-document-verifications/adv_1/attempts/adva_1/assets?..."}, + "previous": {"href": "https://idv.checkout.com/address-document-verifications/adv_1/attempts/adva_1/assets?..."} + } +} +""" + +IDDV_ATTEMPT_ASSETS = """ +{ + "total_count": 2, + "skip": 0, + "limit": 10, + "data": [ + { + "type": "document_front_image", + "_links": { + "asset_url": { + "href": "https://storage-b.env.ubble.ai/ubble-ai/NDY/a54/bb6/document_front.png?X-Amz-Expires=3600" + } + } + }, + { + "type": "document_back_image", + "_links": { + "asset_url": { + "href": "https://storage-b.env.ubble.ai/ubble-ai/NDY/a54/bb6/document_back.png?X-Amz-Expires=3600" + } + } + } + ], + "_links": { + "self": {"href": "https://idv.checkout.com/id-document-verifications/iddv_1/attempts/datp_1/assets"} + } +} +""" + +EMPTY_ASSETS_PAGE = """ +{ + "total_count": 0, + "skip": 0, + "limit": 10, + "data": [], + "_links": { + "self": {"href": "https://idv.checkout.com/address-document-verifications/adv_1/attempts/adva_1/assets"} + } +} +""" + +ADV_PDF_REPORT = '{"pdf_report": "https://www.example.com/reports/adv_tkoi5db4hryu5cei5vwoabr7we.pdf"}' +IDV_PDF_REPORT = '{"pdf_report": "https://www.example.com/reports/idv_tkoi5db4hryu5cei5vwoabr7we.pdf"}' + + +def _client(cls, mock_sdk_configuration, body): + api_client = ApiClient(configuration=mock_sdk_configuration, + base_uri=mock_sdk_configuration.environment.base_uri) + http_client = MagicMock() + response = MagicMock() + response.status_code = 200 + response.text = body + response.headers = {'Content-Type': 'application/json'} + response.json.return_value = json.loads(body) + response.raise_for_status.return_value = None + http_client.request.return_value = response + api_client._http_client = http_client + + authorization = MagicMock() + authorization.get_authorization_header.return_value = 'Bearer test' + + client = cls(api_client=api_client, configuration=mock_sdk_configuration) + client._sdk_authorization = lambda *args, **kwargs: authorization + return client + + +class TestAttemptAssetsResponseShape: + + def test_address_document_verification_assets_from_the_swagger_example(self, mock_sdk_configuration): + client = _client(AddressDocumentVerificationClient, mock_sdk_configuration, ADV_ATTEMPT_ASSETS) + + assets = client.get_address_document_verification_attempt_assets('adv_1', 'adva_1') + + assert assets.total_count == 1 + assert assets.skip == 0 + assert assets.limit == 10 + assert len(assets.data) == 1 + assert assets.data[0].type == 'document' + assert 'address_document.png' in assets.data[0]._links.asset_url.href + assert assets._links.self.href.endswith('/assets') + assert assets._links.next is not None + assert assets._links.previous is not None + + def test_id_document_verification_assets_from_the_swagger_example(self, mock_sdk_configuration): + client = _client(IdDocumentVerificationClient, mock_sdk_configuration, IDDV_ATTEMPT_ASSETS) + + assets = client.get_id_document_verification_attempt_assets('iddv_1', 'datp_1') + + assert assets.total_count == 2 + assert len(assets.data) == 2 + assert assets.data[0].type == 'document_front_image' + assert assets.data[1].type == 'document_back_image' + assert 'document_front.png' in assets.data[0]._links.asset_url.href + assert 'document_back.png' in assets.data[1]._links.asset_url.href + + @pytest.mark.parametrize('body', [ADV_ATTEMPT_ASSETS, IDDV_ATTEMPT_ASSETS]) + def test_the_asset_link_is_asset_url_and_not_download(self, mock_sdk_configuration, body): + """asset_url is the only link the AdvAttemptAsset and IddvAttemptAsset schemas declare, + and it is required. A hand-written fixture guessing 'download' would pass a test that + asserted the same guess, so this asserts against the spec's own example.""" + client = _client(AddressDocumentVerificationClient, mock_sdk_configuration, body) + + assets = client.get_address_document_verification_attempt_assets('adv_1', 'adva_1') + + for asset in assets.data: + assert hasattr(asset._links, 'asset_url') + assert not hasattr(asset._links, 'download') + + def test_an_empty_assets_page_is_a_valid_response(self, mock_sdk_configuration): + """data declares minItems 0, so an attempt with no assets yet is a legal page.""" + client = _client(AddressDocumentVerificationClient, mock_sdk_configuration, EMPTY_ASSETS_PAGE) + + assets = client.get_address_document_verification_attempt_assets('adv_1', 'adva_1') + + assert assets.total_count == 0 + assert assets.data == [] + assert assets._links.self.href.endswith('/assets') + + +class TestPdfReportResponseShape: + + def test_address_document_verification_report_carries_pdf_report(self, mock_sdk_configuration): + client = _client(AddressDocumentVerificationClient, mock_sdk_configuration, ADV_PDF_REPORT) + + report = client.get_address_document_verification_report('adv_1') + + assert report.pdf_report.endswith('.pdf') + + def test_id_document_verification_report_carries_pdf_report(self, mock_sdk_configuration): + client = _client(IdDocumentVerificationClient, mock_sdk_configuration, IDV_PDF_REPORT) + + report = client.get_id_document_verification_report('iddv_1') + + assert report.pdf_report.endswith('.pdf') + + def test_identity_verification_report_carries_pdf_report(self, mock_sdk_configuration): + client = _client(IdentityVerificationClient, mock_sdk_configuration, IDV_PDF_REPORT) + + report = client.get_identity_verification_report('idv_1') + + assert report.pdf_report.endswith('.pdf') + + def test_the_report_no_longer_carries_signed_url(self, mock_sdk_configuration): + """IdvPdf declares pdf_report as its only property. signed_url was the pre 2026-09-02 + name and five integration assertions still named it.""" + client = _client(IdentityVerificationClient, mock_sdk_configuration, IDV_PDF_REPORT) + + report = client.get_identity_verification_report('idv_1') + + assert hasattr(report, 'pdf_report') + assert not hasattr(report, 'signed_url') diff --git a/tests/identities/identities_serialization_test.py b/tests/identities/identities_serialization_test.py new file mode 100644 index 00000000..d413786c --- /dev/null +++ b/tests/identities/identities_serialization_test.py @@ -0,0 +1,299 @@ +import json + +import pytest + +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.identities.entities import ( + AttemptAssetsQueryFilter, AttemptsQueryFilter, IdvAddress, IdvDocumentType, PhoneNumber, +) +from checkout_sdk.identities.addressdocumentverification.addressdocumentverification import ( + AddressDocumentVerificationRequest, DeclaredData as AdvDeclaredData, +) +from checkout_sdk.identities.faceauthentication.faceauthentication import ( + ClientInformation as FavClientInformation, FaceAuthenticationAttemptRequest, +) +from checkout_sdk.identities.iddocumentverification.iddocumentverification import ( + DeclaredData as IddvDeclaredData, IdDocumentVerificationRequest, +) +from checkout_sdk.identities.identityverification.identityverification import ( + ClientInformation as IdvClientInformation, DeclaredData as IdvDeclaredData, + IdentityVerificationAndAttemptRequest, IdentityVerificationAttemptRequest, + IdentityVerificationRequest, +) + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +def _phone_number(): + phone_number = PhoneNumber() + phone_number.country_code = '+33' + phone_number.number = '5555550102' + return phone_number + + +def _idv_address(): + address = IdvAddress() + address.address_line1 = '123 Main Street' + address.city = 'London' + address.zip = 'SW1A 1AA' + address.country = 'GB' + return address + + +def _idv_declared_data(): + declared_data = IdvDeclaredData() + declared_data.name = 'Hannah Bret' + declared_data.birth_date = '1994-10-15' + declared_data.email = 'hannah.bret@example.com' + declared_data.phone_number = _phone_number() + declared_data.address = _idv_address() + return declared_data + + +def _idv_client_information(): + client_information = IdvClientInformation() + client_information.pre_selected_residence_country = 'FR' + client_information.pre_selected_language = 'en-US' + client_information.pre_selected_document_issuing_country = 'GB' + client_information.pre_selected_document_type = IdvDocumentType.TRAVEL_DOCUMENT + return client_information + + +class TestIdentitiesSerialization: + + def test_phone_number_serializes_both_properties(self): + assert _serialize(_phone_number()) == { + 'country_code': '+33', + 'number': '5555550102', + } + + def test_idv_address_serializes_every_property(self): + address = _idv_address() + address.address_line2 = 'Apt 4B' + address.state = 'Greater London' + + assert _serialize(address) == { + 'address_line1': '123 Main Street', + 'address_line2': 'Apt 4B', + 'city': 'London', + 'state': 'Greater London', + 'zip': 'SW1A 1AA', + 'country': 'GB', + } + + def test_identity_declared_data_serializes_every_property(self): + result = _serialize(_idv_declared_data()) + + assert result['name'] == 'Hannah Bret' + assert result['birth_date'] == '1994-10-15' + assert result['email'] == 'hannah.bret@example.com' + assert result['phone_number'] == {'country_code': '+33', 'number': '5555550102'} + assert result['address']['address_line1'] == '123 Main Street' + assert result['address']['country'] == 'GB' + assert len(result) == 5 + + def test_identity_declared_data_round_trip(self): + payload = json.dumps(_idv_declared_data(), cls=JsonSerializer) + + assert json.loads(payload) == _serialize(_idv_declared_data()) + + def test_identity_declared_data_omits_unset_optional_fields(self): + declared_data = IdvDeclaredData() + declared_data.name = 'Hannah Bret' + + assert _serialize(declared_data) == {'name': 'Hannah Bret'} + + def test_identity_declared_data_reads_an_explicit_null_email(self): + """email is nullable in the spec, so a response may carry an explicit null.""" + payload = json.loads('{"name":"Hannah Bret","birth_date":"1994-10-15","email":null}') + + assert 'email' in payload + assert payload['email'] is None + + def test_address_document_declared_data_carries_only_the_shared_shape(self): + """The ADV and IDDV requests take IdvDeclaredData, which has no phone_number, email or + address. Those three belong to IdvIdentityDeclaredData and must not leak here.""" + assert list(AdvDeclaredData.__annotations__) == ['name', 'birth_date'] + assert list(IddvDeclaredData.__annotations__) == ['name', 'birth_date'] + + def test_address_document_declared_data_serializes_birth_date(self): + declared_data = AdvDeclaredData() + declared_data.name = 'Hannah Bret' + declared_data.birth_date = '1994-10-15' + + assert _serialize(declared_data) == { + 'name': 'Hannah Bret', + 'birth_date': '1994-10-15', + } + + def test_face_authentication_client_information_keeps_the_two_field_shape(self): + """FavClientInformation declares neither document field, so sending them would be a + request the API rejects.""" + assert list(FavClientInformation.__annotations__) == [ + 'pre_selected_residence_country', 'pre_selected_language', + ] + + def test_identity_verification_client_information_adds_the_two_idv_only_fields(self): + result = _serialize(_idv_client_information()) + + assert result == { + 'pre_selected_residence_country': 'FR', + 'pre_selected_language': 'en-US', + 'pre_selected_document_issuing_country': 'GB', + 'pre_selected_document_type': 'Travel Document', + } + + def test_idv_document_type_matches_the_swagger_enum_exactly(self): + expected = { + 'DRIVING_LICENCE': 'Driving licence', + 'ID': 'ID', + 'OTHER': 'Other', + 'PASSPORT': 'Passport', + 'RESIDENCE_PERMIT': 'Residence Permit', + 'TRAVEL_DOCUMENT': 'Travel Document', + 'VISA': 'Visa', + } + actual = {member.name: member.value for member in IdvDocumentType} + assert actual == expected + + def test_idv_document_type_is_distinct_from_the_accounts_document_type(self): + """The accounts endpoints use checkout_sdk.common.enums.DocumentType, which shares no + values with this one and even spells the licence differently (driving_license against + Driving licence). Modelling them as one type would send values the API rejects.""" + from checkout_sdk.common.enums import DocumentType as AccountsDocumentType + + idv = {m.value for m in IdvDocumentType} + accounts = {m.value for m in AccountsDocumentType} + assert idv & accounts == set() + + @pytest.mark.parametrize('document_type', list(IdvDocumentType)) + def test_every_idv_document_type_serializes_to_its_bare_swagger_value(self, document_type): + client_information = IdvClientInformation() + client_information.pre_selected_document_type = document_type + + assert _serialize(client_information) == { + 'pre_selected_document_type': document_type.value, + } + + def test_the_document_type_is_a_typed_enum_not_a_bare_string(self): + assert IdvClientInformation.__annotations__['pre_selected_document_type'] is IdvDocumentType + + def test_identity_verification_attempt_request_serializes_phone_number(self): + request = IdentityVerificationAttemptRequest() + request.redirect_url = 'https://example.com/success' + request.phone_number = _phone_number() + request.client_information = _idv_client_information() + + result = _serialize(request) + + assert result['redirect_url'] == 'https://example.com/success' + assert result['phone_number'] == {'country_code': '+33', 'number': '5555550102'} + assert result['client_information']['pre_selected_document_type'] == 'Travel Document' + + def test_face_authentication_attempt_request_serializes_phone_number(self): + client_information = FavClientInformation() + client_information.pre_selected_residence_country = 'FR' + + request = FaceAuthenticationAttemptRequest() + request.redirect_url = 'https://example.com/success' + request.phone_number = _phone_number() + request.client_information = client_information + + result = _serialize(request) + + assert result['phone_number']['country_code'] == '+33' + assert result['client_information'] == {'pre_selected_residence_country': 'FR'} + assert 'pre_selected_document_type' not in result['client_information'] + + def test_identity_verification_attempt_request_from_swagger_example(self): + payload = json.loads( + '{"redirect_url":"https://example.com/success",' + '"phone_number":{"country_code":"+33","number":"5555550102"},' + '"client_information":{"pre_selected_residence_country":"FR",' + '"pre_selected_document_issuing_country":"GB",' + '"pre_selected_document_type":"Passport",' + '"pre_selected_language":"en-US"}}' + ) + + assert payload['phone_number']['country_code'] == '+33' + assert payload['client_information']['pre_selected_document_type'] == 'Passport' + + def test_identity_verification_request_carries_the_identity_declared_data(self): + request = IdentityVerificationRequest() + request.applicant_id = 'aplt_tkoi5db4hryu5cei5vwoabr7we' + request.user_journey_id = 'usj_tkoi5db4hryu5cei5vwoabr7we' + request.declared_data = _idv_declared_data() + + result = _serialize(request) + + assert result['applicant_id'] == 'aplt_tkoi5db4hryu5cei5vwoabr7we' + assert result['declared_data']['email'] == 'hannah.bret@example.com' + assert result['declared_data']['phone_number']['country_code'] == '+33' + assert result['declared_data']['address']['country'] == 'GB' + + def test_identity_verification_and_attempt_request_carries_the_identity_declared_data(self): + request = IdentityVerificationAndAttemptRequest() + request.applicant_id = 'aplt_tkoi5db4hryu5cei5vwoabr7we' + request.redirect_url = 'https://example.com/success' + request.declared_data = _idv_declared_data() + + result = _serialize(request) + + assert result['redirect_url'] == 'https://example.com/success' + assert result['declared_data']['birth_date'] == '1994-10-15' + assert result['declared_data']['address']['city'] == 'London' + + def test_address_document_verification_request_serializes_declared_data(self): + declared_data = AdvDeclaredData() + declared_data.name = 'Hannah Bret' + + request = AddressDocumentVerificationRequest() + request.applicant_id = 'aplt_tkoi5db4hryu5cei5vwoabr7we' + request.user_journey_id = 'usj_tkoi5db4hryu5cei5vwoabr7we' + request.declared_data = declared_data + + assert _serialize(request)['declared_data'] == {'name': 'Hannah Bret'} + + def test_id_document_verification_request_serializes_declared_data(self): + declared_data = IddvDeclaredData() + declared_data.name = 'Hannah Bret' + declared_data.birth_date = '1994-10-15' + + request = IdDocumentVerificationRequest() + request.applicant_id = 'aplt_tkoi5db4hryu5cei5vwoabr7we' + request.declared_data = declared_data + + assert _serialize(request)['declared_data']['birth_date'] == '1994-10-15' + + def test_attempts_query_filter_serializes_skip_and_limit(self): + query = AttemptsQueryFilter() + query.skip = 5 + query.limit = 25 + + assert _serialize(query) == {'skip': 5, 'limit': 25} + + def test_attempts_query_filter_keeps_an_explicit_zero_skip(self): + """skip=0 is a meaningful value, not an absent one.""" + query = AttemptsQueryFilter() + query.skip = 0 + query.limit = 10 + + assert _serialize(query) == {'skip': 0, 'limit': 10} + + def test_attempts_query_filter_serializes_limit_only(self): + query = AttemptsQueryFilter() + query.limit = 25 + + assert _serialize(query) == {'limit': 25} + + def test_empty_attempts_query_filter_serializes_to_an_empty_object(self): + assert _serialize(AttemptsQueryFilter()) == {} + + def test_attempt_assets_query_filter_serializes_skip_and_limit(self): + query = AttemptAssetsQueryFilter() + query.skip = 2 + query.limit = 50 + + assert _serialize(query) == {'skip': 2, 'limit': 50} diff --git a/tests/identities/identities_wire_test.py b/tests/identities/identities_wire_test.py new file mode 100644 index 00000000..4924d96d --- /dev/null +++ b/tests/identities/identities_wire_test.py @@ -0,0 +1,154 @@ +from unittest.mock import MagicMock + +import pytest + +from checkout_sdk.api_client import ApiClient +from checkout_sdk.identities.addressdocumentverification.addressdocumentverification_client import \ + AddressDocumentVerificationClient +from checkout_sdk.identities.entities import AttemptAssetsQueryFilter, AttemptsQueryFilter +from checkout_sdk.identities.faceauthentication.faceauthentication_client import FaceAuthenticationClient +from checkout_sdk.identities.iddocumentverification.iddocumentverification_client import \ + IdDocumentVerificationClient +from checkout_sdk.identities.identityverification.identityverification_client import \ + IdentityVerificationClient + + +# Exercises the REAL ApiClient params path (invoke -> _prepare_request_payload -> requests), unlike +# the four client tests which mock ApiClient and can only assert that the filter object was handed +# over. Nothing else in the SDK covers params reaching the wire. Follows the pattern of +# tests/accounts/accounts_schema_version_header_test.py. +@pytest.fixture +def api_and_http(mock_sdk_configuration): + api_client = ApiClient(configuration=mock_sdk_configuration, + base_uri=mock_sdk_configuration.environment.base_uri) + http_client = MagicMock() + response = MagicMock() + response.text = '' + response.raise_for_status.return_value = None + http_client.request.return_value = response + api_client._http_client = http_client + + authorization = MagicMock() + authorization.get_authorization_header.return_value = 'Bearer test' + return api_client, http_client, authorization + + +def _client(cls, api_client, configuration, authorization): + client = cls(api_client=api_client, configuration=configuration) + client._sdk_authorization = lambda *args, **kwargs: authorization + return client + + +def _sent_params(http_client): + return http_client.request.call_args.kwargs['params'] + + +def _sent_url(http_client): + return http_client.request.call_args.kwargs['url'] + + +def _pagination(): + query = AttemptsQueryFilter() + query.skip = 5 + query.limit = 25 + return query + + +class TestAttemptsPaginationReachesTheWire: + + def test_address_document_verification_attempts_send_skip_and_limit( + self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(AddressDocumentVerificationClient, api_client, mock_sdk_configuration, + authorization) + + client.get_address_document_verification_attempts('adv_123', _pagination()) + + assert _sent_params(http_client) == {'skip': 5, 'limit': 25} + assert _sent_url(http_client).endswith('address-document-verifications/adv_123/attempts') + + def test_id_document_verification_attempts_send_skip_and_limit( + self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(IdDocumentVerificationClient, api_client, mock_sdk_configuration, authorization) + + client.get_id_document_verification_attempts('iddv_123', _pagination()) + + assert _sent_params(http_client) == {'skip': 5, 'limit': 25} + assert _sent_url(http_client).endswith('id-document-verifications/iddv_123/attempts') + + def test_identity_verification_attempts_send_skip_and_limit( + self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(IdentityVerificationClient, api_client, mock_sdk_configuration, authorization) + + client.get_identity_verification_attempts('idv_123', _pagination()) + + assert _sent_params(http_client) == {'skip': 5, 'limit': 25} + assert _sent_url(http_client).endswith('identity-verifications/idv_123/attempts') + + def test_face_authentication_attempts_send_skip_and_limit( + self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(FaceAuthenticationClient, api_client, mock_sdk_configuration, authorization) + + client.get_face_authentication_attempts('fav_123', _pagination()) + + assert _sent_params(http_client) == {'skip': 5, 'limit': 25} + assert _sent_url(http_client).endswith('face-authentications/fav_123/attempts') + + def test_attempt_assets_send_skip_and_limit(self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(AddressDocumentVerificationClient, api_client, mock_sdk_configuration, + authorization) + + query = AttemptAssetsQueryFilter() + query.skip = 2 + query.limit = 50 + + client.get_address_document_verification_attempt_assets('adv_123', 'adva_123', query) + + assert _sent_params(http_client) == {'skip': 2, 'limit': 50} + assert _sent_url(http_client).endswith( + 'address-document-verifications/adv_123/attempts/adva_123/assets') + + def test_an_explicit_zero_skip_reaches_the_wire(self, api_and_http, mock_sdk_configuration): + """skip=0 is a meaningful value. The PHP SDK drops it because its filter skips empty + values; python must not.""" + api_client, http_client, authorization = api_and_http + client = _client(IdentityVerificationClient, api_client, mock_sdk_configuration, authorization) + + query = AttemptsQueryFilter() + query.skip = 0 + query.limit = 10 + + client.get_identity_verification_attempts('idv_123', query) + + assert _sent_params(http_client) == {'skip': 0, 'limit': 10} + + def test_limit_only_sends_just_the_limit(self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(IdentityVerificationClient, api_client, mock_sdk_configuration, authorization) + + query = AttemptsQueryFilter() + query.limit = 25 + + client.get_identity_verification_attempts('idv_123', query) + + assert _sent_params(http_client) == {'limit': 25} + + def test_an_omitted_filter_sends_no_params(self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(IdentityVerificationClient, api_client, mock_sdk_configuration, authorization) + + client.get_identity_verification_attempts('idv_123') + + assert _sent_params(http_client) is None + + def test_an_empty_filter_sends_an_empty_params_object(self, api_and_http, mock_sdk_configuration): + api_client, http_client, authorization = api_and_http + client = _client(IdentityVerificationClient, api_client, mock_sdk_configuration, authorization) + + client.get_identity_verification_attempts('idv_123', AttemptsQueryFilter()) + + assert _sent_params(http_client) == {} diff --git a/tests/identities/identityverification/identityverification_client_test.py b/tests/identities/identityverification/identityverification_client_test.py index dc95a228..160cc88f 100644 --- a/tests/identities/identityverification/identityverification_client_test.py +++ b/tests/identities/identityverification/identityverification_client_test.py @@ -1,6 +1,7 @@ import pytest from tests._assertions import assert_api_call +from checkout_sdk.identities.entities import AttemptsQueryFilter from checkout_sdk.identities.entities import AttemptAssetsQueryFilter from checkout_sdk.identities.identityverification.identityverification import ( IdentityVerificationRequest, IdentityVerificationAndAttemptRequest, IdentityVerificationAttemptRequest @@ -74,3 +75,14 @@ def test_should_get_identity_verification_attempt_assets(self, mocker, client: I assert client.get_identity_verification_attempt_assets('idv_12345', 'attempt_67890', query) == 'response' assert_api_call(mock, 'identity-verifications/idv_12345/attempts/attempt_67890/assets') + + def test_should_get_identity_verification_attempts_with_pagination( + self, mocker, client: IdentityVerificationClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + query = AttemptsQueryFilter() + query.skip = 5 + query.limit = 25 + + assert client.get_identity_verification_attempts('idv_12345', query) == 'response' + assert_api_call(mock, 'identity-verifications/idv_12345/attempts') + assert mock.call_args.args[2] is query diff --git a/tests/identities/identityverification/identityverification_integration_test.py b/tests/identities/identityverification/identityverification_integration_test.py index 047c1eba..8915c690 100644 --- a/tests/identities/identityverification/identityverification_integration_test.py +++ b/tests/identities/identityverification/identityverification_integration_test.py @@ -5,6 +5,8 @@ IdentityVerificationRequest, IdentityVerificationAndAttemptRequest, IdentityVerificationAttemptRequest, DeclaredData, ClientInformation ) +from checkout_sdk.identities.entities import AttemptsQueryFilter, IdvAddress, IdvDocumentType, \ + PhoneNumber from tests.checkout_test_utils import assert_response, new_uuid @@ -92,7 +94,7 @@ def test_should_get_identity_verification_report(default_api): created = default_api.identity_verification.create_identity_verification( identity_verification_request()) report = default_api.identity_verification.get_identity_verification_report(created.id) - assert_response(report, 'http_metadata', 'signed_url') + assert_response(report, 'http_metadata', 'pdf_report') @pytest.mark.skip(reason='Requires valid test environment setup') @@ -119,7 +121,7 @@ def test_should_perform_complete_identity_verification_workflow(default_api): assert retrieved_attempt.id == attempt.id report = default_api.identity_verification.get_identity_verification_report(created_with_attempt.id) - assert_response(report, 'http_metadata', 'signed_url') + assert_response(report, 'http_metadata', 'pdf_report') anonymized = default_api.identity_verification.anonymize_identity_verification(created_with_attempt.id) assert_response(anonymized, 'http_metadata', 'id') @@ -149,17 +151,32 @@ def test_should_perform_separate_create_and_attempt_workflow(default_api): assert retrieved_attempt.id == attempt.id report = default_api.identity_verification.get_identity_verification_report(created.id) - assert_response(report, 'http_metadata', 'signed_url') + assert_response(report, 'http_metadata', 'pdf_report') anonymized = default_api.identity_verification.anonymize_identity_verification(created.id) assert_response(anonymized, 'http_metadata', 'id') +@pytest.mark.skip(reason='Requires valid test environment setup') +def test_should_get_identity_verification_attempts_with_pagination(default_api): + created = default_api.identity_verification.create_identity_verification_and_attempt( + identity_verification_and_attempt_request()) + + query = AttemptsQueryFilter() + query.skip = 0 + query.limit = 1 + + attempts = default_api.identity_verification.get_identity_verification_attempts(created.id, query) + assert_response(attempts, 'http_metadata', 'total_count', 'skip', 'limit', 'data') + assert attempts.limit == 1 + assert attempts.skip == 0 + assert len(attempts.data) <= 1 + + # common methods def identity_verification_and_attempt_request() -> IdentityVerificationAndAttemptRequest: - declared_data = DeclaredData() - declared_data.name = 'John Doe' + declared_data = build_identity_declared_data() request = IdentityVerificationAndAttemptRequest() request.applicant_id = new_uuid() @@ -170,8 +187,7 @@ def identity_verification_and_attempt_request() -> IdentityVerificationAndAttemp def identity_verification_request() -> IdentityVerificationRequest: - declared_data = DeclaredData() - declared_data.name = 'John Doe' + declared_data = build_identity_declared_data() request = IdentityVerificationRequest() request.applicant_id = new_uuid() @@ -184,9 +200,16 @@ def identity_verification_attempt_request() -> IdentityVerificationAttemptReques client_information = ClientInformation() client_information.pre_selected_residence_country = 'US' client_information.pre_selected_language = 'en-US' + client_information.pre_selected_document_issuing_country = 'GB' + client_information.pre_selected_document_type = IdvDocumentType.PASSPORT + + phone_number = PhoneNumber() + phone_number.country_code = '+44' + phone_number.number = '7700900000' request = IdentityVerificationAttemptRequest() request.redirect_url = 'https://example.com/redirect' + request.phone_number = phone_number request.client_information = client_information return request @@ -201,3 +224,23 @@ def assert_identity_verification_response(response): def assert_identity_verification_attempt_response(response): assert_response(response, 'http_metadata', 'id', 'status') + + +def build_identity_declared_data() -> DeclaredData: + address = IdvAddress() + address.address_line1 = '123 Main Street' + address.city = 'London' + address.zip = 'SW1A 1AA' + address.country = 'GB' + + phone_number = PhoneNumber() + phone_number.country_code = '+44' + phone_number.number = '7700900000' + + declared_data = DeclaredData() + declared_data.name = 'John Doe' + declared_data.birth_date = '1994-10-15' + declared_data.email = 'john.doe@example.com' + declared_data.phone_number = phone_number + declared_data.address = address + return declared_data diff --git a/tests/issuing/card_update_headers_wire_test.py b/tests/issuing/card_update_headers_wire_test.py new file mode 100644 index 00000000..346c6f99 --- /dev/null +++ b/tests/issuing/card_update_headers_wire_test.py @@ -0,0 +1,129 @@ +from unittest.mock import MagicMock + +import pytest +from requests import HTTPError + +from checkout_sdk.api_client import ApiClient +from checkout_sdk.exception import CheckoutApiException +from checkout_sdk.issuing.cards import CardUpdateHeaders, UpdateCardRequest +from checkout_sdk.issuing.issuing_client import IssuingClient + + +# Exercises the REAL ApiClient header path (_process_custom_headers -> invoke), unlike +# issuing_client_test which mocks ApiClient and can only assert the headers object was handed over. +# The header names are case sensitive and return-encrypted-cvv is lower case, which the default +# snake_case converter would render as Return-Encrypted-Cvv. +def _build(mock_sdk_configuration, status=200, body='{}'): + api_client = ApiClient(configuration=mock_sdk_configuration, + base_uri=mock_sdk_configuration.environment.base_uri) + http_client = MagicMock() + response = MagicMock() + response.status_code = status + response.text = body + response.headers = {'Content-Type': 'application/json'} + response.json.return_value = __import__('json').loads(body) + if status >= 400: + response.raise_for_status.side_effect = HTTPError(response=response) + else: + response.raise_for_status.return_value = None + http_client.request.return_value = response + api_client._http_client = http_client + + authorization = MagicMock() + authorization.get_authorization_header.return_value = 'Bearer test' + + client = IssuingClient(api_client=api_client, configuration=mock_sdk_configuration) + client._sdk_authorization = lambda *args, **kwargs: authorization + return client, http_client + + +def _sent_headers(http_client): + return http_client.request.call_args.kwargs['headers'] + + +class TestCardUpdateHeadersReachTheWire: + + def test_both_headers_use_their_exact_swagger_names(self, mock_sdk_configuration): + client, http_client = _build(mock_sdk_configuration) + headers = CardUpdateHeaders() + headers.return_encrypted_cvv = 'true' + headers.encryption_key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' + + client.update_card('crd_123', UpdateCardRequest(), headers) + + sent = _sent_headers(http_client) + assert sent['return-encrypted-cvv'] == 'true' + assert sent['Encryption-Key'] == 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' + assert 'Return-Encrypted-Cvv' not in sent + + def test_no_card_headers_are_sent_when_omitted(self, mock_sdk_configuration): + client, http_client = _build(mock_sdk_configuration) + + client.update_card('crd_123', UpdateCardRequest()) + + sent = _sent_headers(http_client) + assert 'return-encrypted-cvv' not in sent + assert 'Encryption-Key' not in sent + + def test_the_key_can_be_sent_without_the_flag(self, mock_sdk_configuration): + client, http_client = _build(mock_sdk_configuration) + headers = CardUpdateHeaders() + headers.encryption_key = 'MIIBIjAN' + + client.update_card('crd_123', UpdateCardRequest(), headers) + + sent = _sent_headers(http_client) + assert sent['Encryption-Key'] == 'MIIBIjAN' + assert 'return-encrypted-cvv' not in sent + + def test_a_python_bool_would_send_the_capitalised_string(self, mock_sdk_configuration): + """Documents why return_encrypted_cvv is declared str. ApiClient stringifies header + values, so a bool reaches the wire as 'True', not the 'true' the spec shows. If a future + change makes ApiClient render bools lowercase, this test should be replaced by one that + asserts the bool path directly.""" + client, http_client = _build(mock_sdk_configuration) + headers = CardUpdateHeaders() + headers.return_encrypted_cvv = True + + client.update_card('crd_123', UpdateCardRequest(), headers) + + assert _sent_headers(http_client)['return-encrypted-cvv'] == 'True' + + def test_a_422_surfaces_the_encryption_key_required_error(self, mock_sdk_configuration): + """The API answers 422 with error code encryption_key_required when return-encrypted-cvv + is true without an Encryption-Key header.""" + client, _ = _build(mock_sdk_configuration, status=422, body='{' + '"request_id":"0HLHPN8802NUF:00000003",' + '"error_type":"request_invalid",' + '"error_codes":["encryption_key_required"]}') + headers = CardUpdateHeaders() + headers.return_encrypted_cvv = 'true' + + with pytest.raises(CheckoutApiException) as exc: + client.update_card('crd_123', UpdateCardRequest(), headers) + + assert exc.value.error_type == 'request_invalid' + assert 'encryption_key_required' in exc.value.error_details + assert exc.value.request_id == '0HLHPN8802NUF:00000003' + + def test_a_successful_update_returns_the_encrypted_cvv(self, mock_sdk_configuration): + client, _ = _build(mock_sdk_configuration, body='{' + '"last_modified_date":"2026-06-01T10:00:00Z",' + '"encrypted_cvv":"oJMoNMEEUiQKYOsQ4Zd"}') + headers = CardUpdateHeaders() + headers.return_encrypted_cvv = 'true' + headers.encryption_key = 'MIIBIjAN' + + response = client.update_card('crd_123', UpdateCardRequest(), headers) + + assert response.encrypted_cvv == 'oJMoNMEEUiQKYOsQ4Zd' + assert response.last_modified_date == '2026-06-01T10:00:00Z' + + def test_a_successful_update_without_the_headers_has_no_encrypted_cvv(self, mock_sdk_configuration): + client, _ = _build(mock_sdk_configuration, + body='{"last_modified_date":"2026-06-01T10:00:00Z"}') + + response = client.update_card('crd_123', UpdateCardRequest()) + + assert response.last_modified_date == '2026-06-01T10:00:00Z' + assert not hasattr(response, 'encrypted_cvv') diff --git a/tests/issuing/cards_issuing_integration_test.py b/tests/issuing/cards_issuing_integration_test.py index fcb72103..c6a006da 100644 --- a/tests/issuing/cards_issuing_integration_test.py +++ b/tests/issuing/cards_issuing_integration_test.py @@ -1,10 +1,11 @@ +import os from datetime import datetime, timedelta import pytest from checkout_sdk.issuing.cards import PasswordEnrollmentRequest, SecurityPair, UpdateThreeDsEnrollmentRequest, \ - CardCredentialsQuery, RevokeRequest, RevokeReason, SuspendRequest, SuspendReason, UpdateCardRequest, CardMetadata, \ - VirtualCardRenewRequest + CardCredentialsQuery, CardUpdateHeaders, RevokeRequest, RevokeReason, SuspendRequest, SuspendReason, \ + UpdateCardRequest, CardMetadata, VirtualCardRenewRequest from tests.checkout_test_utils import assert_response, phone @@ -57,6 +58,30 @@ def test_should_update_card(self, issuing_checkout_api, card): assert_response(response) assert response.http_metadata.status_code == 200 + def test_should_update_card_scheduled_activation_date(self, issuing_checkout_api, card): + request = UpdateCardRequest() + # The earliest value the API accepts is the next round hour in UTC. + request.scheduled_activation_date = ( + datetime.utcnow() + timedelta(hours=2)).strftime('%Y-%m-%dT%H:00Z') + + response = issuing_checkout_api.issuing.update_card(card.id, request) + + assert_response(response) + assert response.http_metadata.status_code == 200 + + def test_should_update_card_returning_the_encrypted_cvv(self, issuing_checkout_api, active_card): + request = UpdateCardRequest() + request.reference = 'UPDATED-REF-123' + + headers = CardUpdateHeaders() + headers.return_encrypted_cvv = 'true' + headers.encryption_key = os.environ.get('CHECKOUT_ISSUING_ENCRYPTION_KEY', '') + + response = issuing_checkout_api.issuing.update_card(active_card.id, request, headers) + + assert_response(response, 'encrypted_cvv') + assert response.http_metadata.status_code == 200 + def test_should_renew_card(self, issuing_checkout_api, card): request = VirtualCardRenewRequest() request.reference = 'RENEW-REF-123' diff --git a/tests/issuing/issuing_client_test.py b/tests/issuing/issuing_client_test.py index 3e98eb97..b87626d8 100644 --- a/tests/issuing/issuing_client_test.py +++ b/tests/issuing/issuing_client_test.py @@ -3,7 +3,8 @@ from tests._assertions import assert_api_call from checkout_sdk.issuing.cardholders import CardholderRequest from checkout_sdk.issuing.cards import PhysicalCardRequest, PasswordEnrollmentRequest, UpdateThreeDsEnrollmentRequest, \ - CardCredentialsQuery, RevokeRequest, SuspendRequest, UpdateCardRequest, VirtualCardRenewRequest + CardCredentialsQuery, CardUpdateHeaders, RevokeRequest, SuspendRequest, UpdateCardRequest, \ + VirtualCardRenewRequest from checkout_sdk.issuing.controls import MccControlRequest, CardControlsQuery, UpdateCardControlRequest, \ CreateControlGroupRequest, ControlGroupQueryTarget, ControlProfileRequest from checkout_sdk.issuing.disputes import CreateDisputeRequest, EscalateDisputeRequest, AmendDisputeRequest, \ @@ -66,6 +67,18 @@ def test_should_update_card(self, mocker, client: IssuingClient): assert client.update_card('card_id', body) == 'response' assert_api_call(mock, 'issuing/cards/card_id', body) + assert mock.call_args.kwargs['headers'] is None + + def test_should_update_card_with_the_encrypted_cvv_headers(self, mocker, client: IssuingClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.patch', return_value='response') + body = UpdateCardRequest() + headers = CardUpdateHeaders() + headers.return_encrypted_cvv = 'true' + headers.encryption_key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' + + assert client.update_card('card_id', body, headers) == 'response' + assert_api_call(mock, 'issuing/cards/card_id', body) + assert mock.call_args.kwargs['headers'] is headers def test_should_enroll_three_ds(self, mocker, client: IssuingClient): mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') diff --git a/tests/issuing/issuing_serialization_test.py b/tests/issuing/issuing_serialization_test.py index b42d4122..37b344ed 100644 --- a/tests/issuing/issuing_serialization_test.py +++ b/tests/issuing/issuing_serialization_test.py @@ -1,7 +1,10 @@ import json +from datetime import date from checkout_sdk.json_serializer import JsonSerializer -from checkout_sdk.issuing.cards import CardType, VirtualCardRequest, UpdateCardRequest +from checkout_sdk.api_client import ApiClient +from checkout_sdk.issuing.cards import CardRequest, CardType, CardUpdateHeaders, UpdateCardRequest, \ + VirtualCardRequest from checkout_sdk.issuing.disputes import ( IssuingDisputeFraudType, IssuingDisputeFraudDetails, CreateDisputeRequest, EscalateDisputeRequest, AmendDisputeRequest, SubmitDisputeRequest, @@ -31,28 +34,120 @@ def test_fraud_type_enum_matches_swagger_strings(self): actual = {member.name: member.value for member in IssuingDisputeFraudType} assert actual == expected - def test_update_card_serializes_activation_and_revocation_date(self): + def test_update_card_serializes_scheduled_activation_and_revocation_date(self): request = UpdateCardRequest() request.reference = 'ref' - request.activation_date = '2026-06-01T10:00Z' + request.scheduled_activation_date = '2026-06-01T10:00Z' request.revocation_date = '2027-03-12' assert _serialize(request) == { 'reference': 'ref', - 'activation_date': '2026-06-01T10:00Z', + 'scheduled_activation_date': '2026-06-01T10:00Z', 'revocation_date': '2027-03-12', } - def test_create_card_serializes_activation_date(self): + def test_create_card_serializes_scheduled_activation_date(self): request = VirtualCardRequest() request.cardholder_id = 'crh_1' - request.activation_date = '2026-06-01T10:00Z' + request.scheduled_activation_date = '2026-06-01T10:00Z' request.revocation_date = '2027-03-12' result = _serialize(request) assert result['type'] == CardType.VIRTUAL.value - assert result['activation_date'] == '2026-06-01T10:00Z' + assert result['scheduled_activation_date'] == '2026-06-01T10:00Z' assert result['revocation_date'] == '2027-03-12' + assert 'activation_date' not in result + + def test_revocation_date_accepts_a_date_object_through_the_serializer(self): + """revocation_date is `format: date`. The SDK convention is to declare it str with a + `# Format: yyyy-MM-dd` comment, and the JsonSerializer date branch added in INT-1699 is + the safety net for a caller who passes a real date instead.""" + request = UpdateCardRequest() + request.revocation_date = date(2027, 3, 12) + + assert _serialize(request) == {'revocation_date': '2027-03-12'} + + def test_scheduled_activation_date_is_not_a_date_only_field(self): + """Unlike revocation_date it has no `format` in the spec: it accepts a date or a round + hour datetime, so it must stay a plain string and carry no yyyy-MM-dd marker.""" + request = UpdateCardRequest() + request.scheduled_activation_date = '2026-06-01T10:00Z' + + assert _serialize(request) == {'scheduled_activation_date': '2026-06-01T10:00Z'} + + def test_update_card_request_declares_no_activation_date(self): + """The spec replaced activation_date with scheduled_activation_date and removed + IssuingActivationDate. Python attributes are dynamic, so a caller still assigning + activation_date would silently serialize a key the API rejects, with no error anywhere. + These guards are the only thing that catches a stale assignment.""" + assert 'scheduled_activation_date' in UpdateCardRequest.__annotations__ + assert 'activation_date' not in UpdateCardRequest.__annotations__ + + def test_card_request_declares_no_activation_date(self): + assert 'scheduled_activation_date' in CardRequest.__annotations__ + assert 'activation_date' not in CardRequest.__annotations__ + + def test_update_card_serializes_every_declared_property(self): + request = UpdateCardRequest() + request.reference = 'X-123456-N11' + request.expiry_month = 6 + request.expiry_year = 2030 + request.scheduled_activation_date = '2026-06-01T10:00Z' + request.revocation_date = '2027-03-12' + + assert _serialize(request) == { + 'reference': 'X-123456-N11', + 'expiry_month': 6, + 'expiry_year': 2030, + 'scheduled_activation_date': '2026-06-01T10:00Z', + 'revocation_date': '2027-03-12', + } + + def test_update_card_request_from_swagger_example(self): + payload = json.loads( + '{"reference":"X-123456-N11","expiry_month":6,"expiry_year":2030,' + '"revocation_date":"2027-03-12","scheduled_activation_date":"2026-06-01T10:00Z"}' + ) + + assert payload['scheduled_activation_date'] == '2026-06-01T10:00Z' + assert payload['revocation_date'] == '2027-03-12' + + def test_card_update_headers_map_to_the_exact_swagger_header_names(self): + assert CardUpdateHeaders().get_header_mappings() == { + 'return_encrypted_cvv': 'return-encrypted-cvv', + 'encryption_key': 'Encryption-Key', + } + + def test_card_update_headers_reach_the_wire_with_the_exact_names(self): + """The header names are case sensitive and return-encrypted-cvv is lower case, which the + default snake_case converter would render as Return-Encrypted-Cvv.""" + headers = CardUpdateHeaders() + headers.return_encrypted_cvv = 'true' + headers.encryption_key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' + + built = ApiClient.__new__(ApiClient)._process_custom_headers(headers) + + assert built['return-encrypted-cvv'] == 'true' + assert built['Encryption-Key'] == 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' + assert 'Return-Encrypted-Cvv' not in built + + def test_card_update_headers_omit_unset_values(self): + assert ApiClient.__new__(ApiClient)._process_custom_headers(CardUpdateHeaders()) == {} + + def test_card_update_headers_send_the_key_without_the_flag(self): + headers = CardUpdateHeaders() + headers.encryption_key = 'MIIBIjAN' + + built = ApiClient.__new__(ApiClient)._process_custom_headers(headers) + + assert built == {'Encryption-Key': 'MIIBIjAN'} + + def test_card_update_headers_are_declared_as_strings_not_bools(self): + """ApiClient stringifies header values, so a Python bool would reach the wire as the + capitalised 'True'/'False' rather than the 'true' the spec shows. Both attributes are + therefore str, matching the three header classes that already exist in this SDK.""" + assert CardUpdateHeaders.__annotations__['return_encrypted_cvv'] is str + assert CardUpdateHeaders.__annotations__['encryption_key'] is str def test_create_dispute_serializes_fraud_details(self): fraud_details = IssuingDisputeFraudDetails()