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 0c4efd2..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: python -python: - - "2.6" - - "2.7" - - "3.4" -# command to install dependencies -install: - - pip install -e .[test] -# command to run tests -script: - - python tests/client_test.py diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..31c250b --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,179 @@ +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 + +5.0.0 2019-01-08 +================ +- Add connection pooling + +INCOMPATIBLE CHANGES INTRODUCED IN 5.0.0: + +- Removed support for Python 2.6 + +- Fix url encoding for all endpoints + + Previously, encoding user ids in URLs was inconsistent between endpoints, encoded for some + endpoints, unencoded for others. Additionally, when encoded in the URL path, forward slashes + weren't encoded. Callers with workarounds for this bug must remove these workarounds when + upgrading to 5.0.0. + +- Improved error handling + + Previously, illegal arguments passed to methods like `Client.track()` and failed calls resulting + from server-side errors both raised `ApiExceptions`. Illegal arguments validated in the client + now raise either `TypeErrors` or `ValueErrors`. Server-side errors still raise `ApiExceptions`, + and `ApiException` has been augmented with metadata for handling the error. + +4.3.0.0 2018-07-31 +================== +- Add support for rescore_user and get_user_score APIs + +4.2.0.0 2018-07-05 +================== +- Add new query parameter force_workflow_run + +4.1.0.0 2018-06-01 +================== + +- Add get session level decisions in Get Decisions APIs. + +4.0.1 2018-04-06 +================== + +- Updated documentation in CHANGES.md and README.md + +4.0.0.0 2018-03-30 +================== + +- Adds support for Sift Science API Version 205, including new [`$create_content`](https://siftscience.com/developers/docs/curl/events-api/reserved-events/create-content) and [`$update_content`](https://siftscience.com/developers/docs/curl/events-api/reserved-events/update-content) formats +- V205 APIs are now called -- **this is an incompatible change** + - Use `version = '204'` when constructing the Client to call the previous API version +- Adds support for content decisions to [Decisions API](https://siftscience.com/developers/docs/curl/decisions-api) + + +INCOMPATIBLE CHANGES INTRODUCED IN API V205: +- `$create_content` and `$update_content` have significantly changed, and the old format will be rejected +- `$send_message` and `$submit_review` events are no longer valid +- V205 improves server-side event data validation. In V204 and earlier, server-side validation accepted some events that did not conform to the published APIs in our [developer documentation](https://siftscience.com/developers/docs/curl/events-api). V205 does not modify existing event APIs other than those mentioned above, but may reject invalid event data that were previously accepted. **Please test your integration on V205 in sandbox before using in production.** + +3.2.0.0 2018-02-12 +================== + +- Add session level decisions in Apply Decisions APIs. +- Add support for filtering get decisions by entity type session. + +3.1.0.0 2017-01-17 +================== + +- Adds support for Get, Apply Decisions APIs + +3.0.0.0 2016-07-19 +================== + +- Adds support for v204 of Sift Science's APIs +- Adds Workflow Status API, User Decisions API, Order Decisions API +- V204 APIs are now called by default -- this is an incompatible change + (use version='203' to call the previous API version) + +2.0.1.0 (2016-07-07) +==================== + +- Fixes bug parsing chunked HTTP responses + +2.0.0.0 (2016-06-21) +==================== + +- Major version bump; client APIs have changed to raise exceptions + in the case of API errors to be more Pythonic + +1.1.2.1 (2015-05-18) +==================== + +- Added Python 2.6 compatibility +- Added Travis CI +- Minor bug fixes + +1.1.2.0 (2015-02-04) +==================== + +- Added Unlabel functionality +- Minor bug fixes. + +1.1.1.0 (2014-09-3) +=================== + +- Added timeout parameter to track, score, and label functions. + +1.1.0.0 (2014-08-25) +==================== + +- Added Module-scoped API key. +- Minor documentation updates. + +0.2.0 (2014-08-20) +================== + +- Added Label and Score functions. +- Added Python 3 compatibility. + +0.1.1 (2014-02-21) +================== + +- Bump default API version to v203. + +0.1.0 (2013-01-08) +================== + +- Just the Python REST client itself. diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index 7557231..0000000 --- a/CHANGES.rst +++ /dev/null @@ -1,32 +0,0 @@ -1.1.2.1 (2015-05-18) -==================== -* Added Python 2.6 compatibility -* Added Travis CI -* Minor bug fixes - -1.1.2.0 (2015-02-04) -==================== -* Added Unlabel functionaly -* Minor bug fixes. - -1.1.1.0 (2014-09-3) -=================== -* Added timeout parameter to track, score, and label functions. - -1.1.0.0 (2014-08-25) -==================== -* Added Module-scoped API key. -* Minor documentation updates. - -0.2.0 (2014-08-20) -================== -* Added Label and Score functions. -* Added Python 3 compatibility. - -0.1.1 (2014-02-21) -================== -* Bump default API version to v203. - -0.1.0 (2013-01-08) -================== -* Just the Python REST client itself. 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/LICENSE b/LICENSE index f389fa0..dea6a27 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2013-2014 Sift Science (https://siftscience.com) +Copyright (c) 2013-2016 Sift Science (https://siftscience.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bc94c5 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Sift Python Bindings + +Bindings for Sift's APIs -- including the +[Events](https://developers.sift.com/docs/python/events-api/, +[Labels](https://developers.sift.com/docs/python/labels-api/), +and +[Score](https://developers.sift.com/docs/python/score-api/) +APIs. + +## Installation + +```sh +# install from PyPi +pip install Sift +``` + +## Documentation + +Please see [here](https://developers.sift.com/docs/python/apis-overview) for the +most up-to-date documentation. + +## Changelog + +Please see +[the CHANGELOG](https://github.com/SiftScience/sift-python/blob/master/CHANGES.md) +for a history of all changes. + +## Usage + +Here's an example: + +```python + +import sift + +client = sift.Client(api_key='', account_id='') + +# User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ +user_id = "23056" + +# Track a transaction event -- note this is a blocking call +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, + ) +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(): + 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 + +# Request a score for the user with user_id 23056 +try: + response = client.score(user_id) +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 + response = client.label(user_id, { + "$is_bad": True, + "$abuse_type": "payment_abuse", + "$description": "Chargeback issued", + "$source": "Manual Review", + "$analyst": "analyst.name@your_domain.com" + }) +except sift.client.ApiException: + # request failed + pass + +# Remove a label from a user with user_id 23056 +try: + response = client.unlabel(user_id, abuse_type='content_abuse') +except sift.client.ApiException: + # request failed + pass + +# Get the status of a workflow run +try: + response = client.get_workflow_status('my_run_id') +except sift.client.ApiException: + # request failed + pass + +# Get the latest decisions for a user +try: + response = client.get_user_decisions('example_user') +except sift.client.ApiException: + # request failed + pass + +# Get the latest decisions for an order +try: + response = client.get_order_decisions('example_order') +except sift.client.ApiException: + # request failed + pass + +# Get the latest decisions for a session +try: + response = client.get_session_decisions('example_user', 'example_session') +except sift.client.ApiException: + # request failed + pass + +# Get the latest decisions for a piece of content +try: + response = client.get_content_decisions('example_user', 'example_content') +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" + } + } +} + +try: + response = client.verification_send(send_properties) +except sift.client.ApiException: + # request failed + pass + +# 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 + +# 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/README.rst b/README.rst deleted file mode 100644 index f96ba91..0000000 --- a/README.rst +++ /dev/null @@ -1,104 +0,0 @@ -============================ -Sift Science Python Bindings |TravisCI|_ -============================ - -.. |TravisCI| image:: https://travis-ci.org/SiftScience/sift-python.png?branch=master -.. _TravisCI: https://travis-ci.org/SiftScience/sift-python - -Bindings for Sift Science's `Events `_, `Labels `_, and `Score `_ 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 - -Usage -===== - -Here's an example: - -:: - - import sift.client - - sift.api_key = '' - client = sift.Client() - - user_id= "23056" # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ - - # Track a transaction event -- note this is blocking - event = "$transaction" - - 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, - } - - response = client.track(event, properties) - - - response.is_ok() # returns True of False - - print response # prints entire response body and http status code - - - # Request a score for the user with user_id 23056 - response = client.score(user_id) - - # Label the user with user_id 23056 as Bad with all optional fields - response = client.label(user_id,{ "$is_bad" : True, "$reasons" : ["$chargeback", ], - "$description" : "Chargeback issued", - "$source" : "Manual Review", - "$analyst" : "analyst.name@your_domain.com"}) - - # Remove a label from a user with user_id 23056 - response = client.unlabel(user_id) - -Testing -======= - -Before submitting a change, make sure the following commands run without errors from the root dir of the repository: - -:: - - PYTHONPATH=. python tests/client_test.py - PYTHONPATH=. python3 tests/client_test.py 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 991554a..0000000 --- a/setup.py +++ /dev/null @@ -1,49 +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', - - 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', - ], - }, - - classifiers=[ - "Programming Language :: Python", - "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 b14df89..da4b03f 100644 --- a/sift/__init__.py +++ b/sift/__init__.py @@ -1,2 +1,11 @@ -api_key = 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 304397f..b421ce4 100644 --- a/sift/client.py +++ b/sift/client.py @@ -1,289 +1,1750 @@ """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 traceback -import warnings import sys -if sys.version_info[0] < 3: - import urllib -else: - import urllib.parse as urllib +import typing as t +from collections.abc import Mapping, Sequence + +import requests +from requests.auth import HTTPBasicAuth import sift -from . import 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 + + +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" + + 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) + + +def _assert_non_empty_dict(val: object, name: str) -> None: + error = f"{name} must be a non-empty mapping (dict)" + + if not isinstance(val, Mapping): + raise TypeError(error) + + if not val: + raise ValueError(error) + + +class Response: + HTTP_CODES_WITHOUT_BODY = (204, 304) + + 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 "" + ) -API_URL = 'https://api.siftscience.com' + 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(object): - def __init__(self, api_key=None, api_url=API_URL, timeout=2.0): +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: Your Sift Science API key associated with your customer - account. You can obtain this from - https://siftscience.com/quickstart - api_url: The URL to send events to. - timeout: Number of seconds to wait before failing request. Defaults - to 2 seconds. + 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 """ - if not isinstance(api_url, str) or len(api_url.strip()) == 0: - raise RuntimeError("api_url must be a string") + _assert_non_empty_str(api_url, "api_url") if api_key is None: api_key = sift.api_key - if not isinstance(api_key, str) or len(api_key.strip()) == 0: - raise RuntimeError("valid api_key is required") + _assert_non_empty_str(api_key, "api_key") - self.api_key = api_key - self.url = api_url + '/v%s' % version.API_VERSION + self.session = session or requests.Session() + self.api_key = t.cast(str, api_key) + self.url = api_url self.timeout = timeout - if sys.version_info[0] < 3: - self.UNICODE_STRING = basestring - else: - self.UNICODE_STRING = str + self.account_id = t.cast(str, account_id or sift.account_id) + self.version = version + + @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)}" + ) + + def _verification_send_url(self) -> str: + return self._v1_api("/verification/send") + + def _verification_resend_url(self) -> str: + return self._v1_api("/verification/resend") + + def _verification_check_url(self) -> str: + return self._v1_api("/verification/check") + + def _validate_send_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) + + send_to = properties.get("$send_to") + _assert_non_empty_str(send_to, "send_to", error_cls=ValueError) + + verification_type = properties.get("$verification_type") + _assert_non_empty_str( + verification_type, "verification_type", error_cls=ValueError + ) + + 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") + + session_id = event.get("$session_id") + _assert_non_empty_str(session_id, "session_id", error_cls=ValueError) - def user_agent(self): - return 'SiftScience/v%s sift-python/%s' % ( - version.API_VERSION, version.VERSION) + def _validate_resend_request( + self, + properties: Mapping[str, t.Any], + ) -> None: + """This method is used to validate arguments passed to the send method.""" - def event_url(self): - return self.url + '/events' + _assert_non_empty_dict(properties, "properties") - def score_url(self, user_id): - return self.url + '/score/%s' % urllib.quote(user_id) + user_id = properties.get("$user_id") + _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) - def label_url(self, user_id): - return self.url + '/users/%s/labels' % urllib.quote(user_id) + 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, - properties, - path=None, - return_score=False, - return_action=False, - timeout=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. + 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: + """ + Track an event and associated properties to the Sift Science client. + + 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 $). + 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 - properties: A dict of additional event-specific attributes to track + return_workflow_status (optional): + Whether the API response should include the status of any + workflow run as a result of the tracked event. - return_score: Whether the API response should include a score for this - user (the score will be calculated using this event). This feature must be - enabled for your account in order to use it. Please contact - support@siftscience.com if you are interested in using this feature. + 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. - 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 + 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 requests.Response object if the track call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. - """ - if not isinstance( - event, self.UNICODE_STRING) or len( - event.strip()) == 0: - raise RuntimeError("event must be a string") + A sift.client.Response object if the call to the Sift API is successful - if not isinstance(properties, dict) or len(properties) == 0: - raise RuntimeError("properties dictionary may not be empty") + Raises: + ApiException: If the call to the Sift API is not successful + """ + _assert_non_empty_str(event, "event") + _assert_non_empty_dict(properties, "properties") - headers = {'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self.user_agent()} + if version is None: + version = self.version if path is None: - path = self.event_url() + 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.update({'return_score': return_score}) + params["return_score"] = "true" if return_action: - params.update({'return_action': return_action}) + params["return_action"] = "true" + + if abuse_types: + params["abuse_types"] = ",".join(abuse_types) + + if return_workflow_status: + params["return_workflow_status"] = "true" + + if return_route_info: + params["return_route_info"] = "true" + + if force_workflow_run: + 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 = requests.post( + 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, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), path) + + 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. + + 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. + + 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 call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + _assert_non_empty_str(user_id, "user_id") + + if timeout is None: + timeout = self.timeout + + if version is None: + version = self.version + + params: dict[str, t.Any] = {} + + if 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, + params=params, + auth=self._auth, + headers=self._default_headers(version), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + 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. + + 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 + 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. + + 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. + + 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 call to the Sift API is successful + + Raises: + ApiException: If the call to the Sift API is not successful + """ + _assert_non_empty_str(user_id, "user_id") + + if timeout is None: + timeout = self.timeout + + url = self._user_score_url(user_id, self.version) + params: dict[str, t.Any] = {} + + if abuse_types: + params["abuse_types"] = ",".join(abuse_types) + + if include_score_percentiles: + params["fields"] = "SCORE_PERCENTILES" + + try: + response = self.session.get( + url, + params=params, + auth=self._auth, + headers=self._default_headers(), timeout=timeout, - params=params) - return Response(response) + ) except requests.exceptions.RequestException as e: - warnings.warn('Failed to track event: %s' % properties) - warnings.warn(traceback.format_exc()) - return e + raise ApiException(str(e), url) + + return Response(response) - def score(self, user_id, timeout=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 + 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): + 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. + Returns: - A requests.Response object if the score call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. + 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 not isinstance( - user_id, self.UNICODE_STRING) or len( - user_id.strip()) == 0: - raise RuntimeError("user_id must be a string") + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout - headers = {'User-Agent': self.user_agent()} - params = {'api_key': self.api_key} + url = self._user_score_url(user_id, self.version) + params: dict[str, t.Any] = {} + + if abuse_types: + params["abuse_types"] = ",".join(abuse_types) try: - response = requests.get( - self.score_url(user_id), - headers=headers, + response = self.session.post( + url, + params=params, + auth=self._auth, + headers=self._default_headers(), timeout=timeout, - params=params) - return Response(response) + ) except requests.exceptions.RequestException as e: - warnings.warn('Failed to get score for user %s' % user_id) - warnings.warn(traceback.format_exc()) - return e + raise ApiException(str(e), url) + + 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. - def label(self, user_id, properties, timeout=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. + 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 - timeout(optional): specify a custom timeout for this call + + properties: + A mapping of additional event-specific attributes to track. + + 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 requests.Response object if the label call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. + 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 not isinstance( - user_id, self.UNICODE_STRING) or len( - user_id.strip()) == 0: - raise RuntimeError("user_id must be a string") + _assert_non_empty_str(user_id, "user_id") + + if version is None: + version = self.version return self.track( - '$label', + "$label", properties, - self.label_url(user_id), - timeout=timeout) + path=self._labels_url(user_id, version), + timeout=timeout, + 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. - def unlabel(self, user_id, timeout=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. + This call is blocking. + + 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): specify a custom timeout 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. + If omitted, the user is unlabeled for all abuse types. + + version (optional): + Use a different version of the Sift Science API for this call. + Returns: - A requests.Response object if the unlabel call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. + 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 not isinstance( - user_id, self.UNICODE_STRING) or len( - user_id.strip()) == 0: - raise RuntimeError("user_id must be a string") + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout - headers = {'User-Agent': self.user_agent()} - params = {'api_key': self.api_key} + if version is None: + version = self.version + + url = self._labels_url(user_id, version) + params: dict[str, t.Any] = {} + + if abuse_type: + params["abuse_type"] = abuse_type + + try: + response = self.session.delete( + url, + params=params, + auth=self._auth, + headers=self._default_headers(version), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + 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 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 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(run_id, "run_id") + + url = self._workflow_status_url(self.account_id, run_id) + + if timeout is None: + timeout = self.timeout 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 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: + 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 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(entity_type, "entity_type") - response = requests.delete( - self.label_url(user_id), - headers=headers, + 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: dict[str, t.Any] = { + "entity_type": entity_type, + } + + if limit: + params["limit"] = limit + + if start_from: + params["from"] = start_from + + if abuse_types: + params["abuse_types"] = ",".join(abuse_types) + + if timeout is None: + timeout = self.timeout + + url = self._decisions_url(self.account_id) + + try: + response = self.session.get( + url, + 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) + + 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 a user + properties: + 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 + + 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 + + url = self._user_decisions_url(self.account_id, 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: - warnings.warn('Failed to unlabel user %s' % user_id) - warnings.warn(traceback.format_exc()) - return e + raise ApiException(str(e), url) + return Response(response) -class Response(object): + 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 - HTTP_CODES_WITHOUT_BODY = [204, 304] + Args: + user_id: + ID of a user. - def __init__(self, http_response): + order_id: + The ID for the order. - # 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 + 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) - if (self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY) \ - and 'content-length' in http_response.headers: - try: - self.body = http_response.json() - self.api_status = self.body['status'] - self.api_error_message = self.body['error_message'] - if 'request' in self.body.keys() \ - and isinstance(self.body['request'], str): - self.request = json.loads(self.body['request']) - else: - self.request = None - except ValueError as e: - not_json_warning = "Failed to parse json response from {}. HTTP status code: {}.".format(self.url, self.http_status_code) - warnings.warn(not_json_warning) - finally: - if (int(self.http_status_code) < 200 or int(self.http_status_code) >= 300): - non_2xx_warning = "{} returned non-2XX http status code {}".format(self.url, self.http_status_code) - if self.api_error_message: - non_2xx_warning += " with error message: {}".format(self.api_error_message) - raise ApiException(non_2xx_warning) + 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(order_id, "order_id") + + self._validate_apply_decision_request(properties, user_id) - 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))) + if timeout is None: + timeout = self.timeout + + url = self._order_apply_decisions_url( + self.account_id, user_id, order_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) - 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 get_user_decisions( + self, + user_id: str, + timeout: float | tuple[float, float] | None = None, + ) -> Response: + """Gets the decisions for a user. - return self.api_status == 0 + Args: + 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 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") + + if timeout is None: + timeout = self.timeout + + url = self._user_decisions_url(self.account_id, user_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 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 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 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(order_id, "order_id") + + if timeout is None: + timeout = self.timeout + + url = self._order_decisions_url(self.account_id, order_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 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 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 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(content_id, "content_id") + _assert_non_empty_str(user_id, "user_id") + + if timeout is None: + timeout = self.timeout + + url = self._content_decisions_url(self.account_id, user_id, content_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 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 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 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(session_id, "session_id") + + if timeout is None: + timeout = self.timeout + + url = self._session_decisions_url(self.account_id, user_id, session_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 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: + 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) + + 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(session_id, "session_id") + + self._validate_apply_decision_request(properties, user_id) + + if timeout is None: + timeout = self.timeout + + url = self._session_decisions_url(self.account_id, user_id, session_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 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: + 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) + + 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 + + url = self._content_decisions_url(self.account_id, user_id, content_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. + + 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: + 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 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) + + 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). + + Args: + batch_token (optional): + Batch or page position of the paginated sequence. + + batch_size: (optional): + Batch or page size of the paginated sequence. + + 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_url(self.account_id) + + params: dict[str, t.Any] = {} + + if batch_size: + params["batch_size"] = batch_size + + if batch_token: + params["batch_token"] = batch_token + + 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) + + return Response(response) + + 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. + + 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.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: + """ + The send call triggers the generation of an OTP code that is stored + by Sift and email/sms the code to the user. + + 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. + + 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) + + return Response(response) + + 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. + + This call is blocking. + + Visit https://developers.sift.com/docs/python/verification-api/resend + for more information on our send response structure. + + Args: + + 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. + + 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_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. + + 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) -class ApiException(Exception): - def __init__(self, *args, **kwargs): - Exception.__init__(self, *args, **kwargs) + 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 dd17efd..ad368aa 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '1.1.2.6' -API_VERSION = '203' +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/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/client_test.py b/tests/client_test.py deleted file mode 100644 index 42b3968..0000000 --- a/tests/client_test.py +++ /dev/null @@ -1,474 +0,0 @@ -import datetime -import warnings -import json -import mock -import sift -import unittest -import sys -import requests.exceptions -if sys.version_info[0] < 3: - import urllib -else: - import urllib.parse as urllib - - -def valid_transaction_properties(): - 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' - } - - -def valid_label_properties(): - return { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - } - - -def score_response_json(): - return """{ - "status": 0, - "error_message": "OK", - "user_id": "12345", - "score": 0.55 - }""" - - -def action_response_json(): - return """{ - "actions": [ - { - "action": { - "id": "freds_action" - }, - "entity": { - "id": "Fred" - }, - "id": "ACTION1234567890:freds_action", - "triggers": [ - { - "source": "synchronous_action", - "trigger": { - "id": "TRIGGER1234567890" - }, - "type": "formula" - } - ] - } - ], - "score": 0.55, - "status": 0, - "error_message": "OK", - "user_id": "Fred" - }""" - - -def response_with_data_header(): - return { - '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) - - def test_global_api_key(self): - # test for error if global key is undefined - self.assertRaises(RuntimeError, sift.Client) - sift.api_key = "a_test_global_api_key" - local_api_key = "a_test_local_api_key" - - client1 = sift.Client() - client2 = sift.Client(local_api_key) - - # test that global api key is assigned - assert(client1.api_key == sift.api_key) - # test that local api key is assigned - 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) - - def test_constructor_requires_valid_api_key(self): - self.assertRaises(RuntimeError, sift.Client, None) - self.assertRaises(RuntimeError, sift.Client, '') - - def test_constructor_invalid_api_url(self): - self.assertRaises(RuntimeError, sift.Client, self.test_key, None) - self.assertRaises(RuntimeError, sift.Client, self.test_key, '') - - def test_constructor_api_key(self): - client = sift.Client(self.test_key) - self.assertEqual(client.api_key, self.test_key) - - def test_track_requires_valid_event(self): - self.assertRaises(RuntimeError, self.sift_client.track, None, {}) - self.assertRaises(RuntimeError, self.sift_client.track, '', {}) - self.assertRaises(RuntimeError, self.sift_client.track, 42, {}) - - def test_track_requires_properties(self): - event = 'custom_event' - self.assertRaises(RuntimeError, self.sift_client.track, event, None) - self.assertRaises(RuntimeError, self.sift_client.track, event, 42) - self.assertRaises(RuntimeError, self.sift_client.track, event, {}) - - def test_score_requires_user_id(self): - self.assertRaises(RuntimeError, self.sift_client.score, None) - self.assertRaises(RuntimeError, self.sift_client.score, '') - self.assertRaises(RuntimeError, self.sift_client.score, 42) - - def test_event_ok(self): - 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('requests.post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.track( - event, valid_transaction_properties()) - mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', - data=mock.ANY, - headers=mock.ANY, - timeout=mock.ANY, - params={}) - 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' - 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('requests.post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.track( - event, valid_transaction_properties(), timeout=test_timeout) - mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', - data=mock.ANY, - headers=mock.ANY, - timeout=test_timeout, - params={}) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - - def test_score_ok(self): - 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('requests.get') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.score('12345') - mock_post.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', - params={ - 'api_key': self.test_key}, - headers=mock.ANY, - timeout=mock.ANY) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) - - def test_score_with_timeout_param_ok(self): - 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('requests.get') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.score('12345', test_timeout) - mock_post.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', - params={ - 'api_key': self.test_key}, - headers=mock.ANY, - timeout=test_timeout) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) - - def test_sync_score_ok(self): - event = '$transaction' - mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK", "score_response": %s}' % 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('requests.post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.track( - event, valid_transaction_properties(), return_score=True) - mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', - data=mock.ANY, - headers=mock.ANY, - timeout=mock.ANY, - params={ - 'return_score': True}) - 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' - 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('requests.post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.label( - user_id, valid_label_properties()) - properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"]} - 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={}) - 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' - 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('requests.post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.label( - user_id, valid_label_properties(), test_timeout) - properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"]} - 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={}) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - - def test_unlabel_user_ok(self): - - user_id = '54321' - mock_response = mock.Mock() - mock_response.status_code = 204 - with mock.patch('requests.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, headers=mock.ANY, timeout=mock.ANY, params={ - 'api_key': self.test_key}) - 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 = u'23056' - - with mock.patch('requests.post') as mock_post: - mock_post.return_value = mock_response - assert( - self.sift_client.track( - u'$transaction', - valid_transaction_properties())) - assert( - self.sift_client.label( - user_id, - valid_label_properties())) - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response - assert(self.sift_client.score(user_id)) - - def test_unlabel_user_with_special_chars_ok(self): - - user_id = "54321=.-_+@:&^%!$" - mock_response = mock.Mock() - mock_response.status_code = 204 - with mock.patch('requests.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' % - urllib.quote(user_id), headers=mock.ANY, timeout=mock.ANY, params={ - 'api_key': self.test_key}) - assert(response.is_ok()) - - def test_label_user__with_special_chars_ok(self): - 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('requests.post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.label( - user_id, valid_label_properties()) - properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"]} - 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.quote(user_id), - data=data, - headers=mock.ANY, - timeout=mock.ANY, - params={}) - 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=.-_+@:&^%!$' - 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('requests.get') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.score(user_id) - mock_post.assert_called_with( - 'https://api.siftscience.com/v203/score/%s' % - urllib.quote(user_id), - params={ - 'api_key': self.test_key}, - headers=mock.ANY, - timeout=mock.ANY) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) - - def test_exception_during_track_call(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - with mock.patch('requests.post') as mock_post: - mock_post.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) - response = self.sift_client.track( - '$transaction', valid_transaction_properties()) - assert(len(w) == 2) - assert('Failed to track event:' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) - - def test_exception_during_score_call(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - with mock.patch('requests.get') as mock_get: - mock_get.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) - response = self.sift_client.score('Fred') - assert(len(w) == 2) - assert('Failed to get score for user Fred' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) - - def test_exception_during_unlabel_call(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - with mock.patch('requests.delete') as mock_delete: - mock_delete.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) - response = self.sift_client.unlabel('Fred') - - assert(len(w) == 2) - assert('Failed to unlabel user Fred' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) - - def test_return_actions_on_track(self): - event = '$transaction' - mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK", "score_response": %s}' % 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('requests.post') as mock_post: - mock_post.return_value = mock_response - - response = self.sift_client.track( - event, valid_transaction_properties(), return_action=True) - mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', - data=mock.ANY, - headers=mock.ANY, - timeout=mock.ANY, - params={ - 'return_action': True}) - - 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 main(): - unittest.main() - -if __name__ == '__main__': - main() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..b69abee --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1848 @@ +from __future__ import annotations + +import datetime +import json +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 +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_label_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", + } + + +def valid_psp_merchant_properties() -> dict[str, t.Any]: + return { + "$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 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", + "user_id": "12345", + "score": 0.85, + "latest_label": { + "is_bad": true, + "time": 1450201660000 + }, + "scores": { + "content_abuse": { + "score": 0.14 + }, + "payment_abuse": { + "score": 0.97 + } + }, + "latest_labels": { + "promotion_abuse": { + "is_bad": false, + "time": 1457201099000 + }, + "payment_abuse": { + "is_bad": true, + "time": 1457212345000 + } + } + }""" + + +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, + "error_message": "OK", + "entity_type": "user", + "entity_id": "12345", + "scores": { + "content_abuse": { + "score": 0.14 + }, + "payment_abuse": { + "score": 0.97 + } + }, + "latest_decisions": { + "payment_abuse": { + "id": "user_looks_bad_payment_abuse", + "category": "block", + "source": "AUTOMATED_RULE", + "time": 1352201880, + "description": "Bad Fraudster" + } + }, + "latest_labels": { + "promotion_abuse": { + "is_bad": false, + "time": 1457201099000 + }, + "payment_abuse": { + "is_bad": true, + "time": 1457212345000 + } + } +}""" + + +def action_response_json() -> str: + return """{ + "actions": [ + { + "action": { + "id": "freds_action" + }, + "entity": { + "id": "Fred" + }, + "id": "ACTION1234567890:freds_action", + "triggers": [ + { + "source": "synchronous_action", + "trigger": { + "id": "TRIGGER1234567890" + }, + "type": "formula" + } + ] + } + ], + "score": 0.85, + "status": 0, + "error_message": "OK", + "user_id": "Fred", + "scores": { + "content_abuse": { + "score": 0.14 + }, + "payment_abuse": { + "score": 0.97 + } + }, + "latest_labels": { + "promotion_abuse": { + "is_bad": false, + "time": 1457201099000 + }, + "payment_abuse": { + "is_bad": true, + "time": 1457212345000 + } + } + }""" + + +def response_with_data_header() -> dict[str, t.Any]: + return {"content-type": "application/json; charset=UTF-8"} + + +class TestSiftPythonClient(TestCase): + + 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) -> None: + # test for error if global key is undefined + 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" + + client1 = sift.Client() + client2 = sift.Client(local_api_key) + + # test that global api key is assigned + assert client1.api_key == sift.api_key + # test that local api key is assigned + 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 + + 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) -> None: + self.assertRaises(TypeError, sift.Client, self.test_key, None) + self.assertRaises(ValueError, sift.Client, self.test_key, "") + + 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) -> None: + self.assertRaises(TypeError, self.sift_client.track, None, {}) + self.assertRaises(ValueError, self.sift_client.track, "", {}) + self.assertRaises(TypeError, self.sift_client.track, 42, {}) + + 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) -> None: + self.assertRaises(TypeError, self.sift_client.score, None) + self.assertRaises(ValueError, self.sift_client.score, "") + self.assertRaises(TypeError, self.sift_client.score, 42) + + 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: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, valid_transaction_properties() + ) + + 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_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: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, valid_transaction_properties(), timeout=test_timeout + ) + + mock_post.assert_called_with( + "https://api.sift.com/v205/events", + data=mock.ANY, + 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" + + 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: + mock_get.return_value = mock_response + + response = self.sift_client.score("12345") + + mock_get.assert_called_with( + "https://api.sift.com/v205/score/12345", + params={}, + 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_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: + mock_get.return_value = mock_response + + response = self.sift_client.score("12345", test_timeout) + + mock_get.assert_called_with( + "https://api.sift.com/v205/score/12345", + params={}, + 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["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() + 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("12345", test_timeout) + + mock_get.assert_called_with( + "https://api.sift.com/v205/users/12345/score", + params={}, + 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_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() + 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( + "12345", + abuse_types=["payment_abuse", "content_abuse"], + timeout=test_timeout, + ) + + mock_get.assert_called_with( + "https://api.sift.com/v205/users/12345/score", + params={"abuse_types": "payment_abuse,content_abuse"}, + 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_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() + 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, "post") as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.rescore_user("12345", test_timeout) + + mock_post.assert_called_with( + "https://api.sift.com/v205/users/12345/score", + params={}, + 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_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() + 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, "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, + ) + + mock_post.assert_called_with( + "https://api.sift.com/v205/users/12345/score", + params={"abuse_types": "payment_abuse,content_abuse"}, + 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_sync_score_ok(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + 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: + 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"], + ) + + 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.sift.com/v205/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + 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 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( + t.cast(t.Literal["user", "order", "session", "content"], "usr") + ) + + def test_get_decisions(self) -> None: + mock_response = mock.Mock() + + get_decisions_response_json = """ + { + "data": [ + { + "id": "block_user", + "name": "Block user", + "description": "user has a different billing and shipping addresses", + "entity_type": "user", + "abuse_type": "legacy", + "category": "block", + "webhook_url": "http://web.hook", + "created_at": "1468005577348", + "created_by": "admin@biz.com", + "updated_at": "1469229177756", + "updated_by": "analyst@biz.com" + } + ], + "has_more": "true", + "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: + 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, + ) + + mock_get.assert_called_with( + "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, + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + assert isinstance(response.body, dict) + assert response.body["data"][0]["id"] == "block_user" + + def test_get_decisions_entity_session(self) -> None: + mock_response = mock.Mock() + get_decisions_response_json = """ + { + "data": [ + { + "id": "block_session", + "name": "Block session", + "description": "session has problems", + "entity_type": "session", + "abuse_type": "legacy", + "category": "block", + "webhook_url": "http://web.hook", + "created_at": "1468005577348", + "created_by": "admin@biz.com", + "updated_at": "1469229177756", + "updated_by": "analyst@biz.com" + } + ], + "has_more": "true", + "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: + 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, + ) + + mock_get.assert_called_with( + "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, + ) + self.assertIsInstance(response, sift.client.Response) + 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, + } + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "user" + }, + "decision": { + "id": "user_looks_ok_legacy" + }, + "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: + mock_post.return_value = mock_response + + 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.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) -> None: + apply_decision_request = { + "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, t.cast(str, 123) + ) + + 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", t.cast(str, None), {} + ) + + 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", t.cast(str, None), {} + ) + + def test_get_session_decisions_fails_with_no_session_id(self) -> None: + with self.assertRaises(TypeError): + self.sift_client.get_session_decisions( + "user_id", t.cast(str, None) + ) + + 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", t.cast(str, None), {} + ) + + 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, + } + + with self.assertRaises(ValueError): + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) + + def test_validate_apply_decision_request_no_source_fails(self) -> None: + apply_decision_request = { + "decision_id": "user_looks_ok_legacy", + "time": 1481569575, + } + + with self.assertRaises(ValueError): + 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] = {} + + with self.assertRaises(ValueError): + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) + + 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, + } + + with self.assertRaises(ValueError): + self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) + + def test_apply_decision_no_source_fails(self) -> None: + user_id = "54321" + apply_decision_request = { + "decision_id": "user_looks_ok_legacy", + "time": 1481569575, + } + + with self.assertRaises(ValueError): + self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) + + 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, + } + + self.assertRaises( + ValueError, + self.sift_client.apply_user_decision, + user_id, + apply_decision_request, + ) + + 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, + } + + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "order" + }, + "decision": { + "id": "order_looks_bad_payment_abuse" + }, + "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: + mock_post.return_value = mock_response + + response = self.sift_client.apply_order_decision( + user_id, order_id, apply_decision_request + ) + + mock_post.assert_called_with( + 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 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, + } + + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "login" + }, + "decision": { + "id": "session_looks_bad_ato" + }, + "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: + mock_post.return_value = mock_response + + response = self.sift_client.apply_session_decision( + user_id, session_id, apply_decision_request + ) + + mock_post.assert_called_with( + 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 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, + } + + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "create_content" + }, + "decision": { + "id": "content_looks_bad_content_abuse" + }, + "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: + mock_post.return_value = mock_response + + response = self.sift_client.apply_content_decision( + user_id, content_id, apply_decision_request + ) + + mock_post.assert_called_with( + 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 isinstance(response.body, dict) + assert response.body["entity"]["type"] == "create_content" + + 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: + mock_post.return_value = mock_response + + 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", + "$api_key": self.test_key, + "$type": "$label", + } + + mock_post.assert_called_with( + 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" + + 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: + mock_post.return_value = mock_response + + response = self.sift_client.label( + 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", + "$api_key": self.test_key, + "$type": "$label", + } + + mock_post.assert_called_with( + 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" + + 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: + mock_delete.return_value = mock_response + + response = self.sift_client.unlabel( + user_id, abuse_type="account_abuse" + ) + + mock_delete.assert_called_with( + f"https://api.sift.com/v205/users/{user_id}/labels", + headers=mock.ANY, + timeout=mock.ANY, + params={"abuse_type": "account_abuse"}, + auth=HTTPBasicAuth(self.test_key, ""), + ) + self.assertIsInstance(response, sift.client.Response) + 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: + mock_delete.return_value = mock_response + + response = self.sift_client.unlabel(user_id) + + mock_delete.assert_called_with( + f"https://api.sift.com/v205/users/{_q(user_id)}/labels", + headers=mock.ANY, + timeout=mock.ANY, + params={}, + auth=HTTPBasicAuth(self.test_key, ""), + ) + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + + 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: + mock_post.return_value = mock_response + + 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", + "$api_key": self.test_key, + "$type": "$label", + } + + mock_post.assert_called_with( + f"https://api.sift.com/v205/users/{_q(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" + + 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: + mock_get.return_value = mock_response + + response = self.sift_client.score(user_id, abuse_types=["legacy"]) + + mock_get.assert_called_with( + f"https://api.sift.com/v205/score/{_q(user_id)}", + params={"abuse_types": "legacy"}, + 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_exception_during_track_call(self) -> None: + warnings.simplefilter("always") + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.side_effect = mock.Mock( + side_effect=RequestException("Failed") + ) + + with self.assertRaises(sift.client.ApiException): + self.sift_client.track( + "$transaction", valid_transaction_properties() + ) + + def test_exception_during_score_call(self) -> None: + warnings.simplefilter("always") + + with mock.patch.object(self.sift_client.session, "get") as mock_get: + mock_get.side_effect = mock.Mock( + side_effect=RequestException("Failed") + ) + + with self.assertRaises(sift.client.ApiException): + self.sift_client.score("Fred") + + def test_exception_during_unlabel_call(self) -> None: + warnings.simplefilter("always") + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: + mock_delete.side_effect = mock.Mock( + side_effect=RequestException("Failed") + ) + + with self.assertRaises(sift.client.ApiException): + self.sift_client.unlabel("Fred") + + def test_return_actions_on_track(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + 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: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, valid_transaction_properties(), return_action=True + ) + + mock_post.assert_called_with( + "https://api.sift.com/v205/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + 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 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 = """ + { + "id": "4zxwibludiaaa", + "config": { + "id": "5rrbr4iaaa", + "version": "1468367620871" + }, + "config_display_name": "workflow config", + "abuse_types": [ + "payment_abuse" + ], + "state": "running", + "entity": { + "id": "example_user", + "type": "user" + }, + "history": [ + { + "app": "decision", + "name": "decision", + "state": "running", + "config": { + "decision_id": "user_decision" + } + }, + { + "app": "event", + "name": "Event", + "state": "finished", + "config": {} + }, + { + "app": "user", + "name": "Entity", + "state": "finished", + "config": {} + } + ] + } + """ + 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_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 isinstance(response.body, dict) + assert response.body["state"] == "running" + + def test_get_user_decisions(self) -> None: + mock_response = mock.Mock() + mock_response.content = """ + { + "decisions": { + "payment_abuse": { + "decision": { + "id": "user_decision" + }, + "time": 1468707128659, + "webhook_succeeded": false + } + } + } + """ + 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_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 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 = """ + { + "decisions": { + "payment_abuse": { + "decision": { + "id": "decision7" + }, + "time": 1468599638005, + "webhook_succeeded": false + }, + "promotion_abuse": { + "decision": { + "id": "good_order" + }, + "time": 1468517407135, + "webhook_succeeded": true + } + } + } + """ + 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_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 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 = """ + { + "decisions": { + "account_takeover": { + "decision": { + "id": "session_decision" + }, + "time": 1461963839151, + "webhook_succeeded": true + } + } + } + """ + 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_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 isinstance(response.body, dict) + assert ( + response.body["decisions"]["account_takeover"]["decision"][ + "id" + ] + == "session_decision" + ) + + def test_get_content_decisions(self) -> None: + mock_response = mock.Mock() + mock_response.content = """ + { + "decisions": { + "content_abuse": { + "decision": { + "id": "content_looks_bad_content_abuse" + }, + "time": 1468517407135, + "webhook_succeeded": true + } + } + } + """ + 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_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 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, + ) + + 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() + session.post.return_value = mock_response + 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() + ) + + 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__": + main() diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py new file mode 100644 index 0000000..24a4f3c --- /dev/null +++ b/tests/test_client_v203.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +import datetime +import json +import typing as t +import unittest +import warnings +from decimal import Decimal +from unittest import mock + +from requests.auth import HTTPBasicAuth +from requests.exceptions import RequestException + +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": 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() -> dict[str, t.Any]: + return { + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + } + + +def score_response_json() -> str: + return """{ + "status": 0, + "error_message": "OK", + "user_id": "12345", + "score": 0.55 + }""" + + +def action_response_json() -> str: + return """{ + "actions": [ + { + "action": { + "id": "freds_action" + }, + "entity": { + "id": "Fred" + }, + "id": "ACTION1234567890:freds_action", + "triggers": [ + { + "source": "synchronous_action", + "trigger": { + "id": "TRIGGER1234567890" + }, + "type": "formula" + } + ] + } + ], + "score": 0.55, + "status": 0, + "error_message": "OK", + "user_id": "Fred" + }""" + + +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", + } + + +class TestSiftPythonClient(unittest.TestCase): + + 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) -> 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"} + ) + + 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(ValueError, self.sift_client.track, event, {}) + + 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) -> 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() + ) + + mock_post.assert_called_with( + "https://api.sift.com/v203/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_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: + mock_post.return_value = mock_response + + response = self.sift_client_v204.track( + event, + valid_transaction_properties(), + timeout=test_timeout, + version="203", + ) + + mock_post.assert_called_with( + "https://api.sift.com/v203/events", + data=mock.ANY, + 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" + + 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: + mock_get.return_value = mock_response + + response = self.sift_client_v204.score("12345", version="203") + + mock_get.assert_called_with( + "https://api.sift.com/v203/score/12345", + params={}, + 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.55 + + 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: + mock_get.return_value = mock_response + + response = self.sift_client.score("12345", test_timeout) + + mock_get.assert_called_with( + "https://api.sift.com/v203/score/12345", + params={}, + 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["score"] == 0.55 + + def test_sync_score_ok(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + 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: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, valid_transaction_properties(), return_score=True + ) + + mock_post.assert_called_with( + "https://api.sift.com/v203/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + 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 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: + mock_post.return_value = mock_response + + 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", + } + properties.update({"$api_key": self.test_key, "$type": "$label"}) + data = json.dumps(properties) + mock_post.assert_called_with( + 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" + + 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: + mock_post.return_value = mock_response + + response = self.sift_client_v204.label( + 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", + "$api_key": self.test_key, + "$type": "$label", + } + + mock_post.assert_called_with( + 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" + + 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: + mock_delete.return_value = mock_response + + response = self.sift_client.unlabel(user_id) + + mock_delete.assert_called_with( + f"https://api.sift.com/v203/users/{user_id}/labels", + headers=mock.ANY, + timeout=mock.ANY, + params={}, + auth=HTTPBasicAuth(self.test_key, ""), + ) + self.assertIsInstance(response, sift.client.Response) + 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: + mock_delete.return_value = mock_response + response = self.sift_client_v204.unlabel(user_id, version="203") + + mock_delete.assert_called_with( + f"https://api.sift.com/v203/users/{_q(user_id)}/labels", + headers=mock.ANY, + timeout=mock.ANY, + params={}, + auth=HTTPBasicAuth(self.test_key, ""), + ) + + self.assertIsInstance(response, sift.client.Response) + assert response.is_ok() + + 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: + mock_post.return_value = mock_response + + 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", + "$api_key": self.test_key, + "$type": "$label", + } + + mock_post.assert_called_with( + f"https://api.sift.com/v203/users/{_q(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" + + 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: + mock_get.return_value = mock_response + + response = self.sift_client.score(user_id) + + mock_get.assert_called_with( + f"https://api.sift.com/v203/score/{_q(user_id)}", + params={}, + 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.55 + + def test_exception_during_track_call(self) -> None: + warnings.simplefilter("always") + + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.side_effect = mock.Mock( + side_effect=RequestException("Failed") + ) + self.assertRaises( + sift.client.ApiException, + self.sift_client.track, + "$transaction", + valid_transaction_properties(), + ) + + def test_exception_during_score_call(self) -> None: + warnings.simplefilter("always") + + with mock.patch.object(self.sift_client.session, "get") as mock_get: + mock_get.side_effect = mock.Mock( + side_effect=RequestException("Failed") + ) + self.assertRaises( + sift.client.ApiException, self.sift_client.score, "Fred" + ) + + def test_exception_during_unlabel_call(self) -> None: + warnings.simplefilter("always") + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: + mock_delete.side_effect = mock.Mock( + side_effect=RequestException("Failed") + ) + self.assertRaises( + sift.client.ApiException, self.sift_client.unlabel, "Fred" + ) + + def test_return_actions_on_track(self) -> None: + event = "$transaction" + mock_response = mock.Mock() + 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: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, valid_transaction_properties(), return_action=True + ) + + mock_post.assert_called_with( + "https://api.sift.com/v203/events", + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + 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 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 main() -> None: + unittest.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()