diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..ef6c702 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +ignore = E501,W503 +per-file-ignores = __init__.py:F401 +max-line-length = 79 +disable-noqa = true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7055ae1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## Purpose + +## Summary + +## Testing + +## Checklist +- [ ] The change was thoroughly tested manually +- [ ] The change was covered with unit tests +- [ ] The change was tested with real API calls (if applicable) +- [ ] Necessary changes were made in the integration tests (if applicable) +- [ ] New functionality is reflected in README diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cff872f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI-WORKFLOW + +on: + push: + branches: + - master + pull_request: + branches: + - master + +permissions: + contents: read + +env: + ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} + API_KEY: ${{ secrets.API_KEY }} + +jobs: + build-and-test-python3: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10.14" + - name: Install the library + run: | + pip install -e . + - name: Run linters + run: | + pip install -U pre-commit + pre-commit run -v --all-files + - name: Run tests + run: | + python -m unittest discover + + run-integration-tests-python3: + runs-on: ubuntu-latest + if: ${{ github.ref == 'refs/heads/master' }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10.14" + - name: Run integration tests + run: | + pip install . + python test_integration_app/main.py diff --git a/.github/workflows/publishing2PyPI.yml b/.github/workflows/publishing2PyPI.yml new file mode 100644 index 0000000..a8ea16b --- /dev/null +++ b/.github/workflows/publishing2PyPI.yml @@ -0,0 +1,38 @@ +name: publishing2PyPI +on: + release: + types: [published] + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + build_and_publish: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - name: Get package version + run: | + VERSION=NOT_SET + VERSION=$(cat ./sift/version.py | grep -E -i '^VERSION.*' | cut -d'=' -f2 | cut -d\" -f2) + [[ $VERSION == "NOT_SET" ]] && echo "Version in version.py NOT_SET" && exit 1 + echo "curr_version=$(echo $VERSION)" >> $GITHUB_ENV + - name: Compare package version and Release tag + run: | + TAG=${GITHUB_REF##*/} + if [[ $TAG != *"$curr_version"* ]]; then + echo "Version $curr_version does not match tag $TAG" + exit 1 + fi + - name: Create distribution files + run: | + python -m pip install build + python -m build + - name: Upload distribution files + env: + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + TWINE_USER: ${{ secrets.USER }} + run: | + python -m pip install --user --upgrade twine + ls dist/ | xargs -I % python -m twine upload --repository pypi dist/% diff --git a/.gitignore b/.gitignore index d2d6f36..ebc2184 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +__pycache__ *.py[cod] # C extensions @@ -33,3 +34,5 @@ nosetests.xml .mr.developer.cfg .project .pydevproject + +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..37254e3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/crate-ci/typos + rev: v1.31.1 + hooks: + - id: typos + args: [ --force-exclude ] + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + - repo: https://github.com/pycqa/flake8 + rev: 7.1.2 + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [ --install-types, --non-interactive ] + additional_dependencies: [ types-requests ] diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 98c2bd6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: python -python: - - "2.7" - - "3.4" -# command to install dependencies -install: - - pip install -e .[test] -# command to run tests -script: - - unit2 diff --git a/CHANGES.md b/CHANGES.md index 19e39fd..31c250b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,56 @@ +6.0.0 2025-05-05 +================ + +- Added support for Python 3.13 +- Dropped support for Python < 3.8 +- Added typing annotations overall the library +- Updated doc strings with actual information +- Fixed an issue when the client could send requests with invalid version in the "User-Agent" header +- Changed the type of the `abuse_types` parameter in the `client.get_decisions()` method + +INCOMPATIBLE CHANGES INTRODUCED IN 6.0.0: + +- Dropped support for Python < 3.8 +- Passing `abuse_types` as a comma-separated string to the `client.get_decisions()` is deprecated. + + Previously, `client.get_decisions()` method allowed to pass `abuse_types` parameter as a + comma-separated string e.g. `abuse_types="legacy,payment_abuse"`. This is deprecated now. + Starting from 6.0.0 callers must pass `abuse_types` parameter to the `client.get_decisions()` + method as a sequence of string literals e.g. `abuse_types=("legacy", "payment_abuse")`. The same + way as it passed to the other client's methods which receive `abuse_types` parameter. + +5.6.1 2024-10-08 +- Updated implementation to use Basic Authentication instead of passing `API_KEY` as a request parameter for the following calls: + - `client.score()` + - `client.get_user_score()` + - `client.rescore_user()` + - `client.unlabel()` + +5.6.0 2024-05-31 +- Added support for a `warnings` value in the `fields` query parameter + +5.5.1 2024-02-22 +- Support for Python 3.12 + +5.5.0 2023-10-03 +- Score percentiles for Score API + +5.4.0 2023-07-26 +- Support for Verification API + +5.3.0 2023-02-03 +- Added support for score_percentiles + +5.2.0 2022-11-07 +- Update PSP Merchant Management API + +5.1.0 2022-06-22 +- Added return_route_info query parameter +- Fixed decimal amount json serialization bug + +5.0.2 2022-01-24 +- Fix usage of urllib for Python 2.7 + 5.0.1 2019-03-07 - Update metadata in setup.py @@ -95,7 +148,7 @@ INCOMPATIBLE CHANGES INTRODUCED IN API V205: 1.1.2.0 (2015-02-04) ==================== -- Added Unlabel functionaly +- Added Unlabel functionality - Minor bug fixes. 1.1.1.0 (2014-09-3) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e583866 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ + +## Setting up the environment + +1. Install [pyenv](https://github.com/pyenv/pyenv?tab=readme-ov-file#installation) +2. Setup virtual environment + +```sh +# install necessary Python version +pyenv install 3.13.2 + +# create a virtual environment +pyenv virtualenv 3.13.2 v3.13 +pyenv activate v3.13 +``` + +3. Upgrade pip + +```sh +pip install -U pip +``` + +4. Install pre-commit + +```sh +pip install -U pre-commit +pre-commit install +``` + +5. Install the library: + +```sh +pip install -e . +``` + +## Testing + +Before submitting a change, make sure the following commands run without +errors from the root folder of the repository: + +```sh +python -m unittest discover +``` + +## Integration testing app + +For testing the app with real calls it is possible to run the integration testing app, +it makes calls to almost all Sift public API endpoints to make sure the library integrates +well. At the moment, the app is run on every merge to master + +#### How to run it locally + +1. Add env variable `API_KEY` with the valid Api Key associated from the account + +```sh +export API_KEY="api_key" +``` + +1. Add env variable `ACCOUNT_ID` with the valid account id + +```sh +export ACCOUNT_ID="account_id" +``` + +3. Run the following under the project root folder + +```sh +# run the app +python test_integration_app/main.py +``` diff --git a/README.md b/README.md index 45bdd4d..5bc94c5 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,22 @@ -# Sift Python Bindings [![Build Status](https://travis-ci.org/SiftScience/sift-python.svg?branch=master)](https://travis-ci.org/SiftScience/sift-python) +# Sift Python Bindings Bindings for Sift's APIs -- including the -[Events](https://sift.com/resources/references/events-api.html), -[Labels](https://sift.com/resources/references/labels-api.html), +[Events](https://developers.sift.com/docs/python/events-api/, +[Labels](https://developers.sift.com/docs/python/labels-api/), and -[Score](https://sift.com/resources/references/score-api.html) +[Score](https://developers.sift.com/docs/python/score-api/) APIs. - ## Installation -Set up a virtual environment with virtualenv (otherwise you will need -to make the pip calls as sudo): - - virtualenv venv - source venv/bin/activate - -Get the latest released package from pip: - -Python 2: - - pip install Sift - -Python 3: - - pip3 install Sift - -or install newest source directly from GitHub: - -Python 2: - - pip install git+https://github.com/SiftScience/sift-python - -Python 3: - - pip3 install git+https://github.com/SiftScience/sift-python - +```sh +# install from PyPi +pip install Sift +``` ## Documentation -Please see [here](https://sift.com/developers/docs/python/events-api/overview) for the +Please see [here](https://developers.sift.com/docs/python/apis-overview) for the most up-to-date documentation. ## Changelog @@ -48,19 +25,13 @@ Please see [the CHANGELOG](https://github.com/SiftScience/sift-python/blob/master/CHANGES.md) for a history of all changes. -Note, that in v2.0.0, the API semantics were changed to raise an -exception in the case of error to be more pythonic. Client code will -need to be updated to catch `sift.client.ApiException` exceptions. - - ## Usage Here's an example: ```python -import json -import sift.client +import sift client = sift.Client(api_key='', account_id='') @@ -85,9 +56,58 @@ properties = { } try: - response = client.track("$transaction", properties) + response = client.track( + "$transaction", + properties, + ) +except sift.client.ApiException: + # request failed + pass +else: + if response.is_ok(): + print("Successfully tracked event") + + +# Track a transaсtion event and receive a score with percentiles in response (sync flow). +# Note: `return_score` or `return_workflow_status` must be set `True`. +properties = { + "$user_id": user_id, + "$user_email": "buyer@gmail.com", + "$seller_user_id": "2371", + "seller_user_email": "seller@gmail.com", + "$transaction_id": "573050", + "$payment_method": { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444" + }, + "$currency_code": "USD", + "$amount": 15230000, +} + +try: + response = client.track( + "$transaction", + properties, + return_score=True, + include_score_percentiles=True, + abuse_types=("promotion_abuse", "content_abuse", "payment_abuse"), + ) +except sift.client.ApiException: + # request failed + pass +else: if response.is_ok(): - print "Successfully tracked event" + score_response = response.body["score_response"] + print(score_response) + + +# In order to include `warnings` field to Events API response via calling +# `track()` method, set it by the `include_warnings` param: +try: + response = client.track("$transaction", properties, include_warnings=True) + # ... except sift.client.ApiException: # request failed pass @@ -95,12 +115,12 @@ except sift.client.ApiException: # Request a score for the user with user_id 23056 try: response = client.score(user_id) - s = json.dumps(response.body) - print s - except sift.client.ApiException: # request failed pass +else: + print(response.body) + try: # Label the user with user_id 23056 as Bad with all optional fields @@ -156,13 +176,55 @@ try: except sift.client.ApiException: # request failed pass -``` +# The send call triggers the generation of a OTP code that is stored by Sift and email/sms the code to the user. +send_properties = { + "$user_id": "billy_jones_301", + "$send_to": "billy_jones_301@gmail.com", + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$site_country": "IN", + "$event": { + "$session_id": "SOME_SESSION_ID", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" + } + } +} -## Testing +try: + response = client.verification_send(send_properties) +except sift.client.ApiException: + # request failed + pass -Before submitting a change, make sure the following commands run without -errors from the root dir of the repository: +# The resend call generates a new OTP and sends it to the original recipient with the same settings. +resend_properties = { + "$user_id": "billy_jones_301", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" +} +try: + response = client.verification_resend(resend_properties) +except sift.client.ApiException: + # request failed + pass - python -m unittest discover - python3 -m unittest discover +# The check call is used for verifying the OTP provided by the end user to Sift. +check_properties = { + "$user_id": "billy_jones_301", + "$code": 123456, + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" +} +try: + response = client.verification_check(check_properties) +except sift.client.ApiException: + # request failed + pass +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ebb193 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "Sift" +version = "6.0.0" +authors = [ + {name = "Sift Science", email = "support@siftscience.com"}, +] +description = "Python bindings for Sift Science's API" +readme = "README.md" +license = {file = "LICENSE"} +classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Typing :: Typed", + "License :: OSI Approved :: MIT License", +] +keywords = ["sift", "sift-python"] +requires-python = ">= 3.8" +dependencies = [ + "requests < 3.0.0", +] + +[project.urls] +Source = "https://github.com/SiftScience/sift-python" +Changelog = "https://github.com/SiftScience/sift-python/blob/master/CHANGES.md" + +[tool.setuptools] +packages = ["sift"] + +[tool.black] +line-length = 79 + +[tool.isort] +profile = "black" +combine_as_imports = true +remove_redundant_aliases = true +line_length = 79 +skip = [ + "build", +] + +[tool.mypy] +follow_imports_for_stubs = false +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unreachable = true +warn_return_any = true +warn_no_return = true +enable_error_code = "possibly-undefined,ignore-without-code" +exclude = [ + "build", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index 192d993..0000000 --- a/setup.py +++ /dev/null @@ -1,53 +0,0 @@ -import imp -import os - -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - - -here = os.path.abspath(os.path.dirname(__file__)) - -try: - README = open(os.path.join(here, 'README.rst')).read() - CHANGES = open(os.path.join(here, 'CHANGES.rst')).read() -except Exception: - README = '' - CHANGES = '' - -# Use imp to avoid sift/__init__.py -version_mod = imp.load_source('__tmp', os.path.join(here, 'sift/version.py')) - -setup( - name='Sift', - description='Python bindings for Sift Science\'s API', - version=version_mod.VERSION, - url='https://siftscience.com', - python_requires=">=2.7", - - author='Sift Science', - author_email='support@siftscience.com', - long_description=README + '\n\n' + CHANGES, - - packages=['sift'], - install_requires=[ - "requests >= 0.14.1", - ], - extras_require={ - 'test': [ - 'mock >= 1.0.1', - 'unittest2 >= 1, < 2', - ], - }, - - classifiers=[ - "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Topic :: Software Development :: Libraries :: Python Modules" - ] -) diff --git a/sift/__init__.py b/sift/__init__.py index c1b28a2..da4b03f 100644 --- a/sift/__init__.py +++ b/sift/__init__.py @@ -1,3 +1,11 @@ -api_key = None -account_id = None +from __future__ import annotations + +import os + from .client import Client +from .version import VERSION + +__version__ = VERSION + +api_key: str | None = os.environ.get("API_KEY") +account_id: str | None = os.environ.get("ACCOUNT_ID") diff --git a/sift/client.py b/sift/client.py index adf690e..b421ce4 100644 --- a/sift/client.py +++ b/sift/client.py @@ -1,193 +1,556 @@ """Python client for Sift Science's API. -See: https://siftscience.com/docs/references/events-api +See: https://developers.sift.com/docs/python/events-api/ """ +from __future__ import annotations + import json -import requests -import requests.auth import sys -if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error - _UNICODE_STRING = str -else: - import urllib.parse - _UNICODE_STRING = str +import typing as t +from collections.abc import Mapping, Sequence + +import requests +from requests.auth import HTTPBasicAuth import sift -import sift.version +from sift.constants import API_URL, DECISION_SOURCES +from sift.exceptions import ApiException +from sift.utils import DecimalEncoder, quote_path as _q +from sift.version import API_VERSION, VERSION -API_URL = 'https://api.siftscience.com' -API3_URL = 'https://api3.siftscience.com' -DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] +def _assert_non_empty_str( + val: object, + name: str, + error_cls: type[Exception] | None = None, +) -> None: + error = f"{name} must be a non-empty string" -def _quote_path(s): - # by default, urllib.quote doesn't escape forward slash; pass the - # optional arg to override this - return urllib.parse.quote(s, '') + if not isinstance(val, str): + error_cls = error_cls or TypeError + raise error_cls(error) + if not val: + error_cls = error_cls or ValueError + raise error_cls(error) -class Client(object): - def __init__( - self, - api_key=None, - api_url=API_URL, - timeout=2.0, - account_id=None, - version=sift.version.API_VERSION, - session=None): - """Initialize the client. +def _assert_non_empty_dict(val: object, name: str) -> None: + error = f"{name} must be a non-empty mapping (dict)" - Args: - api_key: Your Sift Science API key associated with your customer - account. You can obtain this from - https://siftscience.com/console/developer/api-keys . + if not isinstance(val, Mapping): + raise TypeError(error) - api_url: Base URL, including scheme and host, for sending events. - Defaults to 'https://api.siftscience.com'. + if not val: + raise ValueError(error) - timeout: Number of seconds to wait before failing request. Defaults - to 2 seconds. - account_id: The ID of your Sift Science account. You can obtain - this from https://siftscience.com/console/account/profile . +class Response: + HTTP_CODES_WITHOUT_BODY = (204, 304) - version: The version of the Sift Science API to call. Defaults to - the latest version ('205'). + def __init__(self, http_response: requests.Response) -> None: + """ + Raises ApiException on invalid JSON in Response body or non-2XX HTTP + status code. + """ + self.url: str = http_response.url + self.http_status_code: int = http_response.status_code + self.api_status: int | None = None + self.api_error_message: str | None = None + self.body: dict[str, t.Any] | None = None + self.request: dict[str, t.Any] | None = None + + if ( + self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY + ) and http_response.text: + try: + self.body = http_response.json() + + if "status" in self.body: + self.api_status = self.body["status"] + + if "error_message" in self.body: + self.api_error_message = self.body["error_message"] + + if isinstance(self.body.get("request"), str): + self.request = json.loads(self.body["request"]) + except ValueError: + raise ApiException( + f"Failed to parse json response from {self.url}", + url=self.url, + http_status_code=self.http_status_code, + body=self.body, + api_status=self.api_status, + api_error_message=self.api_error_message, + request=self.request, + ) + finally: + if not 200 <= self.http_status_code < 300: + raise ApiException( + f"{self.url} returned non-2XX http status code {self.http_status_code}", + url=self.url, + http_status_code=self.http_status_code, + body=self.body, + api_status=self.api_status, + api_error_message=self.api_error_message, + request=self.request, + ) + + def __str__(self) -> str: + body = ( + f'"body": {json.dumps(self.body)}, ' + if self.body is not None + else "" + ) + + return f'{body}"http_status_code": {self.http_status_code}' + + def is_ok(self) -> bool: + return self.api_status == 0 or self.http_status_code in (200, 204) + + +class Client: + api_key: str + account_id: str + + def __init__( + self, + api_key: str | None = None, + api_url: str = API_URL, + timeout: float | tuple[float, float] = 2, + account_id: str | None = None, + version: str = API_VERSION, + session: requests.Session | None = None, + ) -> None: + """Initialize the client. + + Args: + api_key: + The Sift Science API key associated with your account. You can + obtain it from https://console.sift.com/developer/api-keys + + api_url (optional): + Base URL, including scheme and host, for sending events. + Defaults to 'https://api.sift.com'. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + Defaults to 2 seconds. + + account_id (optional): + The ID of your Sift Science account. You can obtain + it from https://developers.sift.com/console/account/profile + + version (optional): + The version of the Sift Science API to call. + Defaults to the latest version. + + session (optional): + requests.Session object + https://requests.readthedocs.io/en/latest/user/advanced/#session-objects """ - _assert_non_empty_unicode(api_url, 'api_url') + _assert_non_empty_str(api_url, "api_url") if api_key is None: api_key = sift.api_key - _assert_non_empty_unicode(api_key, 'api_key') + _assert_non_empty_str(api_key, "api_key") self.session = session or requests.Session() - self.api_key = api_key + self.api_key = t.cast(str, api_key) self.url = api_url self.timeout = timeout - self.account_id = account_id or sift.account_id + self.account_id = t.cast(str, account_id or sift.account_id) self.version = version - def track( - self, - event, - properties, - path=None, - return_score=False, - return_action=False, - return_workflow_status=False, - force_workflow_run=False, - abuse_types=None, - timeout=None, - version=None): - """Track an event and associated properties to the Sift Science client. - This call is blocking. Check out https://siftscience.com/resources/references/events-api - for more information on what types of events you can send and fields you can add to the - properties parameter. + @staticmethod + def _get_fields_param( + include_score_percentiles: bool, + include_warnings: bool, + ) -> list[str]: + return [ + field + for include, field in ( + (include_score_percentiles, "SCORE_PERCENTILES"), + (include_warnings, "WARNINGS"), + ) + if include + ] + + @property + def _auth(self) -> HTTPBasicAuth: + return HTTPBasicAuth(self.api_key, "") + + def _user_agent(self, version: str | None = None) -> str: + return ( + f"SiftScience/v{version or self.version} " + f"sift-python/{VERSION} " + f"Python/{sys.version.split(' ')[0]}" + ) + + def _default_headers(self, version: str | None = None) -> dict[str, str]: + return { + "User-Agent": self._user_agent(version), + } + + def _post_headers(self, version: str | None = None) -> dict[str, str]: + return { + **self._default_headers(version), + "Content-type": "application/json", + "Accept": "*/*", + } + + def _api_url(self, version: str, endpoint: str) -> str: + return f"{self.url}/{version}{endpoint}" + + def _v1_api(self, endpoint: str) -> str: + return self._api_url("v1", endpoint) + + def _v3_api(self, endpoint: str) -> str: + return self._api_url("v3", endpoint) + + def _versioned_api(self, version: str, endpoint: str) -> str: + return self._api_url(f"v{version}", endpoint) + + def _events_url(self, version: str) -> str: + return self._versioned_api(version, "/events") + + def _score_url(self, user_id: str, version: str) -> str: + return self._versioned_api(version, f"/score/{_q(user_id)}") + + def _user_score_url(self, user_id: str, version: str) -> str: + return self._versioned_api(version, f"/users/{_q(user_id)}/score") + + def _labels_url(self, user_id: str, version: str) -> str: + return self._versioned_api(version, f"/users/{_q(user_id)}/labels") + + def _workflow_status_url(self, account_id: str, run_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/workflows/runs/{_q(run_id)}" + ) + + def _decisions_url(self, account_id: str) -> str: + return self._v3_api(f"/accounts/{_q(account_id)}/decisions") + + def _order_decisions_url(self, account_id: str, order_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/orders/{_q(order_id)}/decisions" + ) + + def _user_decisions_url(self, account_id: str, user_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/decisions" + ) + + def _session_decisions_url( + self, account_id: str, user_id: str, session_id: str + ) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/sessions/{_q(session_id)}/decisions" + ) + + def _content_decisions_url( + self, account_id: str, user_id: str, content_id: str + ) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/content/{_q(content_id)}/decisions" + ) + + def _order_apply_decisions_url( + self, account_id: str, user_id: str, order_id: str + ) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/orders/{_q(order_id)}/decisions" + ) + + def _psp_merchant_url(self, account_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/psp_management/merchants" + ) + + def _psp_merchant_id_url(self, account_id: str, merchant_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/psp_management/merchants/{_q(merchant_id)}" + ) - Args: - event: The name of the event to send. This can either be a reserved - event name such as "$transaction" or "$create_order" or a custom event - name (that does not start with a $). + def _verification_send_url(self) -> str: + return self._v1_api("/verification/send") - properties: A dict of additional event-specific attributes to track. + def _verification_resend_url(self) -> str: + return self._v1_api("/verification/resend") - return_score: Whether the API response should include a score for this - user (the score will be calculated using this event). + def _verification_check_url(self) -> str: + return self._v1_api("/verification/check") - return_action: Whether the API response should include actions in the response. For - more information on how this works, please visit the tutorial at: - https://siftscience.com/resources/tutorials/formulas . + def _validate_send_request(self, properties: Mapping[str, t.Any]) -> None: + """This method is used to validate arguments passed to the send method.""" - return_workflow_status: Whether the API response should - include the status of any workflow run as a result of - the tracked event. + _assert_non_empty_dict(properties, "properties") - force_workflow_run: TODO:(rlong) Add after Rishabh adds documentation. + user_id = properties.get("$user_id") + _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + send_to = properties.get("$send_to") + _assert_non_empty_str(send_to, "send_to", error_cls=ValueError) - timeout(optional): Use a custom timeout (in seconds) for this call. + verification_type = properties.get("$verification_type") + _assert_non_empty_str( + verification_type, "verification_type", error_cls=ValueError + ) - version(optional): Use a different version of the Sift Science API for this call. + event = properties.get("$event") + if not isinstance(event, Mapping): + raise TypeError("$event must be a mapping (dict)") + elif not event: + raise ValueError("$event mapping (dict) may not be empty") - Returns: - A sift.client.Response object if the track call succeeded, otherwise - raises an ApiException. + session_id = event.get("$session_id") + _assert_non_empty_str(session_id, "session_id", error_cls=ValueError) + + def _validate_resend_request( + self, + properties: Mapping[str, t.Any], + ) -> None: + """This method is used to validate arguments passed to the send method.""" + + _assert_non_empty_dict(properties, "properties") + user_id = properties.get("$user_id") + _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) + + def _validate_check_request(self, properties: Mapping[str, t.Any]) -> None: + """This method is used to validate arguments passed to the check method.""" + + _assert_non_empty_dict(properties, "properties") + + user_id = properties.get("$user_id") + _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) + + if properties.get("$code") is None: + raise ValueError("code is required") + + def _validate_apply_decision_request( + self, + properties: Mapping[str, t.Any], + user_id: str, + ) -> None: + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_dict(properties, "properties") + + source = properties.get("source") + + _assert_non_empty_str(source, "source", error_cls=ValueError) + + if source not in DECISION_SOURCES: + raise ValueError( + f"decision 'source' must be one of {list(DECISION_SOURCES)}" + ) + + if source == "MANUAL_REVIEW" and not properties.get("analyst"): + raise ValueError( + "must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'" + ) + + def track( + self, + event: str, + properties: Mapping[str, t.Any], + path: str | None = None, + return_score: bool = False, + return_action: bool = False, + return_workflow_status: bool = False, + return_route_info: bool = False, + force_workflow_run: bool = False, + abuse_types: Sequence[str] | None = None, + timeout: float | tuple[float, float] | None = None, + version: str | None = None, + include_score_percentiles: bool = False, + include_warnings: bool = False, + ) -> Response: """ - _assert_non_empty_unicode(event, 'event') - _assert_non_empty_dict(properties, 'properties') + Track an event and associated properties to the Sift Science client. - headers = {'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()} + This call is blocking. + + Visit https://developers.sift.com/docs/python/events-api/ + for more information on what types of events you can send and fields + you can add to the properties parameter. + + Args: + event: + The name of the event to send. This can either be a reserved + event name such as "$transaction" or "$create_order" or + a custom event name (that does not start with a $). + + properties: + A mapping of additional event-specific attributes to track. + + path: + An API endpoint to make a request to. + Defaults to Events API Endpoint + + return_score (optional): + Whether the API response should include a score for + this user (the score will be calculated using this event). + + return_action (optional): + Whether the API response should include actions in the + response. For more information on how this works, please + visit the tutorial at: + https://developers.sift.com/tutorials/formulas + + return_workflow_status (optional): + Whether the API response should include the status of any + workflow run as a result of the tracked event. + + return_route_info (optional): + Whether to get the route information from the Workflow + Decision. This parameter must be used with the + `return_workflow_status` query parameter. + + force_workflow_run (optional): + Set to True to run the Workflow Asynchronously if your Workflow + is set to only run on API Request. If a Workflow is not running + on the event you send this with, there will be no error or + score response, and no workflow will run. + + abuse_types (optional): + A sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + version (optional): + Use a different version of the Sift Science API for this call. + + include_score_percentiles (optional): + Whether to add new parameter in the query parameter. if + `include_score_percentiles` is True then add a new parameter + called fields in the query parameter + + include_warnings (optional): + Whether the API response should include `warnings` field. + If `include_warnings` is True `warnings` field returns the + amount of validation warnings along with their descriptions. + They are not critical enough to reject the whole request, + but important enough to be fixed. + + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + _assert_non_empty_str(event, "event") + _assert_non_empty_dict(properties, "properties") if version is None: version = self.version if path is None: - path = self._event_url(version) + path = self._events_url(version) if timeout is None: timeout = self.timeout - properties.update({'$api_key': self.api_key, '$type': event}) - params = {} + _properties = { + **properties, + "$api_key": self.api_key, + "$type": event, + } + + params: dict[str, t.Any] = {} if return_score: - params['return_score'] = 'true' + params["return_score"] = "true" if return_action: - params['return_action'] = 'true' + params["return_action"] = "true" if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) if return_workflow_status: - params['return_workflow_status'] = 'true' + params["return_workflow_status"] = "true" + + if return_route_info: + params["return_route_info"] = "true" if force_workflow_run: - params['force_workflow_run'] = 'true' + params["force_workflow_run"] = "true" + + include_fields = self._get_fields_param( + include_score_percentiles, include_warnings + ) + + if include_fields: + params["fields"] = ",".join(include_fields) try: response = self.session.post( path, - data=json.dumps(properties), - headers=headers, + data=json.dumps(_properties, cls=DecimalEncoder), + headers=self._post_headers(version), timeout=timeout, - params=params) - return Response(response) + params=params, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), path) - def score(self, user_id, timeout=None, abuse_types=None, version=None): - """Retrieves a user's fraud score from the Sift Science API. - This call is blocking. Check out https://siftscience.com/resources/references/score_api.html - for more information on our Score response structure. + return Response(response) + + def score( + self, + user_id: str, + timeout: float | tuple[float, float] | None = None, + abuse_types: Sequence[str] | None = None, + version: str | None = None, + include_score_percentiles: bool = False, + ) -> Response: + """ + Retrieves a user's fraud score from the Sift Science API. + + This call is blocking. + + Visit https://developers.sift.com/docs/python/score-api/ + for more details on our Score response structure. Args: - user_id: A user's id. This id should be the same as the user_id used in - event calls. + user_id: + A user's id. This id should be the same as the `user_id` + used in event calls. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. - timeout(optional): Use a custom timeout (in seconds) for this call. + abuse_types (optional): + A sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + version (optional): + Use a different version of the Sift Science API for this call. - version(optional): Use a different version of the Sift Science API for this call. + include_score_percentiles (optional): + Whether to add new parameter in the query parameter. + if `include_score_percentiles` is True then add a new + parameter called `fields` in the query parameter Returns: - A sift.client.Response object if the score call succeeded, or raises - an ApiException. + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -195,156 +558,248 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): if version is None: version = self.version - headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + params: dict[str, t.Any] = {} + if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) + + if include_score_percentiles: + params["fields"] = "SCORE_PERCENTILES" url = self._score_url(user_id, version) try: response = self.session.get( url, - headers=headers, + params=params, + auth=self._auth, + headers=self._default_headers(version), timeout=timeout, - params=params) - return Response(response) + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_user_score(self, user_id, timeout=None, abuse_types=None): - """Fetches the latest score(s) computed for the specified user and abuse types from the Sift Science API. - As opposed to client.score() and client.rescore_user(), this *does not* compute a new score for the user; it - simply fetches the latest score(s) which have computed. These scores may be arbitrarily old. + return Response(response) + + def get_user_score( + self, + user_id: str, + timeout: float | tuple[float, float] | None = None, + abuse_types: Sequence[str] | None = None, + include_score_percentiles: bool = False, + ) -> Response: + """ + Fetches the latest score(s) computed for the specified user and + abuse types from the Sift Science API. As opposed to client.score() + and client.rescore_user(), this *does not* compute a new score for + the user; it simply fetches the latest score(s) which have computed. + These scores may be arbitrarily old. + + This call is blocking. - This call is blocking. See https://siftscience.com/developers/docs/python/score-api/get-score for more details. + Visit https://developers.sift.com/docs/python/score-api/get-score/ + for more details. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + abuse_types (optional): + A sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + include_score_percentiles (optional): + Whether to add new parameter in the query parameter. + if include_score_percentiles is True then add a new parameter + called fields in the query parameter Returns: - A sift.client.Response object if the score call succeeded, or raises - an ApiException. + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout url = self._user_score_url(user_id, self.version) - headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + params: dict[str, t.Any] = {} + if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) + + if include_score_percentiles: + params["fields"] = "SCORE_PERCENTILES" try: response = self.session.get( url, - headers=headers, + params=params, + auth=self._auth, + headers=self._default_headers(), timeout=timeout, - params=params) - return Response(response) + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def rescore_user(self, user_id, timeout=None, abuse_types=None): - """Rescores the specified user for the specified abuse types and returns the resulting score(s). - This call is blocking. See https://siftscience.com/developers/docs/python/score-api/rescore for more details. + return Response(response) + + def rescore_user( + self, + user_id: str, + timeout: float | tuple[float, float] | None = None, + abuse_types: Sequence[str] | None = None, + ) -> Response: + """ + Rescores the specified user for the specified abuse types and returns + the resulting score(s). + + This call is blocking. + + Visit https://developers.sift.com/docs/python/score-api/rescore/ + for more details. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + abuse_types (optional): + A sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. Returns: - A sift.client.Response object if the score call succeeded, or raises - an ApiException. + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout url = self._user_score_url(user_id, self.version) - headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + params: dict[str, t.Any] = {} + if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) try: response = self.session.post( url, - headers=headers, + params=params, + auth=self._auth, + headers=self._default_headers(), timeout=timeout, - params=params) - return Response(response) + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def label(self, user_id, properties, timeout=None, version=None): - """Labels a user as either good or bad through the Sift Science API. - This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html - for more information on what fields to send in properties. + return Response(response) + + def label( + self, + user_id: str, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + version: str | None = None, + ) -> Response: + """ + Labels a user as either good or bad through the Sift Science API. + + This call is blocking. + + Visit https://developers.sift.com/docs/python/labels-api/label-user + for more details on what fields to send in properties. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - properties: A dict of additional event-specific attributes to track. + properties: + A mapping of additional event-specific attributes to track. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. - version(optional): Use a different version of the Sift Science API for this call. + version (optional): + Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the label call succeeded, otherwise - raises an ApiException. + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if version is None: version = self.version return self.track( - '$label', + "$label", properties, - path=self._label_url(user_id, version), + path=self._labels_url(user_id, version), timeout=timeout, - version=version) + version=version, + ) + + def unlabel( + self, + user_id: str, + timeout: float | tuple[float, float] | None = None, + abuse_type: str | None = None, + version: str | None = None, + ) -> Response: + """ + Unlabels a user through the Sift Science API. + + This call is blocking. - def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): - """unlabels a user through the Sift Science API. - This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html - for more information. + Visit https://developers.sift.com/docs/python/labels-api/unlabel-user + for more details. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. - abuse_type(optional): The abuse type for which the user should be unlabeled. + abuse_type (optional): + The abuse type for which the user should be unlabeled. If omitted, the user is unlabeled for all abuse types. - version(optional): Use a different version of the Sift Science API for this call. + version (optional): + Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the unlabel call succeeded, otherwise - raises an ApiException. + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -352,197 +807,280 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): if version is None: version = self.version - url = self._label_url(user_id, version) - headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + url = self._labels_url(user_id, version) + params: dict[str, t.Any] = {} + if abuse_type: - params['abuse_type'] = abuse_type + params["abuse_type"] = abuse_type try: response = self.session.delete( url, - headers=headers, + params=params, + auth=self._auth, + headers=self._default_headers(version), timeout=timeout, - params=params) - return Response(response) - + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_workflow_status(self, run_id, timeout=None): + return Response(response) + + def get_workflow_status( + self, + run_id: str, + timeout: float | tuple[float, float] | None = None, + ) -> Response: """Gets the status of a workflow run. Args: - run_id: The ID of a workflow run. + run_id: + The workflow run unique identifier. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call to the Sift API is successful + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(run_id, 'run_id') + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(run_id, "run_id") url = self._workflow_status_url(self.account_id, run_id) + if timeout is None: timeout = self.timeout try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._default_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=None, timeout=None): - """Get decisions available to customer + return Response(response) + + def get_decisions( + self, + entity_type: t.Literal["user", "order", "session", "content"], + limit: int | None = None, + start_from: int | None = None, + abuse_types: Sequence[str] | None = None, + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Get decisions available to the customer Args: - entity_type: only return decisions applicable to entity type {USER|ORDER|SESSION|CONTENT} - limit: number of query results (decisions) to return [optional, default: 100] - start_from: result set offset for use in pagination [optional, default: 0] - abuse_types: comma-separated list of abuse_types used to filter returned decisions (optional) + entity_type: + Return decisions applicable to entity type + One of: "user", "order", "session", "content" + + limit (optional): + Number of query results (decisions) to return [default: 100] + + start_from (optional): + Result set offset for use in pagination [default: 0] + + abuse_types (optional): + A sequence of abuse types, specifying by which abuse types + decisions should be filtered. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object containing array of decisions if call succeeded - Otherwise raises an ApiException + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ - if timeout is None: - timeout = self.timeout + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(entity_type, "entity_type") - params = {} + if entity_type.lower() not in ("user", "order", "session", "content"): + raise ValueError( + "entity_type must be one of {user, order, session, content}" + ) - _assert_non_empty_unicode(entity_type, 'entity_type') - if entity_type.lower() not in ['user', 'order', 'session', 'content']: - raise ValueError("entity_type must be one of {user, order, session, content}") + if isinstance(abuse_types, str): + raise ValueError( + "Passing `abuse_types` as string is deprecated. " + "Expected a sequence of string literals." + ) - params['entity_type'] = entity_type + params: dict[str, t.Any] = { + "entity_type": entity_type, + } if limit: - params['limit'] = limit + params["limit"] = limit if start_from: - params['from'] = start_from + params["from"] = start_from if abuse_types: - params['abuse_types'] = abuse_types + params["abuse_types"] = ",".join(abuse_types) + + if timeout is None: + timeout = self.timeout - url = self._get_decisions_url(self.account_id) + url = self._decisions_url(self.account_id) try: - return Response(self.session.get(url, params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, timeout=timeout)) - + response = self.session.get( + url, + params=params, + auth=self._auth, + headers=self._default_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_user_decision(self, user_id, properties, timeout=None): - """Apply decision to user + return Response(response) + + def apply_user_decision( + self, + user_id: str, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Apply decision to a user Args: - user_id: id of user + user_id: id of a user + properties: - decision_id: decision to apply to user + decision_id: decision to apply to a user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' time: in millis when decision was applied - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ + _assert_non_empty_str(self.account_id, "account_id") + + self._validate_apply_decision_request(properties, user_id) if timeout is None: timeout = self.timeout - self._validate_apply_decision_request(properties, user_id) url = self._user_decisions_url(self.account_id, user_id) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_order_decision(self, user_id, order_id, properties, timeout=None): + return Response(response) + + def apply_order_decision( + self, + user_id: str, + order_id: str, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + ) -> Response: """Apply decision to order Args: - user_id: id of user - order_id: id of order + user_id: + ID of a user. + + order_id: + The ID for the order. + properties: decision_id: decision to apply to order source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' description: free form text (optional) time: in millis when decision was applied (optional) - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException - """ - if timeout is None: - timeout = self.timeout + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ - _assert_non_empty_unicode(user_id, 'user_id') - _assert_non_empty_unicode(order_id, 'order_id') + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(order_id, "order_id") self._validate_apply_decision_request(properties, user_id) - url = self._order_apply_decisions_url(self.account_id, user_id, order_id) + if timeout is None: + timeout = self.timeout + + url = self._order_apply_decisions_url( + self.account_id, user_id, order_id + ) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def _validate_apply_decision_request(self, properties, user_id): - _assert_non_empty_unicode(user_id, 'user_id') + return Response(response) - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - source = properties.get('source') - - _assert_non_empty_unicode(source, 'source', error_cls=ValueError) - if source not in DECISION_SOURCES: - raise ValueError("decision 'source' must be one of [{0}]".format(", ".join(DECISION_SOURCES))) - - properties.update({'source': source.upper()}) - - if source == 'MANUAL_REVIEW' and not properties.get('analyst', None): - raise ValueError("must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'") - - def get_user_decisions(self, user_id, timeout=None): + def get_user_decisions( + self, + user_id: str, + timeout: float | tuple[float, float] | None = None, + ) -> Response: """Gets the decisions for a user. Args: - user_id: The ID of a user. + user_id: + The ID of a user. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call to the Sift API is successful + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(user_id, 'user_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -550,27 +1088,41 @@ def get_user_decisions(self, user_id, timeout=None): url = self._user_decisions_url(self.account_id, user_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._default_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_order_decisions(self, order_id, timeout=None): + return Response(response) + + def get_order_decisions( + self, + order_id: str, + timeout: float | tuple[float, float] | None = None, + ) -> Response: """Gets the decisions for an order. Args: - order_id: The ID of an order. + order_id: + The ID for the order. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call to the Sift API is successful + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(order_id, 'order_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(order_id, "order_id") if timeout is None: timeout = self.timeout @@ -578,29 +1130,46 @@ def get_order_decisions(self, order_id, timeout=None): url = self._order_decisions_url(self.account_id, order_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._default_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_content_decisions(self, user_id, content_id, timeout=None): + return Response(response) + + def get_content_decisions( + self, + user_id: str, + content_id: str, + timeout: float | tuple[float, float] | None = None, + ) -> Response: """Gets the decisions for a piece of content. Args: - user_id: The ID of the owner of the content. - content_id: The ID of a piece of content. + user_id: + The ID of the owner of the content. + + content_id: + The ID for the content. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call to the Sift API is successful + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(content_id, 'content_id') - _assert_non_empty_unicode(user_id, 'user_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(content_id, "content_id") + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -608,29 +1177,46 @@ def get_content_decisions(self, user_id, content_id, timeout=None): url = self._content_decisions_url(self.account_id, user_id, content_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._default_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_session_decisions(self, user_id, session_id, timeout=None): + return Response(response) + + def get_session_decisions( + self, + user_id: str, + session_id: str, + timeout: float | tuple[float, float] | None = None, + ) -> Response: """Gets the decisions for a user's session. Args: - user_id: The ID of a user. - session_id: The ID of a session. + user_id: + The ID for the user. + + session_id: + The ID for the session. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call to the Sift API is successful + Raises: + ApiException: If the call to the Sift API is not successful """ - _assert_non_empty_unicode(user_id, 'user_id') - _assert_non_empty_unicode(session_id, 'session_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(session_id, "session_id") if timeout is None: timeout = self.timeout @@ -638,233 +1224,527 @@ def get_session_decisions(self, user_id, session_id, timeout=None): url = self._session_decisions_url(self.account_id, user_id, session_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._default_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_session_decision(self, user_id, session_id, properties, timeout=None): - """Apply decision to session + return Response(response) + + def apply_session_decision( + self, + user_id: str, + session_id: str, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Apply decision to a session. Args: - user_id: id of user - session_id: id of session + user_id: + The ID for the user. + + session_id: + The ID for the session. + properties: decision_id: decision to apply to session source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' description: free form text (optional) time: in millis when decision was applied (optional) - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException - """ - if timeout is None: - timeout = self.timeout + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + Returns: + A sift.client.Response object if the call to the Sift API is successful - _assert_non_empty_unicode(session_id, 'session_id') + Raises: + ApiException: If the call to the Sift API is not successful + """ + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(session_id, "session_id") self._validate_apply_decision_request(properties, user_id) - url = self._session_apply_decisions_url(self.account_id, user_id, session_id) + if timeout is None: + timeout = self.timeout + + url = self._session_decisions_url(self.account_id, user_id, session_id) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth, + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_content_decision(self, user_id, content_id, properties, timeout=None): - """Apply decision to content + return Response(response) + + def apply_content_decision( + self, + user_id: str, + content_id: str, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Apply decision to a piece of content. Args: - user_id: id of user - content_id: id of content + user_id: + The ID for the user. + + content_id: + The ID for the content. + properties: decision_id: decision to apply to session source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' description: free form text (optional) time: in millis when decision was applied (optional) - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful """ + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(content_id, "content_id") + + self._validate_apply_decision_request(properties, user_id) if timeout is None: timeout = self.timeout - _assert_non_empty_unicode(content_id, 'content_id') + url = self._content_decisions_url(self.account_id, user_id, content_id) - self._validate_apply_decision_request(properties, user_id) + try: + response = self.session.post( + url, + data=json.dumps(properties), + auth=self._auth, + headers=self._post_headers(), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + return Response(response) + + def create_psp_merchant_profile( + self, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Create a new PSP Merchant profile + + Args: + properties: + A mapping of merchant profile data. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. - url = self._content_apply_decisions_url(self.account_id, user_id, content_id) + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + + _assert_non_empty_str(self.account_id, "account_id") + + if timeout is None: + timeout = self.timeout + + url = self._psp_merchant_url(self.account_id) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) + auth=self._auth, + headers=self._post_headers(), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + return Response(response) + + def update_psp_merchant_profile( + self, + merchant_id: str, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Update already existing PSP Merchant profile + + Args: + merchant_id: + The internal identifier for the merchant or seller providing + the good or service. + + properties: + A mapping of merchant profile data. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + + _assert_non_empty_str(self.account_id, "account_id") + + if timeout is None: + timeout = self.timeout + + url = self._psp_merchant_id_url(self.account_id, merchant_id) + + try: + response = self.session.put( + url, + data=json.dumps(properties), + auth=self._auth, + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def _user_agent(self): - return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) + return Response(response) + + def get_psp_merchant_profiles( + self, + batch_token: str | None = None, + batch_size: int | None = None, + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Gets all PSP merchant profiles (paginated). - def _event_url(self, version): - return self.url + '/v%s/events' % version + Args: + batch_token (optional): + Batch or page position of the paginated sequence. + + batch_size: (optional): + Batch or page size of the paginated sequence. - def _score_url(self, user_id, version): - return self.url + '/v%s/score/%s' % (version, _quote_path(user_id)) + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. - def _user_score_url(self, user_id, version): - return self.url + '/v%s/users/%s/score' % (version, urllib.parse.quote(user_id)) + Returns: + A sift.client.Response object if the call to the Sift API is successful - def _label_url(self, user_id, version): - return self.url + '/v%s/users/%s/labels' % (version, _quote_path(user_id)) + Raises: + ApiException: If the call to the Sift API is not successful + """ - def _workflow_status_url(self, account_id, run_id): - return (API3_URL + '/v3/accounts/%s/workflows/runs/%s' % - (_quote_path(account_id), _quote_path(run_id))) + _assert_non_empty_str(self.account_id, "account_id") - def _get_decisions_url(self, account_id): - return API3_URL + '/v3/accounts/%s/decisions' % (_quote_path(account_id),) + if timeout is None: + timeout = self.timeout - def _user_decisions_url(self, account_id, user_id): - return (API3_URL + '/v3/accounts/%s/users/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id))) + url = self._psp_merchant_url(self.account_id) - def _order_decisions_url(self, account_id, order_id): - return (API3_URL + '/v3/accounts/%s/orders/%s/decisions' % - (_quote_path(account_id), _quote_path(order_id))) + params: dict[str, t.Any] = {} - def _session_decisions_url(self, account_id, user_id, session_id): - return (API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(session_id))) + if batch_size: + params["batch_size"] = batch_size - def _content_decisions_url(self, account_id, user_id, content_id): - return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) + if batch_token: + params["batch_token"] = batch_token - def _order_apply_decisions_url(self, account_id, user_id, order_id): - return (API3_URL + '/v3/accounts/%s/users/%s/orders/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(order_id))) + try: + response = self.session.get( + url, + auth=self._auth, + headers=self._default_headers(), + params=params, + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) - def _session_apply_decisions_url(self, account_id, user_id, session_id): - return (API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(session_id))) + return Response(response) - def _content_apply_decisions_url(self, account_id, user_id, content_id): - return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) + def get_a_psp_merchant_profile( + self, + merchant_id: str, + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Gets a PSP merchant profile by merchant id. + Args: + merchant_id: + The internal identifier for the merchant or seller providing + the good or service. -class Response(object): + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. - HTTP_CODES_WITHOUT_BODY = [204, 304] + Returns: + A sift.client.Response object if the call to the Sift API is successful - def __init__(self, http_response): + Raises: + ApiException: If the call to the Sift API is not successful """ - Raises ApiException on invalid JSON in Response body or non-2XX HTTP - status code. + + _assert_non_empty_str(self.account_id, "account_id") + + if timeout is None: + timeout = self.timeout + + url = self._psp_merchant_id_url(self.account_id, merchant_id) + + try: + response = self.session.get( + url, + auth=self._auth, + headers=self._default_headers(), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + return Response(response) + + def verification_send( + self, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + version: str | None = None, + ) -> Response: """ - # Set defaults. - self.body = None - self.request = None - self.api_status = None - self.api_error_message = None - self.http_status_code = http_response.status_code - self.url = http_response.url + The send call triggers the generation of an OTP code that is stored + by Sift and email/sms the code to the user. - if (self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY) and http_response.text: - try: - self.body = http_response.json() - if 'status' in self.body: - self.api_status = self.body['status'] - if 'error_message' in self.body: - self.api_error_message = self.body['error_message'] - if 'request' in list(self.body.keys()) and isinstance(self.body['request'], str): - self.request = json.loads(self.body['request']) - except ValueError: - raise ApiException( - 'Failed to parse json response from {0}'.format(self.url), - url=self.url, - http_status_code=self.http_status_code, - body=self.body, - api_status=self.api_status, - api_error_message=self.api_error_message, - request=self.request) - finally: - if int(self.http_status_code) < 200 or int(self.http_status_code) >= 300: - raise ApiException( - '{0} returned non-2XX http status code {1}'.format(self.url, self.http_status_code), - url=self.url, - http_status_code=self.http_status_code, - body=self.body, - api_status=self.api_status, - api_error_message=self.api_error_message, - request=self.request) + This call is blocking. + + Visit https://developers.sift.com/docs/python/verification-api/send + for more details on our send response structure. + + Args: + properties: + + $user_id: + User ID of user being verified, e.g. johndoe123. + $send_to: + The phone / email to send the OTP to. + $verification_type: + The channel used for verification. Should be either $email + or $sms. + $brand_name (optional): + Name of the brand of product or service the user interacts + with. + $language (optional): + Language of the content of the web site. + $site_country (optional): + Country of the content of the site. + $event: + $session_id: + The session being verified. See $verification in the + Sift Events API documentation. + $verified_event: + The type of the reserved event being verified. + $reason (optional): + The trigger for the verification. See $verification + in the Sift Events API documentation. + $ip (optional): + The user's IP address. + $browser: + $user_agent: + The user agent of the browser that is verifying. + Represented by the $browser object. + Use this field if the client is a browser. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + version (optional): + Use a different version of the Sift Science API for this call. - def __str__(self): - return ('{%s "http_status_code": %s}' % - ('' if self.body is None else '"body": ' + - json.dumps(self.body) + ',', str(self.http_status_code))) + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + + if timeout is None: + timeout = self.timeout + + if version is None: + version = self.version + + self._validate_send_request(properties) + + url = self._verification_send_url() + + try: + response = self.session.post( + url, + data=json.dumps(properties), + auth=self._auth, + headers=self._post_headers(version), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) - def is_ok(self): + return Response(response) - if self.http_status_code in self.HTTP_CODES_WITHOUT_BODY: - return 204 == self.http_status_code + def verification_resend( + self, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + version: str | None = None, + ) -> Response: + """ + A user can ask for a new OTP (one-time password) if they haven't + received the previous one, or in case the previous OTP expired. - # NOTE: Responses from /v3/... endpoints do not contain an API status. - if self.api_status: - return self.api_status == 0 + This call is blocking. - return self.http_status_code == 200 + Visit https://developers.sift.com/docs/python/verification-api/resend + for more information on our send response structure. + Args: -class ApiException(Exception): - def __init__(self, message, url, http_status_code=None, body=None, api_status=None, - api_error_message=None, request=None): - Exception.__init__(self, message) + properties: + $user_id: + User ID of user being verified, e.g. johndoe123. + $verified_event (optional): + This will be the event type that triggered the verification. + $verified_entity_id (optional): + The ID of the entity impacted by the event being verified. - self.url = url - self.http_status_code = http_status_code - self.body = body - self.api_status = api_status - self.api_error_message = api_error_message - self.request = request + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + version (optional): + Use a different version of the Sift Science API for this call. -def _assert_non_empty_unicode(val, name, error_cls=None): - error = False - if not isinstance(val, _UNICODE_STRING): - error_cls = error_cls or TypeError - error = True - elif not val: - error_cls = error_cls or ValueError - error = True + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + + if timeout is None: + timeout = self.timeout + + if version is None: + version = self.version + + self._validate_resend_request(properties) + + url = self._verification_resend_url() + + try: + response = self.session.post( + url, + data=json.dumps(properties), + auth=self._auth, + headers=self._post_headers(version), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + return Response(response) + + def verification_check( + self, + properties: Mapping[str, t.Any], + timeout: float | tuple[float, float] | None = None, + version: str | None = None, + ) -> Response: + """ + The verification_check call is used for checking the OTP provided by + the end user to Sift. Sift then compares the OTP, checks rate limits + and responds with a decision whether the user should be able to + proceed or not. + + This call is blocking. + + Visit https://developers.sift.com/docs/python/verification-api/check + for more information on our check response structure. - if error: - raise error_cls('{0} must be a non-empty string'.format(name)) + Args: + + properties: + + $user_id: + User ID of user being verified, e.g. johndoe123. + $code: + The code the user sent to the customer for validation. + $verified_event (optional): + This will be the event type that triggered the verification. + $verified_entity_id (optional): + The ID of the entity impacted by the event being verified. + + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. + + version (optional): + Use a different version of the Sift Science API for this call. + + Returns: + A sift.client.Response object if the call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + if timeout is None: + timeout = self.timeout + if version is None: + version = self.version + + self._validate_check_request(properties) + + url = self._verification_check_url() + + try: + response = self.session.post( + url, + data=json.dumps(properties), + auth=self._auth, + headers=self._post_headers(version), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) -def _assert_non_empty_dict(val, name): - if not isinstance(val, dict): - raise TypeError('{0} must be a non-empty dict'.format(name)) - elif not val: - raise ValueError('{0} must be a non-empty dict'.format(name)) + return Response(response) diff --git a/sift/constants.py b/sift/constants.py new file mode 100644 index 0000000..bceecc8 --- /dev/null +++ b/sift/constants.py @@ -0,0 +1,7 @@ +API_URL = "https://api.sift.com" + +DECISION_SOURCES = ( + "MANUAL_REVIEW", + "AUTOMATED_RULE", + "CHARGEBACK", +) diff --git a/sift/exceptions.py b/sift/exceptions.py new file mode 100644 index 0000000..aad1432 --- /dev/null +++ b/sift/exceptions.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import typing as t + + +class ApiException(Exception): + def __init__( + self, + message: str, + url: str, + http_status_code: int | None = None, + body: dict[str, t.Any] | None = None, + api_status: int | None = None, + api_error_message: str | None = None, + request: dict[str, t.Any] | None = None, + ) -> None: + Exception.__init__(self, message) + + self.url = url + self.http_status_code = http_status_code + self.body = body + self.api_status = api_status + self.api_error_message = api_error_message + self.request = request diff --git a/sift/utils.py b/sift/utils.py new file mode 100644 index 0000000..40865e3 --- /dev/null +++ b/sift/utils.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import json +import typing as t +import urllib.parse +from decimal import Decimal + + +def quote_path(s: str) -> str: + # by default, urllib.quote doesn't escape forward slash; pass the + # optional arg to override this + return urllib.parse.quote(s, safe="") + + +class DecimalEncoder(json.JSONEncoder): + def default(self, o: object) -> tuple[str] | t.Any: + if isinstance(o, Decimal): + return (str(o),) + + return super().default(o) diff --git a/sift/version.py b/sift/version.py index 6558a05..ad368aa 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.0.1' -API_VERSION = '205' +VERSION = "6.0.0" +API_VERSION = "205" diff --git a/test_integration_app/decisions_api/__init__.py b/test_integration_app/decisions_api/__init__.py new file mode 100644 index 0000000..e6af361 --- /dev/null +++ b/test_integration_app/decisions_api/__init__.py @@ -0,0 +1 @@ +from decisions_api import test_decisions_api diff --git a/test_integration_app/decisions_api/test_decisions_api.py b/test_integration_app/decisions_api/test_decisions_api.py new file mode 100644 index 0000000..da05991 --- /dev/null +++ b/test_integration_app/decisions_api/test_decisions_api.py @@ -0,0 +1,83 @@ +from os import environ as env + +import globals + +import sift + + +class DecisionAPI: + # Get the value of API_KEY from environment variable + api_key = env["API_KEY"] + account_id = env["ACCOUNT_ID"] + client = sift.Client(api_key=api_key, account_id=account_id) + globals.initialize() + user_id = globals.user_id + session_id = globals.session_id + + def apply_user_decision(self) -> sift.client.Response: + properties = { + "decision_id": "integration_app_watch_account_abuse", + "source": "MANUAL_REVIEW", + "analyst": "analyst@example.com", + "description": "User linked to three other payment abusers and ordering high value items", + } + + return self.client.apply_user_decision(self.user_id, properties) + + def apply_order_decision(self) -> sift.client.Response: + properties = { + "decision_id": "block_order_payment_abuse", + "source": "AUTOMATED_RULE", + "description": "Auto block pending order as score exceeded risk threshold of 90", + } + + return self.client.apply_order_decision( + self.user_id, "ORDER-1234567", properties + ) + + def apply_session_decision(self) -> sift.client.Response: + properties = { + "decision_id": "integration_app_watch_account_takeover", + "source": "MANUAL_REVIEW", + "analyst": "analyst@example.com", + "description": "compromised account reported to customer service", + } + + return self.client.apply_session_decision( + self.user_id, self.session_id, properties + ) + + def apply_content_decision(self) -> sift.client.Response: + properties = { + "decision_id": "integration_app_watch_content_abuse", + "source": "MANUAL_REVIEW", + "analyst": "analyst@example.com", + "description": "fraudulent listing", + } + + return self.client.apply_content_decision( + self.user_id, "content_id", properties + ) + + def get_user_decisions(self) -> sift.client.Response: + return self.client.get_user_decisions(self.user_id) + + def get_order_decisions(self) -> sift.client.Response: + return self.client.get_order_decisions("ORDER-1234567") + + def get_content_decisions(self) -> sift.client.Response: + return self.client.get_content_decisions(self.user_id, "CONTENT_ID") + + def get_session_decisions(self) -> sift.client.Response: + return self.client.get_session_decisions(self.user_id, "SESSION_ID") + + def get_decisions(self) -> sift.client.Response: + return self.client.get_decisions( + entity_type="user", + limit=10, + start_from=5, + abuse_types=( + "legacy", + "payment_abuse", + ), + ) diff --git a/test_integration_app/events_api/__init__.py b/test_integration_app/events_api/__init__.py new file mode 100644 index 0000000..3bf15fe --- /dev/null +++ b/test_integration_app/events_api/__init__.py @@ -0,0 +1 @@ +from events_api import test_events_api diff --git a/test_integration_app/events_api/test_events_api.py b/test_integration_app/events_api/test_events_api.py new file mode 100644 index 0000000..3a065f5 --- /dev/null +++ b/test_integration_app/events_api/test_events_api.py @@ -0,0 +1,1288 @@ +from __future__ import annotations + +import os +import typing as t + +import globals + +import sift + + +class EventsAPI: + # Get the value of API_KEY from environment variable + api_key = os.environ["API_KEY"] + client = sift.Client(api_key=api_key) + globals.initialize() + user_id = globals.user_id + user_email = globals.user_email + + def add_item_to_cart(self) -> sift.client.Response: + add_item_to_cart_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$item": { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$currency_code": "USD", + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + "$quantity": 16, + }, + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track( + "$add_item_to_cart", add_item_to_cart_properties + ) + + def add_promotion(self) -> sift.client.Response: + add_promotion_properties = { + # Required fields. + "$user_id": self.user_id, + # Supported fields. + "$promotions": [ + # Example of a promotion for monetary discounts off good or services + { + "$promotion_id": "NewRideDiscountMay2016", + "$status": "$success", + "$description": "$5 off your first 5 rides", + "$referrer_user_id": "elon-m93903", + "$discount": { + "$amount": 5000000, + "$currency_code": "USD", + }, # $5 + } + ], + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$add_promotion", add_promotion_properties) + + def chargeback(self) -> sift.client.Response: + # Sample $chargeback event + chargeback_properties = { + # Required Fields + "$order_id": "ORDER-123124124", + "$transaction_id": "719637215", + # Recommended Fields + "$user_id": self.user_id, + "$chargeback_state": "$lost", + "$chargeback_reason": "$duplicate", + } + return self.client.track("$chargeback", chargeback_properties) + + def content_status(self) -> sift.client.Response: + # Sample $content_status event + content_status_properties = { + # Required Fields + "$user_id": self.user_id, + "$content_id": "9671500641", + "$status": "$paused", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$content_status", content_status_properties) + + def create_account(self) -> sift.client.Response: + # Sample $create_account event + create_account_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$referrer_user_id": "janejane101", + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$promotions": [ + { + "$promotion_id": "FriendReferral", + "$status": "$success", + "$referrer_user_id": "janejane102", + "$credit_point": { + "$amount": 100, + "$credit_point_type": "account karma", + }, + } + ], + "$social_sign_on_type": "$twitter", + "$account_types": ["merchant", "premium"], + # Suggested Custom Fields + "twitter_handle": "billyjones", + "work_phone": "1-347-555-5921", + "location": "New London, NH", + "referral_code": "MIKEFRIENDS", + "email_confirmed_status": "$pending", + "phone_confirmed_status": "$pending", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_account", create_account_properties) + + def create_content_comment(self) -> sift.client.Response: + # Sample $create_content event for comments + comment_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "comment-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $comment object + "$comment": { + "$body": "Congrats on the new role!", + "$contact_email": "alex_301@domain.com", + "$parent_comment_id": "comment-23407", + "$root_content_id": "listing-12923213", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "An old picture", + } + ], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", comment_properties) + + def create_content_listing(self) -> sift.client.Response: + # Sample $create_content event for listings + listing_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "listing-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $listing object + "$listing": { + "$subject": "2 Bedroom Apartment for Rent", + "$body": "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$listed_items": [ + { + "$price": 2950000000, # $2950.00 + "$currency_code": "USD", + "$tags": ["heat", "washer/dryer"], + } + ], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Billy's picture", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", listing_properties) + + def create_content_message(self) -> sift.client.Response: + # Sample $create_content event for messages + message_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "message-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $message object + "$message": { + "$body": "Let’s meet at 5pm", + "$contact_email": "alex_301@domain.com", + "$recipient_user_ids": ["fy9h989sjphh71"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "My hike today!", + } + ], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", message_properties) + + def create_content_post(self) -> sift.client.Response: + # Sample $create_content event for posts + post_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "post-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $post object + "$post": { + "$subject": "My new apartment!", + "$body": "Moved into my new apartment yesterday.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$categories": ["Personal"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "View from the window!", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", post_properties) + + def create_content_profile(self) -> sift.client.Response: + # Sample $create_content event for reviews + profile_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "profile-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $profile object + "$profile": { + "$body": "Hi! My name is Alex and I just moved to New London!", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Alex Smith", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Alex's picture", + } + ], + "$categories": ["Friends", "Long-term dating"], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", profile_properties) + + def create_content_review(self) -> sift.client.Response: + # Sample $create_content event for reviews + review_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "review-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $review object + "$review": { + "$subject": "Amazing Tacos!", + "$body": "I ate the tacos.", + "$contact_email": "alex_301@domain.com", + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$reviewed_content_id": "listing-234234", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Calamari tacos.", + } + ], + "$rating": 4.5, + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", review_properties) + + def create_order(self) -> sift.client.Response: + # Sample $create_order event + order_properties = self.build_create_order_event() + return self.client.track("$create_order", order_properties) + + def create_order_with_warnings(self) -> sift.client.Response: + # Sample $create_order event + order_properties = self.build_create_order_event() + return self.client.track( + "$create_order", order_properties, include_warnings=True + ) + + def build_create_order_event(self) -> dict[str, t.Any]: + order_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$order_id": "ORDER-28168441", + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$amount": 115940000, # $115.94 + "$currency_code": "USD", + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$ordered_from": { + "$store_id": "123", + "$store_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + }, + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$expedited_shipping": True, + "$shipping_method": "$physical", + "$shipping_carrier": "UPS", + "$shipping_tracking_numbers": [ + "1Z204E380338943508", + "1Z204E380338943509", + ], + "$items": [ + { + "$item_id": "12344321", + "$product_title": "Microwavable Kettle Corn: Original Flavor", + "$price": 4990000, # $4.99 + "$upc": "097564307560", + "$sku": "03586005", + "$brand": "Peters Kettle Corn", + "$manufacturer": "Peters Kettle Corn", + "$category": "Food and Grocery", + "$tags": ["Popcorn", "Snacks", "On Sale"], + "$quantity": 4, + }, + { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + "$quantity": 2, + }, + ], + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id": "slinkys_emporium", + "$promotions": [ + { + "$promotion_id": "FirstTimeBuyer", + "$status": "$success", + "$description": "$5 off", + "$discount": { + "$amount": 5000000, # $5.00 + "$currency_code": "USD", + "$minimum_purchase_amount": 25000000, # $25.00 + }, + } + ], + # Sample Custom Fields + "digital_wallet": "apple_pay", # "google_wallet", etc. + "coupon_code": "dollarMadness", + "shipping_choice": "FedEx Ground Courier", + "is_first_time_buyer": False, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return order_properties + + def flag_content(self) -> sift.client.Response: + # Sample $flag_content event + flag_content_properties = { + # Required Fields + "$user_id": self.user_id, # content creator + "$content_id": "9671500641", + # Supported Fields + "$flagged_by": "jamieli89", + } + return self.client.track("$flag_content", flag_content_properties) + + def link_session_to_user(self) -> sift.client.Response: + # Sample $link_session_to_user event + link_session_to_user_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", + } + return self.client.track( + "$link_session_to_user", link_session_to_user_properties + ) + + def login(self) -> sift.client.Response: + # Sample $login event + login_properties = { + # Required Fields + "$user_id": self.user_id, + "$login_status": "$failure", + "$session_id": "gigtleqddo84l8cm15qe4il", + "$ip": "128.148.1.135", + # Optional Fields + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$failure_reason": "$wrong_password", + "$username": "billjones1@example.com", + "$account_types": ["merchant", "premium"], + "$social_sign_on_type": "$linkedin", + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + # Send this information with a login from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$login", login_properties) + + def logout(self) -> sift.client.Response: + # Sample $logout event + logout_properties = { + # Required Fields + "$user_id": self.user_id, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$logout", logout_properties) + + def order_status(self) -> sift.client.Response: + # Sample $order_status event + order_properties = { + # Required Fields + "$user_id": self.user_id, + "$order_id": "ORDER-28168441", + "$order_status": "$canceled", + # Optional Fields + "$reason": "$payment_risk", + "$source": "$manual_review", + "$analyst": "someone@your-site.com", + "$webhook_id": "3ff1082a4aea8d0c58e3643ddb7a5bb87ffffeb2492dca33", + "$description": "Canceling because multiple fraudulent users on device", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$order_status", order_properties) + + def remove_item_from_cart(self) -> sift.client.Response: + # Sample $remove_item_from_cart event + remove_item_from_cart_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$item": { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$currency_code": "USD", + "$quantity": 2, + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + + return self.client.track( + "$remove_item_from_cart", remove_item_from_cart_properties + ) + + def security_notification(self) -> sift.client.Response: + # Sample $security_notification event + security_notification_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", + "$notification_status": "$sent", + # Optional fields if applicable + "$notification_type": "$email", + "$notified_value": "billy123@domain.com", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + + return self.client.track( + "$security_notification", security_notification_properties + ) + + def transaction(self) -> sift.client.Response: + # Sample $transaction event + transaction_properties = { + # Required Fields + "$user_id": self.user_id, + "$amount": 506790000, # $506.79 + "$currency_code": "USD", + # Supported Fields + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$transaction_type": "$sale", + "$transaction_status": "$failure", + "$decline_category": "$bank_decline", + "$order_id": "ORDER-123124124", + "$transaction_id": "719637215", + "$billing_address": { # or "$sent_address" # or "$received_address" + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + "$ordered_from": { + "$store_id": "123", + "$store_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + }, + # Credit card example + "$payment_method": { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444", + }, + # Supported fields for 3DS + "$status_3ds": "$attempted", + "$triggered_3ds": "$processor", + "$merchant_initiated_transaction": False, + # Supported Fields + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$session_id": "gigtleqddo84l8cm15qe4il", + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id": "slinkys_emporium", + # Sample Custom Fields + "digital_wallet": "apple_pay", # "google_wallet", etc. + "coupon_code": "dollarMadness", + "shipping_choice": "FedEx Ground Courier", + "is_first_time_buyer": False, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$transaction", transaction_properties) + + def update_account(self) -> sift.client.Response: + # Sample $update_account event + update_account_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$changed_password": True, + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$referrer_user_id": "janejane102", + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$social_sign_on_type": "$twitter", + "$account_types": ["merchant", "premium"], + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + + return self.client.track("$update_account", update_account_properties) + + def update_content_comment(self) -> sift.client.Response: + # Sample $update_content event for comments + update_content_comment_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "comment-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $comment object + "$comment": { + "$body": "Congrats on the new role!", + "$contact_email": "alex_301@domain.com", + "$parent_comment_id": "comment-23407", + "$root_content_id": "listing-12923213", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "An old picture", + } + ], + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_comment_properties + ) + + def update_content_listing(self) -> sift.client.Response: + # Sample $update_content event for listings + update_content_listing_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "listing-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $listing object + "$listing": { + "$subject": "2 Bedroom Apartment for Rent", + "$body": "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$listed_items": [ + { + "$price": 2950000000, # $2950.00 + "$currency_code": "USD", + "$tags": ["heat", "washer/dryer"], + } + ], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Billy's picture", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_listing_properties + ) + + def update_content_message(self) -> sift.client.Response: + # Sample $update_content event for messages + update_content_message_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "message-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $message object + "$message": { + "$body": "Lets meet at 5pm", + "$contact_email": "alex_301@domain.com", + "$recipient_user_ids": ["fy9h989sjphh71"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "My hike today!", + } + ], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + + return self.client.track( + "$update_content", update_content_message_properties + ) + + def update_content_post(self) -> sift.client.Response: + # Sample $update_content event for posts + update_content_post_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "post-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $post object + "$post": { + "$subject": "My new apartment!", + "$body": "Moved into my new apartment yesterday.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$categories": ["Personal"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "View from the window!", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_post_properties + ) + + def update_content_profile(self) -> sift.client.Response: + # Sample $update_content event for reviews + update_content_profile_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "profile-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $profile object + "$profile": { + "$body": "Hi! My name is Alex and I just moved to New London!", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Alex Smith", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Alex's picture", + } + ], + "$categories": ["Friends", "Long-term dating"], + }, + # ========================================= + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_profile_properties + ) + + def update_content_review(self) -> sift.client.Response: + # Sample $update_content event for reviews + update_content_review_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "review-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $review object + "$review": { + "$subject": "Amazing Tacos!", + "$body": "I ate the tacos.", + "$contact_email": "alex_301@domain.com", + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$reviewed_content_id": "listing-234234", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Calamari tacos.", + } + ], + "$rating": 4.5, + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_review_properties + ) + + def update_order(self) -> sift.client.Response: + # Sample $update_order event + update_order_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$order_id": "ORDER-28168441", + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$amount": 115940000, # $115.94 + "$currency_code": "USD", + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + "$ordered_from": { + "$store_id": "123", + "$store_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + }, + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$expedited_shipping": True, + "$shipping_method": "$physical", + "$shipping_carrier": "UPS", + "$shipping_tracking_numbers": [ + "1Z204E380338943508", + "1Z204E380338943509", + ], + "$items": [ + { + "$item_id": "12344321", + "$product_title": "Microwavable Kettle Corn: Original Flavor", + "$price": 4990000, # $4.99 + "$upc": "097564307560", + "$sku": "03586005", + "$brand": "Peters Kettle Corn", + "$manufacturer": "Peters Kettle Corn", + "$category": "Food and Grocery", + "$tags": ["Popcorn", "Snacks", "On Sale"], + "$quantity": 4, + }, + { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + "$quantity": 2, + }, + ], + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id": "slinkys_emporium", + "$promotions": [ + { + "$promotion_id": "FirstTimeBuyer", + "$status": "$success", + "$description": "$5 off", + "$discount": { + "$amount": 5000000, # $5.00 + "$currency_code": "USD", + "$minimum_purchase_amount": 25000000, # $25.00 + }, + } + ], + # Sample Custom Fields + "digital_wallet": "apple_pay", # "google_wallet", etc. + "coupon_code": "dollarMadness", + "shipping_choice": "FedEx Ground Courier", + "is_first_time_buyer": False, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track("$update_order", update_order_properties) + + def update_password(self) -> sift.client.Response: + # Sample $update_password event + update_password_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", + "$status": "$success", + "$reason": "$forced_reset", + "$ip": "128.148.1.135", # IP of the user that entered the new password after the old password was reset + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_password", update_password_properties + ) + + def verification(self) -> sift.client.Response: + # Sample $verification event + verification_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", + "$status": "$pending", + # Optional fields if applicable + "$verified_event": "$login", + "$reason": "$automated_rule", + "$verification_type": "$sms", + "$verified_value": "14155551212", + } + return self.client.track("$verification", verification_properties) diff --git a/test_integration_app/globals.py b/test_integration_app/globals.py new file mode 100644 index 0000000..cc7959e --- /dev/null +++ b/test_integration_app/globals.py @@ -0,0 +1,7 @@ +user_id = "billy_jones_301" +user_email = "billjones1@example.com" +session_id = "gigtleqddo84l8cm15qe4il" + + +def initialize() -> None: + global user_id, user_email, session_id diff --git a/test_integration_app/main.py b/test_integration_app/main.py new file mode 100644 index 0000000..6ac0fb8 --- /dev/null +++ b/test_integration_app/main.py @@ -0,0 +1,129 @@ +import random +import string + +from decisions_api import test_decisions_api +from events_api import test_events_api +from psp_merchant_api import test_psp_merchant_api +from score_api import test_score_api +from verifications_api import test_verification_api +from workflows_api import test_workflows_api + +from sift.client import Response + + +def is_ok(response: Response) -> bool: + if hasattr(response, "status"): + return response.status == 0 and response.http_status_code in (200, 201) + + return response.http_status_code in (200, 201) + + +def is_ok_with_warnings(response: Response) -> bool: + return ( + is_ok(response) + and hasattr(response, "body") + and isinstance(response.body, dict) + and bool(response.body["warnings"]) + ) + + +def is_ok_without_warnings(response: Response) -> bool: + return ( + is_ok(response) + and hasattr(response, "body") + and isinstance(response.body, dict) + and "warnings" not in response.body + ) + + +def run_all_methods() -> None: + obj_events = test_events_api.EventsAPI() + obj_decisions = test_decisions_api.DecisionAPI() + obj_score = test_score_api.ScoreAPI() + obj_workflow = test_workflows_api.WorkflowsAPI() + obj_verification = test_verification_api.VerificationAPI() + obj_psp_merchant = test_psp_merchant_api.PSPMerchantAPI() + + # Events APIs + assert is_ok(obj_events.add_item_to_cart()) + assert is_ok(obj_events.add_promotion()) + assert is_ok(obj_events.chargeback()) + assert is_ok(obj_events.content_status()) + assert is_ok(obj_events.create_account()) + assert is_ok(obj_events.create_content_comment()) + assert is_ok(obj_events.create_content_listing()) + assert is_ok(obj_events.create_content_message()) + assert is_ok(obj_events.create_content_post()) + assert is_ok(obj_events.create_content_profile()) + assert is_ok(obj_events.create_content_review()) + assert is_ok(obj_events.create_order()) + assert is_ok(obj_events.flag_content()) + assert is_ok(obj_events.link_session_to_user()) + assert is_ok(obj_events.login()) + assert is_ok(obj_events.logout()) + assert is_ok(obj_events.order_status()) + assert is_ok(obj_events.remove_item_from_cart()) + assert is_ok(obj_events.security_notification()) + assert is_ok(obj_events.transaction()) + assert is_ok(obj_events.update_account()) + assert is_ok(obj_events.update_content_comment()) + assert is_ok(obj_events.update_content_listing()) + assert is_ok(obj_events.update_content_message()) + assert is_ok(obj_events.update_content_post()) + assert is_ok(obj_events.update_content_profile()) + assert is_ok(obj_events.update_content_review()) + assert is_ok(obj_events.update_order()) + assert is_ok(obj_events.update_password()) + assert is_ok(obj_events.verification()) + + # Testing include warnings query param + assert is_ok_without_warnings(obj_events.create_order()) + assert is_ok_with_warnings(obj_events.create_order_with_warnings()) + + print("Events API Tested") + + # Decision APIs + assert is_ok(obj_decisions.apply_user_decision()) + assert is_ok(obj_decisions.apply_order_decision()) + assert is_ok(obj_decisions.apply_session_decision()) + assert is_ok(obj_decisions.apply_content_decision()) + assert is_ok(obj_decisions.get_user_decisions()) + assert is_ok(obj_decisions.get_order_decisions()) + assert is_ok(obj_decisions.get_content_decisions()) + assert is_ok(obj_decisions.get_session_decisions()) + assert is_ok(obj_decisions.get_decisions()) + print("Decision API Tested") + + # Workflows APIs + assert is_ok(obj_workflow.synchronous_workflows()) + print("Workflow API Tested") + + # Score APIs + assert is_ok(obj_score.get_user_score()) + print("Score API Tested") + + # Verification APIs + assert is_ok(obj_verification.send()) + assert is_ok(obj_verification.resend()) + checkResponse = obj_verification.check() + assert is_ok(checkResponse) + assert isinstance(checkResponse.body, dict) + assert checkResponse.body["status"] == 50 + print("Verification API Tested") + + # PSP Merchant APIs + merchant_id = "merchant_id_test_app" + "".join( + random.choices(string.digits, k=7) + ) + assert is_ok(obj_psp_merchant.create_merchant(merchant_id)) + assert is_ok(obj_psp_merchant.edit_merchant(merchant_id)) + assert is_ok(obj_psp_merchant.get_merchant_profiles()) + assert is_ok( + obj_psp_merchant.get_merchant_profiles(batch_size=10, batch_token=None) + ) + print("PSP Merchant API Tested") + + print("API Integration tests execution finished") + + +run_all_methods() diff --git a/test_integration_app/psp_merchant_api/__init__.py b/test_integration_app/psp_merchant_api/__init__.py new file mode 100644 index 0000000..46637e6 --- /dev/null +++ b/test_integration_app/psp_merchant_api/__init__.py @@ -0,0 +1 @@ +from psp_merchant_api import test_psp_merchant_api diff --git a/test_integration_app/psp_merchant_api/test_psp_merchant_api.py b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py new file mode 100644 index 0000000..6a023d2 --- /dev/null +++ b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from os import environ as env + +import sift + + +class PSPMerchantAPI: + # Get the value of API_KEY and ACCOUNT_ID from environment variable + api_key = env["API_KEY"] + account_id = env["ACCOUNT_ID"] + + client = sift.Client(api_key=api_key, account_id=account_id) + + def create_merchant(self, merchant_id: str) -> sift.client.Response: + properties = { + "id": merchant_id, + "name": "Wonderful Payments Inc.13", + "description": "Wonderful Payments payment provider.", + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320", + }, + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": {"level": "low", "score": 10}, + } + return self.client.create_psp_merchant_profile(properties) + + def edit_merchant(self, merchant_id: str) -> sift.client.Response: + properties = { + "id": merchant_id, + "name": "Wonderful Payments Inc.13 edit", + "description": "Wonderful Payments payment provider. edit", + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320", + }, + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": {"level": "low", "score": 10}, + } + return self.client.update_psp_merchant_profile(merchant_id, properties) + + def get_a_merchant_profile(self, merchant_id: str) -> sift.client.Response: + return self.client.get_a_psp_merchant_profile(merchant_id) + + def get_merchant_profiles( + self, + batch_token: str | None = None, + batch_size: int | None = None, + ) -> sift.client.Response: + return self.client.get_psp_merchant_profiles(batch_token, batch_size) diff --git a/test_integration_app/score_api/__init__.py b/test_integration_app/score_api/__init__.py new file mode 100644 index 0000000..71ccddf --- /dev/null +++ b/test_integration_app/score_api/__init__.py @@ -0,0 +1 @@ +from score_api import test_score_api diff --git a/test_integration_app/score_api/test_score_api.py b/test_integration_app/score_api/test_score_api.py new file mode 100644 index 0000000..844fdaa --- /dev/null +++ b/test_integration_app/score_api/test_score_api.py @@ -0,0 +1,19 @@ +from os import environ as env + +import globals + +import sift + + +class ScoreAPI: + # Get the value of API_KEY from environment variable + api_key = env["API_KEY"] + client = sift.Client(api_key=api_key) + globals.initialize() + user_id = globals.user_id + + def get_user_score(self) -> sift.client.Response: + return self.client.get_user_score( + user_id=self.user_id, + abuse_types=["payment_abuse", "promotion_abuse"], + ) diff --git a/test_integration_app/verifications_api/__init__.py b/test_integration_app/verifications_api/__init__.py new file mode 100644 index 0000000..710885a --- /dev/null +++ b/test_integration_app/verifications_api/__init__.py @@ -0,0 +1 @@ +from verifications_api import test_verification_api diff --git a/test_integration_app/verifications_api/test_verification_api.py b/test_integration_app/verifications_api/test_verification_api.py new file mode 100644 index 0000000..899df21 --- /dev/null +++ b/test_integration_app/verifications_api/test_verification_api.py @@ -0,0 +1,54 @@ +from os import environ as env + +import globals + +import sift + + +class VerificationAPI: + # Get the value of API_KEY from environment variable + api_key = env["API_KEY"] + client = sift.Client(api_key=api_key) + globals.initialize() + user_id = globals.user_id + user_email = globals.user_email + + def send(self) -> sift.client.Response: + properties = { + "$user_id": self.user_id, + "$send_to": self.user_email, + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$site_country": "IN", + "$event": { + "$session_id": "SOME_SESSION_ID", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + }, + } + return self.client.verification_send(properties) + + def resend(self) -> sift.client.Response: + properties = { + "$user_id": self.user_id, + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + } + return self.client.verification_resend(properties) + + def check(self) -> sift.client.Response: + properties = { + "$user_id": self.user_id, + "$code": "123456", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + } + return self.client.verification_check(properties) diff --git a/test_integration_app/workflows_api/__init__.py b/test_integration_app/workflows_api/__init__.py new file mode 100644 index 0000000..0b1a1fe --- /dev/null +++ b/test_integration_app/workflows_api/__init__.py @@ -0,0 +1 @@ +from workflows_api import test_workflows_api diff --git a/test_integration_app/workflows_api/test_workflows_api.py b/test_integration_app/workflows_api/test_workflows_api.py new file mode 100644 index 0000000..afb55f5 --- /dev/null +++ b/test_integration_app/workflows_api/test_workflows_api.py @@ -0,0 +1,28 @@ +from os import environ as env + +import globals + +import sift + + +class WorkflowsAPI: + # Get the value of API_KEY from environment variable + api_key = env["API_KEY"] + client = sift.Client(api_key=api_key) + globals.initialize() + user_id = globals.user_id + user_email = globals.user_email + + def synchronous_workflows(self) -> sift.client.Response: + properties = { + "$user_id": self.user_id, + "$user_email": self.user_email, + } + + return self.client.track( + "$create_order", + properties, + return_workflow_status=True, + return_route_info=True, + abuse_types=["promo_abuse", "content_abuse", "payment_abuse"], + ) diff --git a/tests/test_client.py b/tests/test_client.py index 6c0b8f1..b69abee 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,48 +1,97 @@ +from __future__ import annotations + import datetime -import warnings import json -import mock +import typing as t +import warnings +from decimal import Decimal +from unittest import TestCase, mock + +from requests.auth import HTTPBasicAuth +from requests.exceptions import RequestException + import sift -import unittest -import sys -import requests.exceptions -if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error -else: - import urllib.parse +from sift.utils import quote_path as _q + + +def valid_transaction_properties() -> dict[str, t.Any]: + return { + "$buyer_user_id": "123456", + "$seller_user_id": "654321", + "$amount": Decimal("1253200.0"), + "$currency_code": "USD", + "$time": int(datetime.datetime.now().strftime("%S")), + "$transaction_id": "my_transaction_id", + "$billing_name": "Mike Snow", + "$billing_bin": "411111", + "$billing_last4": "1111", + "$billing_address1": "123 Main St.", + "$billing_city": "San Francisco", + "$billing_region": "CA", + "$billing_country": "US", + "$billing_zip": "94131", + "$user_email": "mike@example.com", + } -def valid_transaction_properties(): +def valid_label_properties() -> dict[str, t.Any]: return { - '$buyer_user_id': '123456', - '$seller_user_id': '654321', - '$amount': 1253200, - '$currency_code': 'USD', - '$time': int(datetime.datetime.now().strftime('%s')), - '$transaction_id': 'my_transaction_id', - '$billing_name': 'Mike Snow', - '$billing_bin': '411111', - '$billing_last4': '1111', - '$billing_address1': '123 Main St.', - '$billing_city': 'San Francisco', - '$billing_region': 'CA', - '$billing_country': 'US', - '$billing_zip': '94131', - '$user_email': 'mike@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", } -def valid_label_properties(): +def valid_psp_merchant_properties() -> dict[str, t.Any]: return { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$id": "api-key-1", + "$name": "Wonderful Payments Inc.", + "$description": "Wonderful Payments payment provider.", + "$address": { + "$name": "Alany", + "$address_1": "Big Payment blvd, 22", + "$address_2": "apt, 8", + "$city": "New Orleans", + "$region": "NA", + "$country": "US", + "$zipcode": "76830", + "$phone": "0394888320", + }, + "$category": "1002", + "$service_level": "Platinum", + "$status": "active", + "$risk_profile": {"$level": "low", "$score": 10}, } -def score_response_json(): +def valid_psp_merchant_properties_response() -> str: + return """{ + "id":"api-key-1", + "name": "Wonderful Payments Inc.", + "description": "Wonderful Payments payment provider.", + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": { + "level": "low", + "score": "10" + }, + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320" + } + }""" + + +def score_response_json() -> str: return """{ "status": 0, "error_message": "OK", @@ -73,6 +122,24 @@ def score_response_json(): }""" +def workflow_statuses_json() -> str: + return """{ + "route" : { + "name" : "my route" + }, + "history": [ + { + "app": "decision", + "name": "Order Looks OK", + "state": "running", + "config": { + "decision_id": "order_looks_ok_payment_abuse" + } + } + ] + }""" + + # A sample response from the /{version}/users/{userId}/score API. USER_SCORE_RESPONSE_JSON = """{ "status": 0, @@ -109,7 +176,7 @@ def score_response_json(): }""" -def action_response_json(): +def action_response_json() -> str: return """{ "actions": [ { @@ -156,22 +223,25 @@ def action_response_json(): }""" -def response_with_data_header(): - return { - 'content-type': 'application/json; charset=UTF-8' - } +def response_with_data_header() -> dict[str, t.Any]: + return {"content-type": "application/json; charset=UTF-8"} -class TestSiftPythonClient(unittest.TestCase): +class TestSiftPythonClient(TestCase): - def setUp(self): - self.test_key = 'a_fake_test_api_key' - self.account_id = 'ACCT' - self.sift_client = sift.Client(api_key=self.test_key, account_id=self.account_id) + def setUp(self) -> None: + self.test_key = "a_fake_test_api_key" + self.account_id = "ACCT" + self.sift_client = sift.Client( + api_key=self.test_key, + account_id=self.account_id, + ) - def test_global_api_key(self): + def test_global_api_key(self) -> None: # test for error if global key is undefined - self.assertRaises(TypeError, sift.Client) + with mock.patch("sift.api_key"): + self.assertRaises(TypeError, sift.Client) + sift.api_key = "a_test_global_api_key" local_api_key = "a_test_local_api_key" @@ -179,131 +249,157 @@ def test_global_api_key(self): client2 = sift.Client(local_api_key) # test that global api key is assigned - assert(client1.api_key == sift.api_key) + assert client1.api_key == sift.api_key # test that local api key is assigned - assert(client2.api_key == local_api_key) + assert client2.api_key == local_api_key client2 = sift.Client() # test that client2 is assigned a new object with global api_key - assert(client2.api_key == sift.api_key) + assert client2.api_key == sift.api_key - def test_constructor_requires_valid_api_key(self): - self.assertRaises(TypeError, sift.Client, None) - self.assertRaises(ValueError, sift.Client, '') + def test_constructor_requires_valid_api_key(self) -> None: + with mock.patch("sift.api_key", return_value=None): + self.assertRaises(TypeError, sift.Client, None) + self.assertRaises(ValueError, sift.Client, "") - def test_constructor_invalid_api_url(self): + def test_constructor_invalid_api_url(self) -> None: self.assertRaises(TypeError, sift.Client, self.test_key, None) - self.assertRaises(ValueError, sift.Client, self.test_key, '') + self.assertRaises(ValueError, sift.Client, self.test_key, "") - def test_constructor_api_key(self): + def test_constructor_api_key(self) -> None: client = sift.Client(self.test_key) self.assertEqual(client.api_key, self.test_key) - def test_track_requires_valid_event(self): + def test_track_requires_valid_event(self) -> None: self.assertRaises(TypeError, self.sift_client.track, None, {}) - self.assertRaises(ValueError, self.sift_client.track, '', {}) + self.assertRaises(ValueError, self.sift_client.track, "", {}) self.assertRaises(TypeError, self.sift_client.track, 42, {}) - def test_track_requires_properties(self): - event = 'custom_event' + def test_track_requires_properties(self) -> None: + event = "custom_event" self.assertRaises(TypeError, self.sift_client.track, event, None) self.assertRaises(TypeError, self.sift_client.track, event, 42) self.assertRaises(ValueError, self.sift_client.track, event, {}) - def test_score_requires_user_id(self): + def test_score_requires_user_id(self) -> None: self.assertRaises(TypeError, self.sift_client.score, None) - self.assertRaises(ValueError, self.sift_client.score, '') + self.assertRaises(ValueError, self.sift_client.score, "") self.assertRaises(TypeError, self.sift_client.score, 42) - def test_event_ok(self): - event = '$transaction' + def test_event_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties()) + + response = self.sift_client.track( + event, valid_transaction_properties() + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_event_with_timeout_param_ok(self): - event = '$transaction' + def test_event_with_timeout_param_ok(self) -> None: + event = "$transaction" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.track( - event, valid_transaction_properties(), timeout=test_timeout) + event, valid_transaction_properties(), timeout=test_timeout + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=test_timeout, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score_ok(self): + def test_score_ok(self) -> None: mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score('12345') + + response = self.sift_client.score("12345") + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/12345', - params={'api_key': self.test_key}, + "https://api.sift.com/v205/score/12345", + params={}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.85) - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - - def test_score_with_timeout_param_ok(self): + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_score_with_timeout_param_ok(self) -> None: test_timeout = 5 mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score('12345', test_timeout) + + response = self.sift_client.score("12345", test_timeout) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/12345', - params={'api_key': self.test_key}, + "https://api.sift.com/v205/score/12345", + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.85) - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - - def test_get_user_score_ok(self): - """Test the GET /{version}/users/{userId}/score API, i.e. client.get_user_score() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_get_user_score_ok(self) -> None: + """ + Test the GET /{version}/users/{userId}/score API, + i.e. client.get_user_score() """ test_timeout = 5 mock_response = mock.Mock() @@ -311,24 +407,32 @@ def test_get_user_score_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_user_score('12345', test_timeout) + + response = self.sift_client.get_user_score("12345", test_timeout) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key}, + "https://api.sift.com/v205/users/12345/score", + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) - - def test_get_user_score_with_abuse_types_ok(self): - """Test the GET /{version}/users/{userId}/score?abuse_types=... API, i.e. client.get_user_score() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_get_user_score_with_abuse_types_ok(self) -> None: + """ + Test the GET /{version}/users/{userId}/score?abuse_types=... API, + i.e. client.get_user_score() """ test_timeout = 5 mock_response = mock.Mock() @@ -336,26 +440,36 @@ def test_get_user_score_with_abuse_types_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_user_score('12345', - abuse_types=['payment_abuse', 'content_abuse'], - timeout=test_timeout) + + response = self.sift_client.get_user_score( + "12345", + abuse_types=["payment_abuse", "content_abuse"], + timeout=test_timeout, + ) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + "https://api.sift.com/v205/users/12345/score", + params={"abuse_types": "payment_abuse,content_abuse"}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) - - def test_rescore_user_ok(self): - """Test the POST /{version}/users/{userId}/score API, i.e. client.rescore_user() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_rescore_user_ok(self) -> None: + """ + Test the POST /{version}/users/{userId}/score API, + i.e. client.rescore_user() """ test_timeout = 5 mock_response = mock.Mock() @@ -363,24 +477,32 @@ def test_rescore_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.rescore_user('12345', test_timeout) + + response = self.sift_client.rescore_user("12345", test_timeout) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key}, + "https://api.sift.com/v205/users/12345/score", + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) - - def test_rescore_user_with_abuse_types_ok(self): - """Test the POST /{version}/users/{userId}/score?abuse_types=... API, i.e. client.rescore_user() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_rescore_user_with_abuse_types_ok(self) -> None: + """ + Test the POST /{version}/users/{userId}/score?abuse_types=... API, + i.e. client.rescore_user() """ test_timeout = 5 mock_response = mock.Mock() @@ -388,58 +510,126 @@ def test_rescore_user_with_abuse_types_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.rescore_user('12345', - abuse_types=['payment_abuse', 'content_abuse'], - timeout=test_timeout) + + response = self.sift_client.rescore_user( + "12345", + abuse_types=["payment_abuse", "content_abuse"], + timeout=test_timeout, + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + "https://api.sift.com/v205/users/12345/score", + params={"abuse_types": "payment_abuse,content_abuse"}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) - - def test_sync_score_ok(self): - event = '$transaction' + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_sync_score_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % score_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {score_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.track( event, valid_transaction_properties(), return_score=True, - abuse_types=['payment_abuse', 'content_abuse', 'legacy']) + abuse_types=["payment_abuse", "content_abuse", "legacy"], + ) + + mock_post.assert_called_with( + "https://api.sift.com/v205/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={ + "return_score": "true", + "abuse_types": "payment_abuse,content_abuse,legacy", + }, + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score_response"]["score"] == 0.85 + assert ( + response.body["score_response"]["scores"]["content_abuse"][ + "score" + ] + == 0.14 + ) + assert ( + response.body["score_response"]["scores"]["payment_abuse"][ + "score" + ] + == 0.97 + ) + + def test_sync_workflow_ok(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + mock_response.content = f'{{"status": 0, "error_message": "OK", "workflow_statuses": {workflow_statuses_json()}}}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, + valid_transaction_properties(), + return_workflow_status=True, + return_route_info=True, + abuse_types=["payment_abuse", "content_abuse", "legacy"], + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_score': 'true', 'abuse_types': 'payment_abuse,content_abuse,legacy'}) + params={ + "return_workflow_status": "true", + "return_route_info": "true", + "abuse_types": "payment_abuse,content_abuse,legacy", + }, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - assert(response.body['score_response']['score'] == 0.85) - assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) - assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) - - def test_get_decisions_fails(self): + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert ( + response.body["workflow_statuses"]["route"]["name"] + == "my route" + ) + + def test_get_decisions_fails(self) -> None: with self.assertRaises(ValueError): - self.sift_client.get_decisions('usr') + self.sift_client.get_decisions( + t.cast(t.Literal["user", "order", "session", "content"], "usr") + ) - def test_get_decisions(self): + def test_get_decisions(self) -> None: mock_response = mock.Mock() get_decisions_response_json = """ @@ -463,31 +653,42 @@ def test_get_decisions(self): "next_ref": "v3/accounts/accountId/decisions" } """ - mock_response.content = get_decisions_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_decisions(entity_type="user", - limit=10, - start_from=None, - abuse_types="legacy,payment_abuse", - timeout=3) + response = self.sift_client.get_decisions( + entity_type="user", + limit=10, + start_from=None, + abuse_types=( + "legacy", + "payment_abuse", + ), + timeout=3, + ) + mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', + "https://api.sift.com/v3/accounts/ACCT/decisions", headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'user', 'limit': 10, 'abuse_types': 'legacy,payment_abuse'}, - timeout=3) - + params={ + "entity_type": "user", + "limit": 10, + "abuse_types": "legacy,payment_abuse", + }, + timeout=3, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['data'][0]['id'] == 'block_user') + assert response.is_ok() + assert isinstance(response.body, dict) + assert response.body["data"][0]["id"] == "block_user" - def test_get_decisions_entity_session(self): + def test_get_decisions_entity_session(self) -> None: mock_response = mock.Mock() get_decisions_response_json = """ { @@ -510,40 +711,61 @@ def test_get_decisions_entity_session(self): "next_ref": "v3/accounts/accountId/decisions" } """ - mock_response.content = get_decisions_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_decisions(entity_type="session", - limit=10, - start_from=None, - abuse_types="account_takeover", - timeout=3) + response = self.sift_client.get_decisions( + entity_type="session", + limit=10, + start_from=None, + abuse_types=("account_takeover",), + timeout=3, + ) + mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', + "https://api.sift.com/v3/accounts/ACCT/decisions", headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'session', 'limit': 10, 'abuse_types': 'account_takeover'}, - timeout=3) - + params={ + "entity_type": "session", + "limit": 10, + "abuse_types": "account_takeover", + }, + timeout=3, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['data'][0]['id'] == 'block_session') - - def test_apply_decision_to_user_ok(self): - user_id = '54321' + assert response.is_ok() + assert isinstance(response.body, dict) + assert response.body["data"][0]["id"] == "block_session" + + def test_get_decisions_with_deprecated_signature(self) -> None: + with mock.patch.object(self.sift_client.session, "get") as mock_get: + with self.assertRaises(ValueError): + self.sift_client.get_decisions( + entity_type="session", + limit=10, + start_from=None, + abuse_types=t.cast(list, "legacy,account_takeover"), + timeout=3, + ) + + mock_get.assert_not_called() + + def test_apply_decision_to_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - 'time': 1481569575 - } + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "analyst": "analyst@biz.com", + "description": "called user and verified account", + "time": 1481569575, + } apply_decision_response_json = """ { "entity": { @@ -560,109 +782,144 @@ def test_apply_decision_to_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_user_decision(user_id, apply_decision_request) - data = json.dumps(apply_decision_request) - mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + response = self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) + + mock_post.assert_called_with( + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.body['entity']['type'] == 'user') - assert(response.http_status_code == 200) - assert(response.is_ok()) + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "user" - def test_validate_no_user_id_string_fails(self): + def test_validate_no_user_id_string_fails(self) -> None: apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - } + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "analyst": "analyst@biz.com", + "description": "called user and verified account", + } + with self.assertRaises(TypeError): - self.sift_client._validate_apply_decision_request(apply_decision_request, 123) + self.sift_client._validate_apply_decision_request( + apply_decision_request, t.cast(str, 123) + ) - def test_apply_decision_to_order_fails_with_no_order_id(self): + def test_apply_decision_to_order_fails_with_no_order_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.apply_order_decision("user_id", None, {}) + self.sift_client.apply_order_decision( + "user_id", t.cast(str, None), {} + ) - def test_apply_decision_to_session_fails_with_no_session_id(self): + def test_apply_decision_to_session_fails_with_no_session_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.apply_session_decision("user_id", None, {}) + self.sift_client.apply_session_decision( + "user_id", t.cast(str, None), {} + ) - def test_get_session_decisions_fails_with_no_session_id(self): + def test_get_session_decisions_fails_with_no_session_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.get_session_decisions("user_id", None) + self.sift_client.get_session_decisions( + "user_id", t.cast(str, None) + ) - def test_apply_decision_to_content_fails_with_no_content_id(self): + def test_apply_decision_to_content_fails_with_no_content_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.apply_content_decision("user_id", None, {}) + self.sift_client.apply_content_decision( + "user_id", t.cast(str, None), {} + ) - def test_validate_apply_decision_request_no_analyst_fails(self): + def test_validate_apply_decision_request_no_analyst_fails(self) -> None: apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) - def test_validate_apply_decision_request_no_source_fails(self): + def test_validate_apply_decision_request_no_source_fails(self) -> None: apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) + + def test_validate_empty_apply_decision_request_fails(self) -> None: + apply_decision_request: dict[str, t.Any] = {} - def test_validate_empty_apply_decision_request_fails(self): - apply_decision_request = {} with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) - def test_apply_decision_manual_review_no_analyst_fails(self): - user_id = '54321' + def test_apply_decision_manual_review_no_analyst_fails(self) -> None: + user_id = "54321" apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision(user_id, apply_decision_request) + self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) - def test_apply_decision_no_source_fails(self): - user_id = '54321' + def test_apply_decision_no_source_fails(self) -> None: + user_id = "54321" apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision(user_id, apply_decision_request) + self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) - def test_apply_decision_invalid_source_fails(self): - user_id = '54321' + def test_apply_decision_invalid_source_fails(self) -> None: + user_id = "54321" apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'INVALID_SOURCE', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "source": "INVALID_SOURCE", + "time": 1481569575, } - self.assertRaises(ValueError, self.sift_client.apply_user_decision, user_id, apply_decision_request) + self.assertRaises( + ValueError, + self.sift_client.apply_user_decision, + user_id, + apply_decision_request, + ) - def test_apply_decision_to_order_ok(self): - user_id = '54321' - order_id = '43210' + def test_apply_decision_to_order_ok(self) -> None: + user_id = "54321" + order_id = "43210" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'order_looks_bad_payment_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + "decision_id": "order_looks_bad_payment_abuse", + "source": "AUTOMATED_RULE", + "time": 1481569575, + } apply_decision_response_json = """ { @@ -676,32 +933,40 @@ def test_apply_decision_to_order_ok(self): "time": "1481569575" } """ - mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_order_decision(user_id, order_id, apply_decision_request) - data = json.dumps(apply_decision_request) + + response = self.sift_client.apply_order_decision( + user_id, order_id, apply_decision_request + ) + mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id, order_id), - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/orders/{order_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.http_status_code == 200) - assert(response.body['entity']['type'] == 'order') - - def test_apply_decision_to_session_ok(self): - user_id = '54321' - session_id = 'gigtleqddo84l8cm15qe4il' + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "order" + + def test_apply_decision_to_session_ok(self) -> None: + user_id = "54321" + session_id = "gigtleqddo84l8cm15qe4il" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'session_looks_bad_ato', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + "decision_id": "session_looks_bad_ato", + "source": "AUTOMATED_RULE", + "time": 1481569575, + } apply_decision_response_json = """ { @@ -715,32 +980,40 @@ def test_apply_decision_to_session_ok(self): "time": "1481569575" } """ - mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_session_decision(user_id, session_id, apply_decision_request) - data = json.dumps(apply_decision_request) + + response = self.sift_client.apply_session_decision( + user_id, session_id, apply_decision_request + ) + mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id, session_id), - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/sessions/{session_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.http_status_code == 200) - assert(response.body['entity']['type'] == 'login') - - def test_apply_decision_to_content_ok(self): - user_id = '54321' - content_id = 'listing-1231' + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "login" + + def test_apply_decision_to_content_ok(self) -> None: + user_id = "54321" + content_id = "listing-1231" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'content_looks_bad_content_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + "decision_id": "content_looks_bad_content_abuse", + "source": "AUTOMATED_RULE", + "time": 1481569575, + } apply_decision_response_json = """ { @@ -754,244 +1027,290 @@ def test_apply_decision_to_content_ok(self): "time": "1481569575" } """ - mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_content_decision(user_id, content_id, apply_decision_request) - data = json.dumps(apply_decision_request) + + response = self.sift_client.apply_content_decision( + user_id, content_id, apply_decision_request + ) + mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id, content_id), - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/content/{content_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.http_status_code == 200) - assert(response.body['entity']['type'] == 'create_content') + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "create_content" - def test_label_user_ok(self): - user_id = '54321' + def test_label_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.label(user_id, valid_label_properties()) + + response = self.sift_client.label( + user_id, valid_label_properties() + ) + properties = { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=mock.ANY, params={}) + f"https://api.sift.com/v205/users/{user_id}/labels", + data=json.dumps(properties), + headers=mock.ANY, + timeout=mock.ANY, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_label_user_with_timeout_param_ok(self): - user_id = '54321' + def test_label_user_with_timeout_param_ok(self) -> None: + user_id = "54321" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.label( - user_id, valid_label_properties(), test_timeout) + user_id, valid_label_properties(), test_timeout + ) + properties = { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=test_timeout, params={}) + f"https://api.sift.com/v205/users/{user_id}/labels", + data=json.dumps(properties), + headers=mock.ANY, + timeout=test_timeout, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_unlabel_user_ok(self): - user_id = '54321' + def test_unlabel_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response - response = self.sift_client.unlabel(user_id, abuse_type='account_abuse') + + response = self.sift_client.unlabel( + user_id, abuse_type="account_abuse" + ) + mock_delete.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % user_id, + f"https://api.sift.com/v205/users/{user_id}/labels", headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key, 'abuse_type': 'account_abuse'}) + params={"abuse_type": "account_abuse"}, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - - def test_unicode_string_parameter_support(self): - # str is unicode in python 3, so no need to check as this was covered - # by other unit tests. - if sys.version_info[0] < 3: - mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK"}' - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - - user_id = '23056' - - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - assert(self.sift_client.track( - '$transaction', - valid_transaction_properties())) - assert(self.sift_client.label( - user_id, - valid_label_properties())) - with mock.patch.object(self.sift_client.session, 'get') as mock_get: - mock_get.return_value = mock_response - assert(self.sift_client.score( - user_id, abuse_types=['payment_abuse', 'content_abuse'])) - - def test_unlabel_user_with_special_chars_ok(self): + assert response.is_ok() + + def test_unlabel_user_with_special_chars_ok(self) -> None: user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response + response = self.sift_client.unlabel(user_id) + mock_delete.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), + f"https://api.sift.com/v205/users/{_q(user_id)}/labels", headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key}) + params={}, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) + assert response.is_ok() - def test_label_user__with_special_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_label_user__with_special_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.label( - user_id, valid_label_properties()) + user_id, valid_label_properties() + ) + properties = { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), - data=data, + f"https://api.sift.com/v205/users/{_q(user_id)}/labels", + data=json.dumps(properties), headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score__with_special_user_id_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_score__with_special_user_id_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score(user_id, abuse_types=['legacy']) + + response = self.sift_client.score(user_id, abuse_types=["legacy"]) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote(user_id), - params={'api_key': self.test_key, 'abuse_types': 'legacy'}, + f"https://api.sift.com/v205/score/{_q(user_id)}", + params={"abuse_types": "legacy"}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.85) - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - - def test_exception_during_track_call(self): + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_exception_during_track_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) + with self.assertRaises(sift.client.ApiException): - self.sift_client.track('$transaction', valid_transaction_properties()) + self.sift_client.track( + "$transaction", valid_transaction_properties() + ) - def test_exception_during_score_call(self): + def test_exception_during_score_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) + with self.assertRaises(sift.client.ApiException): - self.sift_client.score('Fred') + self.sift_client.score("Fred") - def test_exception_during_unlabel_call(self): + def test_exception_during_unlabel_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) + with self.assertRaises(sift.client.ApiException): - self.sift_client.unlabel('Fred') + self.sift_client.unlabel("Fred") - def test_return_actions_on_track(self): - event = '$transaction' + def test_return_actions_on_track(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % action_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {action_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( - event, valid_transaction_properties(), return_action=True) + event, valid_transaction_properties(), return_action=True + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_action': 'true'}) + params={"return_action": "true"}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - - actions = response.body["score_response"]['actions'] - assert(actions) - assert(actions[0]['action']) - assert(actions[0]['action']['id'] == 'freds_action') - assert(actions[0]['triggers']) - - def test_get_workflow_status(self): + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + + actions = response.body["score_response"]["actions"] + assert actions + assert actions[0]["action"] + assert actions[0]["action"]["id"] == "freds_action" + assert actions[0]["triggers"] + + def test_get_workflow_status(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1037,19 +1356,25 @@ def test_get_workflow_status(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_workflow_status('4zxwibludiaaa', timeout=3) - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa', - headers=mock.ANY, auth=mock.ANY, timeout=3) + response = self.sift_client.get_workflow_status( + "4zxwibludiaaa", timeout=3 + ) + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa", + headers=mock.ANY, + auth=mock.ANY, + timeout=3, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['state'] == 'running') + assert response.is_ok() + assert isinstance(response.body, dict) + assert response.body["state"] == "running" - def test_get_user_decisions(self): + def test_get_user_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1068,19 +1393,26 @@ def test_get_user_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_user_decisions('example_user') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_user_decisions("example_user") + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/users/example_user/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') - - def test_get_order_decisions(self): + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["payment_abuse"]["decision"]["id"] + == "user_decision" + ) + + def test_get_order_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1106,20 +1438,30 @@ def test_get_order_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_order_decisions('example_order') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/orders/example_order/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_order_decisions("example_order") + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/orders/example_order/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') - assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') - - def test_get_session_decisions(self): + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["payment_abuse"]["decision"]["id"] + == "decision7" + ) + assert ( + response.body["decisions"]["promotion_abuse"]["decision"]["id"] + == "good_order" + ) + + def test_get_session_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1138,19 +1480,30 @@ def test_get_session_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_session_decisions('example_user', 'example_session') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_session_decisions( + "example_user", "example_session" + ) + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["account_takeover"]["decision"][ + "id" + ] + == "session_decision" + ) - def test_get_content_decisions(self): + def test_get_content_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1169,21 +1522,34 @@ def test_get_content_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_content_decisions('example_user', 'example_content') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_content_decisions( + "example_user", "example_content" + ) + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') - - def test_provided_session(self): + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["content_abuse"]["decision"]["id"] + == "content_looks_bad_content_abuse" + ) + + def test_provided_session(self) -> None: session = mock.Mock() - client = sift.Client(api_key=self.test_key, account_id=self.account_id, session=session) + client = sift.Client( + api_key=self.test_key, + account_id=self.account_id, + session=session, + ) mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' @@ -1191,15 +1557,292 @@ def test_provided_session(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() session.post.return_value = mock_response + event = "$transaction" - event = '$transaction' client.track(event, valid_transaction_properties()) + session.post.assert_called_once() + def test_get_psp_merchant_profile(self) -> None: + """Test the GET /{version}/accounts/{accountId}/scorepsp_management/merchants?batch_type=...""" + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "get") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.get_psp_merchant_profiles( + timeout=test_timeout + ) + + mock_post.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants", + params={}, + headers=mock.ANY, + auth=mock.ANY, + timeout=test_timeout, + ) + self.assertIsInstance(response, sift.client.Response) + assert isinstance(response.body, dict) + assert "address" in response.body + + def test_get_psp_merchant_profile_id(self) -> None: + """Test the GET /{version}/accounts/{accountId}/scorepsp_management/merchants/{merchantId}""" + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "get") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.get_a_psp_merchant_profile( + merchant_id="api-key-1", timeout=test_timeout + ) + + mock_post.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants/api-key-1", + headers=mock.ANY, + auth=mock.ANY, + timeout=test_timeout, + ) + self.assertIsInstance(response, sift.client.Response) + assert isinstance(response.body, dict) + assert "address" in response.body + + def test_create_psp_merchant_profile(self) -> None: + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.create_psp_merchant_profile( + valid_psp_merchant_properties() + ) + + mock_post.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants", + data=json.dumps(valid_psp_merchant_properties()), + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) + self.assertIsInstance(response, sift.client.Response) + assert isinstance(response.body, dict) + assert "address" in response.body + + def test_update_psp_merchant_profile(self) -> None: + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "put") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.update_psp_merchant_profile( + "api-key-1", valid_psp_merchant_properties() + ) -def main(): - unittest.main() + mock_post.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants/api-key-1", + data=json.dumps(valid_psp_merchant_properties()), + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) + self.assertIsInstance(response, sift.client.Response) + assert isinstance(response.body, dict) + assert "address" in response.body + + def test_with_include_score_percentiles_ok(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, + valid_transaction_properties(), + include_score_percentiles=True, + ) + + mock_post.assert_called_with( + "https://api.sift.com/v205/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={"fields": "SCORE_PERCENTILES"}, + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + + def test_include_score_percentiles_as_false_ok(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, + valid_transaction_properties(), + include_score_percentiles=False, + ) + + mock_post.assert_called_with( + "https://api.sift.com/v205/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={}, + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + + def test_score_api_include_score_percentiles_ok(self) -> None: + mock_response = mock.Mock() + mock_response.content = score_response_json() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "get") as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.score( + user_id="12345", include_score_percentiles=True + ) + + mock_get.assert_called_with( + "https://api.sift.com/v205/score/12345", + params={"fields": "SCORE_PERCENTILES"}, + headers=mock.ANY, + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, ""), + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_get_user_score_include_score_percentiles_ok(self) -> None: + """ + Test the GET /{version}/users/{userId}/score API, + i.e. client.get_user_score() + """ + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = USER_SCORE_RESPONSE_JSON + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, "get") as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_user_score( + user_id="12345", + timeout=test_timeout, + include_score_percentiles=True, + ) + + mock_get.assert_called_with( + "https://api.sift.com/v205/users/12345/score", + params={"fields": "SCORE_PERCENTILES"}, + headers=mock.ANY, + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, ""), + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_warnings_added_as_fields_param(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, valid_transaction_properties(), include_warnings=True + ) + + mock_post.assert_called_with( + "https://api.sift.com/v205/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={"fields": "WARNINGS"}, + ) + self.assertIsInstance(response, sift.client.Response) + + def test_warnings_and_score_percentiles_added_as_fields_param( + self, + ) -> None: + event = "$transaction" + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track( + event, + valid_transaction_properties(), + include_score_percentiles=True, + include_warnings=True, + ) + mock_post.assert_called_with( + "https://api.sift.com/v205/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={"fields": "SCORE_PERCENTILES,WARNINGS"}, + ) + self.assertIsInstance(response, sift.client.Response) + + +def main() -> None: + main() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index a18b3d0..24a4f3c 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -1,48 +1,51 @@ +from __future__ import annotations + import datetime -import warnings import json -import mock -import sift +import typing as t import unittest -import sys -import requests.exceptions -if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error -else: - import urllib.parse +import warnings +from decimal import Decimal +from unittest import mock +from requests.auth import HTTPBasicAuth +from requests.exceptions import RequestException -def valid_transaction_properties(): +import sift +from sift.utils import quote_path as _q + + +def valid_transaction_properties() -> dict[str, t.Any]: return { - '$buyer_user_id': '123456', - '$seller_user_id': '654321', - '$amount': 1253200, - '$currency_code': 'USD', - '$time': int(datetime.datetime.now().strftime('%s')), - '$transaction_id': 'my_transaction_id', - '$billing_name': 'Mike Snow', - '$billing_bin': '411111', - '$billing_last4': '1111', - '$billing_address1': '123 Main St.', - '$billing_city': 'San Francisco', - '$billing_region': 'CA', - '$billing_country': 'US', - '$billing_zip': '94131', - '$user_email': 'mike@example.com' + "$buyer_user_id": "123456", + "$seller_user_id": "654321", + "$amount": Decimal("1253200.0"), + "$currency_code": "USD", + "$time": int(datetime.datetime.now().strftime("%S")), + "$transaction_id": "my_transaction_id", + "$billing_name": "Mike Snow", + "$billing_bin": "411111", + "$billing_last4": "1111", + "$billing_address1": "123 Main St.", + "$billing_city": "San Francisco", + "$billing_region": "CA", + "$billing_country": "US", + "$billing_zip": "94131", + "$user_email": "mike@example.com", } -def valid_label_properties(): +def valid_label_properties() -> dict[str, t.Any]: return { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", } -def score_response_json(): +def score_response_json() -> str: return """{ "status": 0, "error_message": "OK", @@ -51,7 +54,7 @@ def score_response_json(): }""" -def action_response_json(): +def action_response_json() -> str: return """{ "actions": [ { @@ -80,366 +83,447 @@ def action_response_json(): }""" -def response_with_data_header(): +def response_with_data_header() -> dict[str, t.Any]: return { - 'content-length': 1, # Simply has to be > 0 - 'content-type': 'application/json; charset=UTF-8' + "content-length": 1, # Simply has to be > 0 + "content-type": "application/json; charset=UTF-8", } class TestSiftPythonClient(unittest.TestCase): - def setUp(self): - self.test_key = 'a_fake_test_api_key' - self.sift_client = sift.Client(self.test_key, version='203') - self.sift_client_v204 = sift.Client(self.test_key) + def setUp(self) -> None: + self.test_key = "a_fake_test_api_key" + self.sift_client = sift.Client(api_key=self.test_key, version="203") + self.sift_client_v204 = sift.Client(api_key=self.test_key) - def test_track_requires_valid_event(self): + def test_track_requires_valid_event(self) -> None: self.assertRaises(TypeError, self.sift_client.track, None, {}) - self.assertRaises(ValueError, self.sift_client.track, '', {}) - self.assertRaises(TypeError, self.sift_client_v204.track, 42, {'version': '203'}) + self.assertRaises(ValueError, self.sift_client.track, "", {}) + self.assertRaises( + TypeError, self.sift_client_v204.track, 42, {"version": "203"} + ) - def test_track_requires_properties(self): - event = 'custom_event' + def test_track_requires_properties(self) -> None: + event = "custom_event" self.assertRaises(TypeError, self.sift_client.track, event, None, {}) - self.assertRaises(TypeError, self.sift_client_v204.track, event, 42, {'version': '203'}) + self.assertRaises( + TypeError, + self.sift_client_v204.track, + event, + 42, + {"version": "203"}, + ) self.assertRaises(ValueError, self.sift_client.track, event, {}) - def test_score_requires_user_id(self): - self.assertRaises(TypeError, self.sift_client_v204.score, None, {'version': '203'}) - self.assertRaises(ValueError, self.sift_client.score, '', {}) + def test_score_requires_user_id(self) -> None: + self.assertRaises( + TypeError, self.sift_client_v204.score, None, {"version": "203"} + ) + self.assertRaises(ValueError, self.sift_client.score, "", {}) self.assertRaises(TypeError, self.sift_client.score, 42, {}) - def test_event_ok(self): - event = '$transaction' + def test_event_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties()) + + response = self.sift_client.track( + event, valid_transaction_properties() + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_event_with_timeout_param_ok(self): - event = '$transaction' + def test_event_with_timeout_param_ok(self) -> None: + event = "$transaction" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client_v204.session, 'post') as mock_post: + + with mock.patch.object( + self.sift_client_v204.session, "post" + ) as mock_post: mock_post.return_value = mock_response + response = self.sift_client_v204.track( - event, valid_transaction_properties(), timeout=test_timeout, version='203') + event, + valid_transaction_properties(), + timeout=test_timeout, + version="203", + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=test_timeout, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score_ok(self): + def test_score_ok(self) -> None: mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client_v204.session, 'get') as mock_get: + + with mock.patch.object( + self.sift_client_v204.session, "get" + ) as mock_get: mock_get.return_value = mock_response - response = self.sift_client_v204.score('12345', version='203') + + response = self.sift_client_v204.score("12345", version="203") + mock_get.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', - params={'api_key': self.test_key}, + "https://api.sift.com/v203/score/12345", + params={}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.55 - def test_score_with_timeout_param_ok(self): + def test_score_with_timeout_param_ok(self) -> None: test_timeout = 5 mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score('12345', test_timeout) + + response = self.sift_client.score("12345", test_timeout) + mock_get.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', - params={'api_key': self.test_key}, + "https://api.sift.com/v203/score/12345", + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.55 - def test_sync_score_ok(self): - event = '$transaction' + def test_sync_score_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % score_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {score_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.track( - event, valid_transaction_properties(), return_score=True) + event, valid_transaction_properties(), return_score=True + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_score': 'true'}) + params={"return_score": "true"}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - assert(response.body["score_response"]['score'] == 0.55) - - def test_label_user_ok(self): - user_id = '54321' + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score_response"]["score"] == 0.55 + + def test_label_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.label(user_id, valid_label_properties()) + + response = self.sift_client.label( + user_id, valid_label_properties() + ) + properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) + properties.update({"$api_key": self.test_key, "$type": "$label"}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=mock.ANY, params={}) + f"https://api.sift.com/v203/users/{user_id}/labels", + data=data, + headers=mock.ANY, + timeout=mock.ANY, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_label_user_with_timeout_param_ok(self): - user_id = '54321' + def test_label_user_with_timeout_param_ok(self) -> None: + user_id = "54321" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client_v204.session, 'post') as mock_post: + + with mock.patch.object( + self.sift_client_v204.session, "post" + ) as mock_post: mock_post.return_value = mock_response + response = self.sift_client_v204.label( - user_id, valid_label_properties(), test_timeout, version='203') + user_id, valid_label_properties(), test_timeout, version="203" + ) + properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=test_timeout, params={}) + f"https://api.sift.com/v203/users/{user_id}/labels", + data=json.dumps(properties), + headers=mock.ANY, + timeout=test_timeout, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_unlabel_user_ok(self): - user_id = '54321' + def test_unlabel_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response + response = self.sift_client.unlabel(user_id) + mock_delete.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % user_id, + f"https://api.sift.com/v203/users/{user_id}/labels", headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key}) + params={}, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - - def test_unicode_string_parameter_support(self): - # str is unicode in python 3, so no need to check as this was covered - # by other unit tests. - if sys.version_info[0] < 3: - mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK"}' - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - - user_id = '23056' - - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - assert( - self.sift_client.track( - '$transaction', - valid_transaction_properties())) - assert( - self.sift_client.label( - user_id, - valid_label_properties())) - with mock.patch.object(self.sift_client.session, 'get') as mock_get: - mock_get.return_value = mock_response - assert(self.sift_client.score(user_id)) - - def test_unlabel_user_with_special_chars_ok(self): + assert response.is_ok() + + def test_unlabel_user_with_special_chars_ok(self) -> None: user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client_v204.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client_v204.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response - response = self.sift_client_v204.unlabel(user_id, version='203') + response = self.sift_client_v204.unlabel(user_id, version="203") + mock_delete.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % urllib.parse.quote(user_id), + f"https://api.sift.com/v203/users/{_q(user_id)}/labels", headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key}) + params={}, + auth=HTTPBasicAuth(self.test_key, ""), + ) + self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) + assert response.is_ok() - def test_label_user__with_special_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_label_user__with_special_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.label( - user_id, valid_label_properties()) + user_id, valid_label_properties() + ) + properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % urllib.parse.quote(user_id), - data=data, + f"https://api.sift.com/v203/users/{_q(user_id)}/labels", + data=json.dumps(properties), headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score__with_special_user_id_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_score__with_special_user_id_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response + response = self.sift_client.score(user_id) + mock_get.assert_called_with( - 'https://api.siftscience.com/v203/score/%s' % urllib.parse.quote(user_id), - params={'api_key': self.test_key}, + f"https://api.sift.com/v203/score/{_q(user_id)}", + params={}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.55 - def test_exception_during_track_call(self): + def test_exception_during_track_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) self.assertRaises( - sift.client.ApiException, self.sift_client.track, - '$transaction', valid_transaction_properties()) + sift.client.ApiException, + self.sift_client.track, + "$transaction", + valid_transaction_properties(), + ) - def test_exception_during_score_call(self): + def test_exception_during_score_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) self.assertRaises( - sift.client.ApiException, self.sift_client.score, 'Fred') + sift.client.ApiException, self.sift_client.score, "Fred" + ) - def test_exception_during_unlabel_call(self): + def test_exception_during_unlabel_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) self.assertRaises( - sift.client.ApiException, self.sift_client.unlabel, 'Fred') + sift.client.ApiException, self.sift_client.unlabel, "Fred" + ) - def test_return_actions_on_track(self): - event = '$transaction' + def test_return_actions_on_track(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % action_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {action_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( - event, valid_transaction_properties(), return_action=True) + event, valid_transaction_properties(), return_action=True + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_action': 'true'}) + params={"return_action": "true"}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) - actions = response.body["score_response"]['actions'] - assert(actions) - assert(actions[0]['action']) - assert(actions[0]['action']['id'] == 'freds_action') - assert(actions[0]['triggers']) + actions = response.body["score_response"]["actions"] + assert actions + assert actions[0]["action"] + assert actions[0]["action"]["id"] == "freds_action" + assert actions[0]["triggers"] -def main(): +def main() -> None: unittest.main() -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/tests/test_verification_apis.py b/tests/test_verification_apis.py new file mode 100644 index 0000000..ad799b6 --- /dev/null +++ b/tests/test_verification_apis.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +import typing as t +from unittest import TestCase, mock + +import sift + + +def valid_verification_send_properties() -> dict[str, t.Any]: + return { + "$user_id": "billy_jones_301", + "$send_to": "billy_jones_301@gmail.com", + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$site_country": "IN", + "$event": { + "$session_id": "SOME_SESSION_ID", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" + }, + }, + } + + +def valid_verification_resend_properties() -> dict[str, t.Any]: + return { + "$user_id": "billy_jones_301", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + } + + +def valid_verification_check_properties() -> dict[str, t.Any]: + return { + "$user_id": "billy_jones_301", + "$code": "123456", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + } + + +def response_with_data_header() -> dict[str, t.Any]: + return { + "content-length": 1, + "content-type": "application/json; charset=UTF-8", + } + + +class TestVerificationAPI(TestCase): + def setUp(self) -> None: + self.test_key = "a_fake_test_api_key" + self.sift_client = sift.Client(self.test_key) + + def test_verification_send_ok(self) -> None: + mock_response = mock.Mock() + + send_response_json = """ + { + "status": 0, + "error_message": "OK", + "sent_at": 1689316615034, + "segment_id": "143", + "segment_name": "Verification Template", + "brand_name": "", + "site_country": "", + "content_language": "", + "http_status_code": 200 + } + """ + + mock_response.content = send_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.verification_send( + valid_verification_send_properties() + ) + data = json.dumps(valid_verification_send_properties()) + mock_post.assert_called_with( + "https://api.sift.com/v1/verification/send", + auth=mock.ANY, + data=data, + headers=mock.ANY, + timeout=mock.ANY, + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + + def test_verification_resend_ok(self) -> None: + mock_response = mock.Mock() + + resend_response_json = """ + { + "status": 0, + "error_message": "OK", + "sent_at": 1689316615034, + "segment_id": "143", + "segment_name": "Verification Template", + "brand_name": "", + "site_country": "", + "content_language": "", + "http_status_code": 200 + } + """ + + mock_response.content = resend_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.verification_resend( + valid_verification_resend_properties() + ) + data = json.dumps(valid_verification_resend_properties()) + mock_post.assert_called_with( + "https://api.sift.com/v1/verification/resend", + auth=mock.ANY, + data=data, + headers=mock.ANY, + timeout=mock.ANY, + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + + def test_verification_check_ok(self) -> None: + mock_response = mock.Mock() + + check_response_json = """ + { + "status": 0, + "error_message": "OK", + "checked_at": 1689316615034, + "http_status_code": 200 + } + """ + + mock_response.content = check_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.verification_check( + valid_verification_check_properties() + ) + data = json.dumps(valid_verification_check_properties()) + mock_post.assert_called_with( + "https://api.sift.com/v1/verification/check", + auth=mock.ANY, + data=data, + headers=mock.ANY, + timeout=mock.ANY, + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + + +def main() -> None: + main() + + +if __name__ == "__main__": + main()