├── .gitignore ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── btnamespace ├── __init__.py ├── _version.py ├── actions.py ├── compat.py ├── namespace.py ├── patch.py ├── schemas.py └── shared.py ├── dev-requirements.txt ├── pytest.ini ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_integration.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .DS_Store 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | History 2 | ------- 3 | 4 | 2.1.1 5 | +++++ 6 | released 2022-03-18 7 | 8 | - Add missing history notes 9 | 10 | 2.1.0 11 | +++++ 12 | released 2022-03-18 13 | 14 | - Add support for Python 3.5 and 3.7 15 | 16 | 2.0.0 17 | +++++ 18 | released 2016-07-15 19 | 20 | - breaking: drop python 2.6 support 21 | - fix a bug affecting operations after exiting a namespace 22 | - add strict_missing and strict_missing_exception options 23 | 24 | 1.1.1 25 | +++++ 26 | released 2014-09-26 27 | 28 | - pin bidict to 0.1.5 to avoid breaking changes in newer versions 29 | 30 | 1.1.0 31 | +++++ 32 | released 2014-07-28 33 | 34 | - add schema for braintree.ClientToken.generate 35 | 36 | 1.0.1 37 | +++++ 38 | released 2014-04-08 39 | 40 | - use less expensive introspection 41 | - remove decorator dependency 42 | 43 | 1.0.0 44 | +++++ 45 | released 2014-04-07 46 | 47 | - initial release 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Venmo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include btnamespace *.py 2 | include HISTORY.rst LICENSE README.rst 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | btnamespace 2 | =========== 3 | 4 | *[not actively supported outside of internal Venmo usage]* 5 | 6 | A Braintree namespace isolates state on the Braintree gateway: 7 | 8 | .. code-block:: python 9 | 10 | import braintree 11 | import btnamespace 12 | 13 | with btnamespace.Namespace(): 14 | customer = braintree.Customer.create({"id": "123"}) 15 | assert customer.id == "123" 16 | braintree.Customer.find("123") # success 17 | 18 | braintree.Customer.find("123") # NotFound exception 19 | 20 | This is primarily useful during integration tests: 21 | 22 | .. code-block:: python 23 | 24 | def setUp(self): 25 | self.namespace = btnamespace.Namespace() 26 | self.namespace.__enter__() 27 | 28 | def test_some_sandbox_integration(self): 29 | #... 30 | 31 | def tearDown(self): 32 | self.namespace.__exit__() 33 | 34 | 35 | Compared to calling eg ``braintree.Customer.delete`` during ``tearDown``, this has a number of advantages: 36 | 37 | - it's faster, since no teardown is needed 38 | - it's simpler, since it doesn't require any bookkeeping 39 | - it's robust, since tests can be written without any state assumptions 40 | 41 | You can install it with ``$ pip install btnamespace``. 42 | 43 | 44 | What's supported 45 | ---------------- 46 | 47 | - Customer create, update, find, delete 48 | - CreditCard create, update, find, delete 49 | - Transaction create, find 50 | 51 | All operations involving subresources - eg creating a CreditCard and Customer in one call - work as expected. 52 | 53 | Adding support for other operations is easy; we just haven't needed them yet. 54 | Contributions welcome! 55 | 56 | 57 | How it Works 58 | ------------ 59 | 60 | Under the hood, a Namespace globally patches the braintree client library. 61 | 62 | During create operations, any provided ids are removed. 63 | This forces the gateway to respond with unique ids, which are later mapped back to the originally-provided ids. 64 | Here's an example: 65 | 66 | - on a call to ``braintree.Customer.create({'id': '123', ...})``, ``'123'`` is stored as a Customer id and the call becomes ``braintree.Customer.create({...})``. 67 | - then, the server returns a unique id ``'abcde'`` for the Customer. ``'123'`` is mapped to ``'abcde'``, and the resulting Customer object's id is set to ``'123'``. 68 | - later, a call to ``braintree.Customer.find('123')`` becomes ``braintree.Customer.find('abcde')``. 69 | 70 | 71 | Contributing 72 | ------------ 73 | 74 | Inside your vitualenv: 75 | 76 | .. code-block:: bash 77 | 78 | $ cd btnamespace 79 | $ pip install -e . 80 | $ pip install -r dev-requirements.txt 81 | 82 | 83 | To run the tests, first add your sandbox credentials: 84 | 85 | .. code-block:: bash 86 | 87 | $ export BT_MERCHANT_ID=merchant-id 88 | $ export BT_PUBLIC_KEY=public-id 89 | $ export BT_PRIVATE_KEY=private-key 90 | 91 | 92 | Then run ``$ pytest``. 93 | -------------------------------------------------------------------------------- /btnamespace/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ._version import __version__ 4 | from .namespace import Namespace 5 | from .shared import NamespaceError 6 | 7 | # appease flake8 8 | (Namespace, NamespaceError, __version__) 9 | 10 | __title__ = 'btnamespace' 11 | __author__ = 'Simon Weber' 12 | __license__ = 'MIT' 13 | __copyright__ = 'Copyright 2014 Venmo' 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | logger.setLevel(logging.WARNING) 18 | 19 | # Set default logging handler to avoid "No handler found" warnings. 20 | try: # Python 2.7+ 21 | from logging import NullHandler 22 | except ImportError: 23 | class NullHandler(logging.Handler): 24 | def emit(self, record): 25 | pass 26 | 27 | logger.addHandler(NullHandler()) 28 | -------------------------------------------------------------------------------- /btnamespace/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.1.1" 2 | -------------------------------------------------------------------------------- /btnamespace/actions.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | 4 | from bidict import namedbidict 5 | import braintree 6 | 7 | from .compat import getcallargs 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | IDMap = namedbidict('IDMap', 'fake_id', 'real_id') 12 | 13 | 14 | def ensure_state_is_init(f): 15 | def wrapper(*args, **kwargs): 16 | # There's not currently a place to provide global init for the state dict, 17 | # each action needs to ensure subitems are initialized. 18 | 19 | named_args = getcallargs(f, *args, **kwargs) 20 | state = named_args['state'] 21 | 22 | if 'id_maps' not in state: 23 | state['id_maps'] = collections.defaultdict(IDMap) 24 | if 'last_fake_ids' not in state: 25 | state['last_fake_ids'] = {} 26 | 27 | return f(*args, **kwargs) 28 | return wrapper 29 | 30 | 31 | def clear_old_creation_ids(state, call_params, options): 32 | # Used as a start_hook in appcode entry points (ie, not __init__) 33 | # to ensure that old state doesn't stick around. 34 | state['last_fake_ids'] = {} 35 | 36 | 37 | @ensure_state_is_init 38 | def convert_to_real_id(params, schema_params, key, resource_id, state, options): 39 | fake_id = params[key] 40 | id_maps = state['id_maps'] 41 | 42 | # When replacing, we always default to the value itself. 43 | # This means that when we don't have the necessary bookkeeping 44 | # to replace it, everything still proceeds normally. 45 | real_id = fake_id 46 | try: 47 | real_id = id_maps[resource_id.bt_class].fake_id_for[fake_id] 48 | except KeyError: 49 | # This can happen in two cases: 50 | # * The caller made a mistake and didn't create the resource yet. 51 | # * The caller created the resource, but not through our (patched) braintree library. 52 | if (('strict_missing_exception' in options or 53 | options.get('strict_missing', False))): 54 | exception = options.get('strict_missing_exception', braintree.exceptions.NotFoundError) 55 | raise exception 56 | else: 57 | logger.warning("The braintree id %r has not been previously stored." 58 | " Either the resource was never created," 59 | " or it was not created through this client and namespace.", fake_id) 60 | 61 | params[key] = real_id 62 | logger.debug("%r --[real_id]--> %r", fake_id, params[key]) 63 | 64 | 65 | @ensure_state_is_init 66 | def delete_and_store(params, schema_params, key, resource_id, state, options): 67 | provided_id = params[key] 68 | bt_class = resource_id.bt_class 69 | id_maps = state['id_maps'] 70 | 71 | if provided_id in id_maps[bt_class].fake_id_for: 72 | # Properly handle duplicate creates of the same id. 73 | # We should pass these through, since the gateway will return an error. 74 | params[key] = id_maps[bt_class].fake_id_for[provided_id] 75 | logger.debug("would have deleted, but %r has been used in a prior creation", provided_id) 76 | else: 77 | # We need to know which id the client expects the response to be mapped to. 78 | # This only works because multiple creations are impossible. 79 | state['last_fake_ids'][bt_class] = provided_id 80 | del params[key] 81 | logger.debug("deleting %r in create call", provided_id) 82 | 83 | 84 | @ensure_state_is_init 85 | def convert_to_fake_id(params, schema_params, key, resource_id, state, options): 86 | real_id = params[key] 87 | id_maps = state['id_maps'] 88 | bt_class = resource_id.bt_class 89 | last_fake_ids = state['last_fake_ids'] 90 | 91 | if real_id not in id_maps[bt_class].real_id_for: 92 | # We need to update our mapping. 93 | # This condition also prevents us from updating existing mappings, 94 | # which we'd want to change to support id updates. 95 | 96 | if bt_class in last_fake_ids: 97 | # An id was provided during creation; include it in our mapping. 98 | fake_id = last_fake_ids.pop(bt_class) 99 | else: 100 | # There are actually two cases here, but we don't currently distinguish 101 | # between them: 102 | # 1) No id provided during creation: self-map this key. 103 | # 2) We don't have bookkeeping for this id at all: this is an error, 104 | # but the chance of it happening and *also* disrupting normal 105 | # operation is incredibly slim. 106 | fake_id = real_id 107 | 108 | id_maps[bt_class].fake_id_for[fake_id] = real_id 109 | logger.debug('mapping updated: fake_id %r == %r', fake_id, real_id) 110 | 111 | params[key] = id_maps[bt_class].real_id_for[real_id] 112 | logger.debug("%r <--[fake_id]-- %r", params[key], real_id) 113 | -------------------------------------------------------------------------------- /btnamespace/compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Single interface for code that varies across Python environments. 3 | """ 4 | 5 | from builtins import zip 6 | from builtins import next 7 | import inspect 8 | import sys 9 | 10 | 11 | # backport of inspect.getcallargs from 2.7 12 | def getcallargs(func, *positional, **named): 13 | """Get the mapping of arguments to values. 14 | 15 | A dict is returned, with keys the function argument names (including the 16 | names of the * and ** arguments, if any), and values the respective bound 17 | values from 'positional' and 'named'.""" 18 | args, varargs, varkw, defaults = inspect.getargspec(func) 19 | f_name = func.__name__ 20 | arg2value = {} 21 | 22 | # The following closures are basically because of tuple parameter unpacking. 23 | assigned_tuple_params = [] 24 | 25 | def assign(arg, value): 26 | if isinstance(arg, str): 27 | arg2value[arg] = value 28 | else: 29 | assigned_tuple_params.append(arg) 30 | value = iter(value) 31 | for i, subarg in enumerate(arg): 32 | try: 33 | subvalue = next(value) 34 | except StopIteration: 35 | raise ValueError('need more than %d %s to unpack' % 36 | (i, 'values' if i > 1 else 'value')) 37 | assign(subarg, subvalue) 38 | try: 39 | next(value) 40 | except StopIteration: 41 | pass 42 | else: 43 | raise ValueError('too many values to unpack') 44 | 45 | def is_assigned(arg): 46 | if isinstance(arg, str): 47 | return arg in arg2value 48 | return arg in assigned_tuple_params 49 | if inspect.ismethod(func) and func.__self__ is not None: 50 | # implicit 'self' (or 'cls' for classmethods) argument 51 | positional = (func.__self__,) + positional 52 | num_pos = len(positional) 53 | num_total = num_pos + len(named) 54 | num_args = len(args) 55 | num_defaults = len(defaults) if defaults else 0 56 | for arg, value in zip(args, positional): 57 | assign(arg, value) 58 | if varargs: 59 | if num_pos > num_args: 60 | assign(varargs, positional[-(num_pos - num_args):]) 61 | else: 62 | assign(varargs, ()) 63 | elif 0 < num_args < num_pos: 64 | raise TypeError('%s() takes %s %d %s (%d given)' % ( 65 | f_name, 'at most' if defaults else 'exactly', num_args, 66 | 'arguments' if num_args > 1 else 'argument', num_total)) 67 | elif num_args == 0 and num_total: 68 | raise TypeError('%s() takes no arguments (%d given)' % 69 | (f_name, num_total)) 70 | for arg in args: 71 | if isinstance(arg, str) and arg in named: 72 | if is_assigned(arg): 73 | raise TypeError("%s() got multiple values for keyword " 74 | "argument '%s'" % (f_name, arg)) 75 | else: 76 | assign(arg, named.pop(arg)) 77 | if defaults: # fill in any missing values with the defaults 78 | for arg, value in zip(args[-num_defaults:], defaults): 79 | if not is_assigned(arg): 80 | assign(arg, value) 81 | if varkw: 82 | assign(varkw, named) 83 | elif named: 84 | unexpected = next(iter(named)) 85 | if isinstance(unexpected, str): 86 | unexpected = unexpected.encode(sys.getdefaultencoding(), 'replace') 87 | raise TypeError("%s() got an unexpected keyword argument '%s'" % 88 | (f_name, unexpected)) 89 | unassigned = num_args - len([arg for arg in args if is_assigned(arg)]) 90 | if unassigned: 91 | num_required = num_args - num_defaults 92 | raise TypeError('%s() takes %s %d %s (%d given)' % ( 93 | f_name, 'at least' if defaults else 'exactly', num_required, 94 | 'arguments' if num_required > 1 else 'argument', num_total)) 95 | return arg2value 96 | -------------------------------------------------------------------------------- /btnamespace/namespace.py: -------------------------------------------------------------------------------- 1 | from builtins import object 2 | import braintree 3 | from mock import patch 4 | 5 | from .patch import SchemaPatcher 6 | from .schemas import schemas 7 | from .shared import UnsupportedSearchNode 8 | 9 | 10 | class Namespace(object): 11 | """A Namespace is a context manager which guarantees that state on Braintree 12 | will not be shared.""" 13 | 14 | def __init__(self, custom_schemas=None, options=None): 15 | """ 16 | :param custom_schemas: (optional) a list of CallSchemas to guide patching. 17 | If they're not provided, those defined in actions.schemas will be used. 18 | :param options (optional) a dictionary of configuration passed through to 19 | actions. The same instance is passed to options; it can be mutated 20 | at runtime to affect the next action run. 21 | 22 | Built in options: 23 | * 'strict_missing' and 'strict_missing_exception': by default, 24 | attempts to access non-namespaced resources will log a warning 25 | but be allowed to proceed. 26 | If strict_missing is True, an exception will be raised before 27 | the request is sent. 28 | By default this exception is braintree.exceptions.NotFoundError, 29 | but can be overridden with strict_missing_exception. 30 | """ 31 | 32 | if custom_schemas is None: 33 | custom_schemas = schemas 34 | 35 | if options is None: 36 | options = {} 37 | 38 | self.schemas = custom_schemas 39 | self.options = options 40 | self.schema_patcher = SchemaPatcher(self.options) 41 | self._patchers = self.schema_patcher.create_patchers(self.schemas) 42 | 43 | search_patch_nodes = { 44 | braintree.CustomerSearch: [ 45 | 'id', 'payment_method_token', 'payment_method_token_with_duplicates'], 46 | 47 | braintree.TransactionSearch: [ 48 | 'id', 'payment_method_token', 'customer_id'], 49 | } 50 | 51 | for search_cls, node_names in list(search_patch_nodes.items()): 52 | for node_name in node_names: 53 | self._patchers.append( 54 | patch.object(search_cls, node_name, UnsupportedSearchNode()) 55 | ) 56 | 57 | def __enter__(self): 58 | """Globally patch the braintree library to create a new namespace. 59 | 60 | Only one namespace may be active at any time. 61 | Results from entering more than once are undefined. 62 | """ 63 | for patcher in self._patchers: 64 | patcher.start() 65 | 66 | def __exit__(self, *exc): 67 | for patcher in self._patchers: 68 | patcher.stop() 69 | -------------------------------------------------------------------------------- /btnamespace/patch.py: -------------------------------------------------------------------------------- 1 | from builtins import str 2 | from builtins import object 3 | import copy 4 | import functools 5 | import logging 6 | 7 | from mock import patch 8 | 9 | from .compat import getcallargs 10 | from .schemas import ResourceId 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class PatchedMethod(object): 16 | """Instances of this callable replace braintree methods.""" 17 | 18 | def __init__(self, method, state, call_schema, options): 19 | """ 20 | :param method: a staticmethod or instance method 21 | :param state: the state dictionary to provide to actions 22 | :param call_schema 23 | :param options: dictionary with arbitrary contents passed through to actions 24 | """ 25 | 26 | self.method = method 27 | self.state = state 28 | self.call_schema = call_schema 29 | self.options = options 30 | 31 | def __call__(self, *args, **kwargs): 32 | named_args = getcallargs(self.method, *args, **kwargs) 33 | 34 | # Avoid mutating caller objects. 35 | # We can't just do deepcopy(named_args), because then we'll make copies of 36 | # self and gateway. 37 | named_args_copy = copy.copy(named_args) 38 | for key in self.call_schema.params: 39 | named_args_copy[key] = copy.deepcopy(named_args_copy[key]) 40 | 41 | if self.call_schema.start_hook is not None: 42 | self.call_schema.start_hook(self.state, named_args_copy, self.options) 43 | 44 | self._apply_param_actions(named_args_copy, self.call_schema.params) 45 | 46 | if (('self' in named_args_copy 47 | and args[0] is named_args_copy['self'])): 48 | # Receivers need to be passed positionally, apparently. 49 | receiver = named_args_copy.pop('self') 50 | return self.method(receiver, **named_args_copy) 51 | 52 | return self.method(**named_args_copy) 53 | 54 | def _apply_param_actions(self, params, schema_params): 55 | """Traverse a schema and perform the updates it describes to params.""" 56 | 57 | for key, val in list(schema_params.items()): 58 | if key not in params: 59 | continue 60 | 61 | if isinstance(val, dict): 62 | self._apply_param_actions(params[key], schema_params[key]) 63 | elif isinstance(val, ResourceId): 64 | resource_id = val 65 | 66 | # Callers can provide ints as ids. 67 | # We normalize them to strings so that actions don't get confused. 68 | params[key] = str(params[key]) 69 | 70 | resource_id.action(params, schema_params, key, 71 | resource_id, self.state, self.options) 72 | else: 73 | logger.error("Invalid value in schema params: %r. schema_params: %r and params: %r", 74 | val, schema_params, params) 75 | 76 | def __get__(self, obj, objtype): 77 | if obj is None: 78 | # This is a staticmethod; don't provide the receiver. 79 | return self.__call__ 80 | 81 | # This is an instance method; freeze the receiver onto the call. 82 | return functools.partial(self.__call__, obj) 83 | 84 | 85 | class SchemaPatcher(object): 86 | def __init__(self, options): 87 | self._action_state = {} 88 | self.options = options 89 | 90 | def create_patchers(self, call_schemas): 91 | patchers = [] 92 | 93 | for call_schema in call_schemas: 94 | bt_class = call_schema.bt_class 95 | original_method = getattr(bt_class, call_schema.method_name) 96 | 97 | replacement = PatchedMethod(original_method, self._action_state, 98 | call_schema, self.options) 99 | patchers.append(patch.object(bt_class, call_schema.method_name, replacement)) 100 | 101 | return patchers 102 | -------------------------------------------------------------------------------- /btnamespace/schemas.py: -------------------------------------------------------------------------------- 1 | import braintree 2 | 3 | from .actions import ( 4 | clear_old_creation_ids, 5 | convert_to_real_id, 6 | delete_and_store, 7 | convert_to_fake_id 8 | ) 9 | from .shared import ResourceId, CallSchema 10 | 11 | 12 | def creation_id(bt_class): 13 | return ResourceId(bt_class, action=delete_and_store) 14 | 15 | 16 | def fake_id(bt_class): 17 | return ResourceId(bt_class, action=convert_to_real_id) 18 | 19 | 20 | def real_id(bt_class): 21 | return ResourceId(bt_class, action=convert_to_fake_id) 22 | 23 | 24 | def schema(**kwargs): 25 | if 'start_hook' not in kwargs: 26 | kwargs['start_hook'] = None 27 | 28 | return CallSchema(**kwargs) 29 | 30 | 31 | schemas = [ 32 | schema( 33 | bt_class=braintree.Customer, 34 | method_name='__init__', 35 | params={ 36 | 'attributes': { 37 | # Semantically, this is read as: 38 | # "this param will be a real id of a Customer". 39 | 'id': real_id(braintree.Customer), 40 | } 41 | } 42 | ), 43 | schema( 44 | bt_class=braintree.Customer, 45 | method_name='create', 46 | start_hook=clear_old_creation_ids, 47 | params={ 48 | 'params': { 49 | 'id': creation_id(braintree.Customer), 50 | 'credit_card': { 51 | 'token': creation_id(braintree.CreditCard), 52 | } 53 | } 54 | }, 55 | ), 56 | schema( 57 | bt_class=braintree.Customer, 58 | method_name='update', 59 | start_hook=clear_old_creation_ids, 60 | params={ 61 | 'customer_id': fake_id(braintree.Customer), 62 | 'params': { 63 | 'credit_card': { 64 | 'options': { 65 | 'update_existing_token': fake_id(braintree.CreditCard), 66 | } 67 | } 68 | } 69 | } 70 | ), 71 | schema( 72 | bt_class=braintree.Customer, 73 | method_name='find', 74 | start_hook=clear_old_creation_ids, 75 | params={ 76 | 'customer_id': fake_id(braintree.Customer), 77 | } 78 | ), 79 | schema( 80 | bt_class=braintree.Customer, 81 | method_name='delete', 82 | start_hook=clear_old_creation_ids, 83 | params={ 84 | 'customer_id': fake_id(braintree.Customer), 85 | } 86 | ), 87 | 88 | # cards 89 | schema( 90 | bt_class=braintree.CreditCard, 91 | method_name='__init__', 92 | params={ 93 | 'attributes': { 94 | 'token': real_id(braintree.CreditCard), 95 | 'customer_id': real_id(braintree.Customer), 96 | } 97 | } 98 | ), 99 | schema( 100 | bt_class=braintree.CreditCard, 101 | method_name='create', 102 | start_hook=clear_old_creation_ids, 103 | params={ 104 | 'params': { 105 | 'token': creation_id(braintree.CreditCard), 106 | 'customer_id': fake_id(braintree.Customer), 107 | } 108 | }, 109 | ), 110 | schema( 111 | bt_class=braintree.CreditCard, 112 | method_name='update', 113 | start_hook=clear_old_creation_ids, 114 | params={ 115 | 'credit_card_token': fake_id(braintree.CreditCard), 116 | } 117 | ), 118 | schema( 119 | bt_class=braintree.CreditCard, 120 | method_name='find', 121 | start_hook=clear_old_creation_ids, 122 | params={ 123 | 'credit_card_token': fake_id(braintree.CreditCard), 124 | } 125 | ), 126 | schema( 127 | bt_class=braintree.CreditCard, 128 | method_name='delete', 129 | start_hook=clear_old_creation_ids, 130 | params={ 131 | 'credit_card_token': fake_id(braintree.CreditCard), 132 | } 133 | ), 134 | 135 | # transactions 136 | schema( 137 | bt_class=braintree.Transaction, 138 | method_name='__init__', 139 | params={ 140 | 'attributes': { 141 | 'id': real_id(braintree.Transaction), 142 | } 143 | } 144 | ), 145 | schema( 146 | bt_class=braintree.Transaction, 147 | method_name='create', 148 | start_hook=clear_old_creation_ids, 149 | params={ 150 | 'params': { 151 | 'id': creation_id(braintree.Transaction), 152 | 'customer_id': fake_id(braintree.Customer), 153 | 'payment_method_token': fake_id(braintree.CreditCard), 154 | 'credit_card': { 155 | 'token': creation_id(braintree.CreditCard), 156 | }, 157 | 'customer': { 158 | 'id': creation_id(braintree.Customer), 159 | }, 160 | } 161 | }, 162 | ), 163 | schema( 164 | bt_class=braintree.Transaction, 165 | method_name='find', 166 | start_hook=clear_old_creation_ids, 167 | params={ 168 | 'transaction_id': fake_id(braintree.Transaction), 169 | } 170 | ), 171 | # Transactions can not be deleted nor updated. 172 | 173 | # client tokens 174 | schema( 175 | bt_class=braintree.ClientToken, 176 | method_name='generate', 177 | params={ 178 | 'params': { 179 | 'customer_id': fake_id(braintree.Customer) 180 | } 181 | } 182 | ), 183 | ] 184 | -------------------------------------------------------------------------------- /btnamespace/shared.py: -------------------------------------------------------------------------------- 1 | from builtins import object 2 | import collections 3 | 4 | 5 | ResourceId = collections.namedtuple('ResourceId', ['bt_class', 'action']) 6 | CallSchema = collections.namedtuple('CallSchema', ['bt_class', 'method_name', 7 | 'start_hook', 'params']) 8 | 9 | 10 | class NamespaceError(Exception): 11 | pass 12 | 13 | 14 | class UnsupportedSearchNode(object): 15 | def __eq__(*_): 16 | raise NamespaceError("Advanced search on ids or tokens not allowed.") 17 | 18 | def __ne__(*_): 19 | raise NamespaceError("Advanced search on ids or tokens not allowed.") 20 | 21 | def __getattribute__(*_): 22 | raise NamespaceError("Advanced search on ids or tokens not allowed.") 23 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = 4 | --cov=btnamespace 5 | --cov-fail-under=85 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # Signal that this project has no C extensions and supports Python 2 and 3 3 | # https://packaging.python.org/guides/distributing-packages-using-setuptools/#wheels 4 | universal=1 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | 4 | from setuptools import setup 5 | 6 | with open('README.rst') as f: 7 | readme = f.read() 8 | 9 | with open('HISTORY.rst') as f: 10 | history = f.read() 11 | 12 | # This hack is from http://stackoverflow.com/a/7071358/1231454; 13 | # the version is kept in a seperate file and gets parsed - this 14 | # way, setup.py doesn't have to import the package. 15 | VERSIONFILE = 'btnamespace/_version.py' 16 | 17 | with open(VERSIONFILE) as f: 18 | version_line = f.read() 19 | 20 | version_re = r"^__version__ = ['\"]([^'\"]*)['\"]" 21 | match = re.search(version_re, version_line, re.M) 22 | if match: 23 | version = match.group(1) 24 | else: 25 | raise RuntimeError("Could not find version in '%s'" % VERSIONFILE) 26 | 27 | setup( 28 | name='btnamespace', 29 | version=version, 30 | description='Isolate state on the Braintree sandbox during testing.', 31 | long_description=readme + '\n\n' + history, 32 | author='Simon Weber', 33 | author_email='simon@venmo.com', 34 | url='https://github.com/venmo/btnamespace', 35 | packages=['btnamespace'], 36 | package_dir={'btnamespace': 'btnamespace'}, 37 | include_package_data=True, 38 | install_requires=[ 39 | 'bidict>=0.18.4,<0.19;python_version<="2.7"', 40 | 'bidict>=0.18.4;python_version>"2.7"', 41 | 'braintree>=3.46.0,<4;python_version<="2.7"', 42 | 'braintree>=3.46.0;python_version>"2.7"', 43 | 'future>=0.18.2', 44 | 'mock', 45 | ], 46 | license='MIT', 47 | zip_safe=False, 48 | classifiers=[ 49 | "Development Status :: 5 - Production/Stable", 50 | "Intended Audience :: Developers", 51 | "License :: OSI Approved :: MIT License", 52 | "Programming Language :: Python", 53 | "Programming Language :: Python :: 2", 54 | "Programming Language :: Python :: 2.7", 55 | "Programming Language :: Python :: 3", 56 | "Programming Language :: Python :: 3.5", 57 | "Programming Language :: Python :: 3.7", 58 | ], 59 | python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.6.*", 60 | ) 61 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/venmo/btnamespace/6bc4f93f8f1f55dbee8352e3bd98395cdbcbd476/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from builtins import zip 4 | from builtins import str 5 | import copy 6 | import os 7 | import uuid 8 | 9 | import braintree 10 | from unittest import TestCase, main 11 | 12 | from btnamespace import Namespace, NamespaceError 13 | 14 | 15 | braintree.Configuration.configure( 16 | braintree.Environment.Sandbox, 17 | merchant_id=os.environ["BT_MERCHANT_ID"], 18 | public_key=os.environ["BT_PUBLIC_KEY"], 19 | private_key=os.environ["BT_PRIVATE_KEY"], 20 | ) 21 | 22 | 23 | def _ensure_user_exists(user_params): 24 | try: 25 | braintree.Customer.find(user_params['id']) 26 | except braintree.exceptions.NotFoundError: 27 | braintree.Customer.create(user_params) 28 | 29 | braintree.Customer.find(user_params['id']) 30 | 31 | 32 | class ActionOutsideNamespaceTest(TestCase): 33 | def test_customer_operations_outside_of_namespace(self): 34 | with self.assertRaises(braintree.exceptions.NotFoundError): 35 | braintree.Customer.find('nonexistent') 36 | 37 | _ensure_user_exists({ 38 | 'id': 'nonnamespaced', 39 | }) 40 | 41 | with Namespace(): 42 | pass 43 | 44 | try: 45 | braintree.Customer.find('nonnamespaced') 46 | except braintree.exceptions.NotFoundError: 47 | self.fail() 48 | 49 | 50 | class NamespaceTest(TestCase): 51 | def setUp(self): 52 | self.namespace = Namespace() 53 | self.namespace.__enter__() 54 | self.addCleanup(self.namespace.__exit__) 55 | 56 | 57 | class OptionsTest(NamespaceTest): 58 | def test_omit_options_gets_empty(self): 59 | namespace = Namespace() 60 | self.assertEqual(namespace.options, {}) 61 | 62 | 63 | class StrictMissingOptionTest(NamespaceTest): 64 | class MyError(Exception): 65 | pass 66 | 67 | def setUp(self): 68 | _ensure_user_exists({ 69 | "id": "existing", 70 | "first_name": "Existing", 71 | "last_name": "User", 72 | }) 73 | 74 | # Cleanups are run LIFO, so this runs outside of the namespace. 75 | self.addCleanup(braintree.Customer.delete, 'existing') 76 | super(StrictMissingOptionTest, self).setUp() 77 | 78 | def test_existing_nonnamespace_user_found_with_default_options(self): 79 | braintree.Customer.find('existing') # should not raise NotFoundError 80 | 81 | def test_strict_missing_will_404_existing_nonnamespace_user(self): 82 | self.namespace.options['strict_missing'] = True 83 | 84 | with self.assertRaises(braintree.exceptions.NotFoundError): 85 | braintree.Customer.find('existing') 86 | 87 | def test_strict_missing_exception_overrides_notfounderror(self): 88 | self.namespace.options['strict_missing'] = True 89 | 90 | self.namespace.options['strict_missing_exception'] = self.MyError 91 | 92 | with self.assertRaises(self.MyError): 93 | braintree.Customer.find('existing') 94 | 95 | def test_strict_missing_exception_overrides_strict_missing(self): 96 | self.namespace.options['strict_missing'] = False 97 | 98 | self.namespace.options['strict_missing_exception'] = self.MyError 99 | 100 | with self.assertRaises(self.MyError): 101 | braintree.Customer.find('existing') 102 | 103 | 104 | class PatchDeleteTest(NamespaceTest): 105 | def test_delete_customer(self): 106 | result = braintree.Customer.create({ 107 | "id": "customer_id", 108 | "first_name": "Jen", 109 | "last_name": "Smith", 110 | "company": "Braintree", 111 | "email": "jen@example.com", 112 | "phone": "312.555.1234", 113 | "fax": "614.555.5678", 114 | "website": "www.example.com" 115 | }) 116 | self.assertTrue(result.is_success, result) 117 | 118 | result = braintree.Customer.delete('customer_id') 119 | self.assertTrue(result.is_success, result) 120 | 121 | def test_delete_credit_card(self): 122 | result = braintree.Customer.create({ 123 | "id": "customer_id", 124 | "first_name": "Jen", 125 | "last_name": "Smith", 126 | "company": "Braintree", 127 | "email": "jen@example.com", 128 | "phone": "312.555.1234", 129 | "fax": "614.555.5678", 130 | "website": "www.example.com", 131 | "credit_card": { 132 | "token": "credit_card_token", 133 | "number": "4111111111111111", 134 | "expiration_date": "05/2015", 135 | "cvv": "123" 136 | } 137 | }) 138 | self.assertTrue(result.is_success, result) 139 | 140 | result = braintree.CreditCard.delete('credit_card_token') 141 | self.assertTrue(result.is_success, result) 142 | 143 | 144 | class PatchFindTest(NamespaceTest): 145 | def test_find_customer(self): 146 | result = braintree.Customer.create({ 147 | "id": "customer_id", 148 | "first_name": "Jen", 149 | "last_name": "Smith", 150 | "company": "Braintree", 151 | "email": "jen@example.com", 152 | "phone": "312.555.1234", 153 | "fax": "614.555.5678", 154 | "website": "www.example.com", 155 | "credit_card": { 156 | "token": "credit_card_token", 157 | "number": "4111111111111111", 158 | "expiration_date": "05/2015", 159 | "cvv": "123" 160 | } 161 | }) 162 | self.assertTrue(result.is_success, result) 163 | 164 | customer = braintree.Customer.find('customer_id') 165 | self.assertEqual(customer.id, 'customer_id') 166 | self.assertEqual(customer.credit_cards[0].token, 'credit_card_token') 167 | 168 | def test_find_card(self): 169 | result = braintree.Customer.create({ 170 | "id": "customer_id", 171 | "first_name": "Jen", 172 | "last_name": "Smith", 173 | "company": "Braintree", 174 | "email": "jen@example.com", 175 | "phone": "312.555.1234", 176 | "fax": "614.555.5678", 177 | "website": "www.example.com", 178 | "credit_card": { 179 | "token": "credit_card_token", 180 | "number": "4111111111111111", 181 | "expiration_date": "05/2015", 182 | "cvv": "123" 183 | } 184 | }) 185 | self.assertTrue(result.is_success, result) 186 | 187 | card = braintree.CreditCard.find('credit_card_token') 188 | self.assertEqual(card.token, 'credit_card_token') 189 | 190 | def test_find_transaction(self): 191 | result = braintree.Transaction.sale({ 192 | "id": "txn_id", 193 | "amount": "10.00", 194 | "order_id": str(uuid.uuid4()), # sidestep duplicate transaction validation 195 | "credit_card": { 196 | "token": "credit_card_token", 197 | "number": "4111111111111111", 198 | "expiration_date": "05/2015", 199 | "cvv": "123" 200 | }, 201 | "customer": { 202 | "id": "customer_id", 203 | "first_name": "Drew", 204 | "last_name": "Smith", 205 | "company": "Braintree", 206 | "phone": "312-555-1234", 207 | "fax": "312-555-1235", 208 | "website": "http://www.example.com", 209 | "email": "drew@example.com" 210 | }, 211 | }) 212 | self.assertTrue(result.is_success, result) 213 | 214 | transaction = braintree.Transaction.find('txn_id') 215 | 216 | self.assertEqual(transaction.id, 'txn_id') 217 | self.assertEqual(transaction.customer_details.id, 'customer_id') 218 | self.assertEqual(transaction.credit_card_details.token, 'credit_card_token') 219 | 220 | 221 | class PatchUpdateTest(NamespaceTest): 222 | def test_update_customer(self): 223 | result = braintree.Customer.create({ 224 | "id": "customer_id", 225 | "first_name": "Jen", 226 | "last_name": "Smith", 227 | "company": "Braintree", 228 | "email": "jen@example.com", 229 | "phone": "312.555.1234", 230 | "fax": "614.555.5678", 231 | "website": "www.example.com", 232 | "credit_card": { 233 | "token": "credit_card_token", 234 | "number": "4111111111111111", 235 | "expiration_date": "05/2015", 236 | "cvv": "123" 237 | } 238 | }) 239 | self.assertTrue(result.is_success, result) 240 | result = braintree.Customer.update('customer_id', {"first_name": "Jenny"}) 241 | self.assertTrue(result.is_success, result) 242 | 243 | self.assertEqual(result.customer.id, 'customer_id') 244 | self.assertEqual(result.customer.credit_cards[0].token, 'credit_card_token') 245 | 246 | def test_update_customer_and_existing_card(self): 247 | result = braintree.Customer.create({ 248 | "id": "customer_id", 249 | "first_name": "Jen", 250 | "last_name": "Smith", 251 | "company": "Braintree", 252 | "email": "jen@example.com", 253 | "phone": "312.555.1234", 254 | "fax": "614.555.5678", 255 | "website": "www.example.com", 256 | "credit_card": { 257 | "token": "credit_card_token", 258 | "number": "4111111111111111", 259 | "expiration_date": "05/2015", 260 | "cvv": "123" 261 | } 262 | }) 263 | self.assertTrue(result.is_success, result) 264 | 265 | result = braintree.Customer.update('customer_id', { 266 | 'first_name': 'Jenny', 267 | 'credit_card': { 268 | 'cvv': '123', 269 | 'expiration_date': '08/2016', 270 | 'options': { 271 | 'update_existing_token': 'credit_card_token', 272 | } 273 | } 274 | }) 275 | self.assertTrue(result.is_success, result) 276 | 277 | self.assertEqual(result.customer.first_name, 'Jenny') 278 | self.assertEqual(result.customer.credit_cards[0].expiration_date, '08/2016') 279 | 280 | def test_update_credit_card(self): 281 | result = braintree.Customer.create({ 282 | "id": "customer_id", 283 | "first_name": "Jen", 284 | "last_name": "Smith", 285 | "company": "Braintree", 286 | "email": "jen@example.com", 287 | "phone": "312.555.1234", 288 | "fax": "614.555.5678", 289 | "website": "www.example.com", 290 | "credit_card": { 291 | "token": "credit_card_token", 292 | "number": "4111111111111111", 293 | "expiration_date": "05/2015", 294 | "cvv": "123" 295 | } 296 | }) 297 | self.assertTrue(result.is_success, result) 298 | 299 | result = braintree.CreditCard.update('credit_card_token', { 300 | 'number': '4005519200000004', 301 | 'cvv': '123' 302 | }) 303 | self.assertEqual(result.credit_card.token, 'credit_card_token') 304 | 305 | 306 | class PatchCreateTest(NamespaceTest): 307 | def setUp(self): 308 | super(PatchCreateTest, self).setUp() 309 | 310 | self.customer_params_no_id = { 311 | "first_name": "Jen", 312 | "last_name": "Smith", 313 | "company": "Braintree", 314 | "email": "jen@example.com", 315 | "phone": "312.555.1234", 316 | "fax": "614.555.5678", 317 | "website": "www.example.com", 318 | } 319 | 320 | self.card_params_no_token = { 321 | "number": "4111111111111111", 322 | "expiration_date": "05/2015", 323 | "cardholder_name": "The Cardholder", 324 | "cvv": "123", 325 | } 326 | 327 | def id_maps(self): 328 | # This isn't super clean: we're using internal knowledge of action state. 329 | return self.namespace.schema_patcher._action_state['id_maps'] 330 | 331 | def get_real_id(self, bt_class, fake_id): 332 | return self.id_maps()[bt_class].fake_id_for[fake_id] 333 | 334 | def get_fake_id(self, bt_class, real_id): 335 | return self.id_maps()[bt_class].real_id_for[real_id] 336 | 337 | def assert_self_mapping(self, bt_class, fake_id): 338 | """Assert that this id is mapped symetrically to itself.""" 339 | self.assertEqual(fake_id, self.get_fake_id(bt_class, fake_id)) 340 | self.assertEqual(fake_id, self.get_real_id(bt_class, fake_id)) 341 | 342 | def assert_nonself_mapping(self, bt_class, fake_id): 343 | """Assert that this id is mapped to something other than itself 344 | (ie, a random braintree-provided id).""" 345 | real_id = self.get_real_id(bt_class, fake_id) 346 | self.assertNotEqual(fake_id, real_id) 347 | 348 | 349 | class PatchCustomerCreate(PatchCreateTest): 350 | def test_create_patch_no_id(self): 351 | result = braintree.Customer.create(self.customer_params_no_id) 352 | self.assertTrue(result.is_success, result) 353 | real_id = result.customer.id 354 | 355 | self.assert_self_mapping(braintree.Customer, real_id) 356 | 357 | def test_create_patch_with_id(self): 358 | customer_params = copy.copy(self.customer_params_no_id) 359 | customer_params['id'] = 'original_id' 360 | 361 | result = braintree.Customer.create(customer_params) 362 | self.assertTrue(result.is_success, result) 363 | self.assertEqual(result.customer.id, "original_id") 364 | 365 | self.assert_nonself_mapping(braintree.Customer, 'original_id') 366 | 367 | def test_create_patch_with_card_no_token(self): 368 | customer_params = copy.copy(self.customer_params_no_id) 369 | customer_params['id'] = 'original_id' 370 | customer_params['credit_card'] = self.card_params_no_token 371 | 372 | result = braintree.Customer.create(customer_params) 373 | self.assertTrue(result.is_success, result) 374 | self.assertEqual(result.customer.id, "original_id") 375 | self.assertEqual(len(result.customer.credit_cards), 1) 376 | server_tok = result.customer.credit_cards[0].token 377 | 378 | self.assert_nonself_mapping(braintree.Customer, 'original_id') 379 | self.assert_self_mapping(braintree.CreditCard, server_tok) 380 | 381 | def test_create_patch_with_card_and_token(self): 382 | customer_params = copy.copy(self.customer_params_no_id) 383 | customer_params['id'] = 'original_id' 384 | 385 | customer_params['credit_card'] = self.card_params_no_token 386 | customer_params['credit_card']['token'] = 'original_tok' 387 | 388 | result = braintree.Customer.create(customer_params) 389 | self.assertTrue(result.is_success, result) 390 | self.assertEqual(result.customer.id, "original_id") 391 | self.assertEqual(len(result.customer.credit_cards), 1) 392 | self.assertEqual(result.customer.credit_cards[0].token, "original_tok") 393 | 394 | self.assert_nonself_mapping(braintree.Customer, 'original_id') 395 | self.assert_nonself_mapping(braintree.CreditCard, 'original_tok') 396 | 397 | def test_double_create_causes_error(self): 398 | """Creating a customer twice should return an error from the gateway.""" 399 | 400 | customer_params = copy.copy(self.customer_params_no_id) 401 | customer_params['id'] = 'original_id' 402 | 403 | result = braintree.Customer.create(customer_params) 404 | self.assertTrue(result.is_success, result) 405 | 406 | result = braintree.Customer.create(customer_params) 407 | self.assertFalse(result.is_success, result) 408 | 409 | def test_different_class_stale_state_is_ignored(self): 410 | # Make a failed request that provides a card token. 411 | customer_params = copy.copy(self.customer_params_no_id) 412 | customer_params['credit_card'] = copy.copy(self.card_params_no_token) 413 | customer_params['credit_card']['token'] = 'tok_from_failure' 414 | customer_params['credit_card']['cvv'] = 'invalid cvv' 415 | 416 | result = braintree.Customer.create(customer_params) 417 | self.assertFalse(result.is_success, result) 418 | 419 | # Make a good request without a card token. 420 | result = braintree.Customer.create(self.customer_params_no_id) 421 | self.assertTrue(result.is_success, result) 422 | customer_id = result.customer.id 423 | card_params = self.card_params_no_token 424 | card_params['customer_id'] = customer_id 425 | result = braintree.CreditCard.create(self.card_params_no_token) 426 | self.assertTrue(result.is_success, result) 427 | 428 | # The token from the failed request shouldn't be returned. 429 | self.assertNotEqual(result.credit_card.token, 'tok_from_failure') 430 | 431 | 432 | class PatchTransactionCreate(PatchCreateTest): 433 | def setUp(self): 434 | super(PatchTransactionCreate, self).setUp() 435 | self.txn_params_no_id = { 436 | 'amount': '10.00', 437 | 'order_id': str(uuid.uuid4()) # sidestep duplicate transaction validation 438 | } 439 | 440 | def test_create_patch_with_customer_card_and_token(self): 441 | params = self.txn_params_no_id 442 | params['id'] = 'orig_txn_id' 443 | params['amount'] = '10.00' 444 | params['credit_card'] = self.card_params_no_token 445 | params['credit_card']['token'] = 'orig_cc_tok' 446 | params['customer'] = self.customer_params_no_id 447 | params['customer']['id'] = 'orig_cust_id' 448 | 449 | result = braintree.Transaction.sale(params) 450 | 451 | self.assertTrue(result.is_success, result) 452 | self.assertEqual(result.transaction.id, "orig_txn_id") 453 | self.assertEqual(result.transaction.customer_details.id, "orig_cust_id") 454 | self.assertEqual(result.transaction.credit_card_details.token, "orig_cc_tok") 455 | 456 | self.assert_nonself_mapping(braintree.Customer, 'orig_cust_id') 457 | self.assert_nonself_mapping(braintree.CreditCard, 'orig_cc_tok') 458 | self.assert_nonself_mapping(braintree.Transaction, 'orig_txn_id') 459 | 460 | def test_create_with_existing_card(self): 461 | customer_params = copy.copy(self.customer_params_no_id) 462 | customer_params['id'] = 'orig_cust_id' 463 | 464 | result = braintree.Customer.create(customer_params) 465 | self.assertTrue(result.is_success, result) 466 | customer_id = result.customer.id 467 | 468 | card_params = self.card_params_no_token 469 | card_params['token'] = 'orig_cc_tok' 470 | card_params['customer_id'] = customer_id 471 | result = braintree.CreditCard.create(card_params) 472 | self.assertTrue(result.is_success, result) 473 | card_tok = result.credit_card.token 474 | 475 | txn_params = self.txn_params_no_id 476 | txn_params['payment_method_token'] = card_tok 477 | txn_params['id'] = 'orig_txn_id' 478 | txn_params['customer_id'] = customer_id 479 | 480 | result = braintree.Transaction.sale(txn_params) 481 | self.assertTrue(result.is_success) 482 | self.assertEqual(result.transaction.id, "orig_txn_id") 483 | self.assertEqual(result.transaction.customer_details.id, "orig_cust_id") 484 | self.assertEqual(result.transaction.credit_card_details.token, "orig_cc_tok") 485 | 486 | self.assert_nonself_mapping(braintree.Customer, 'orig_cust_id') 487 | self.assert_nonself_mapping(braintree.CreditCard, 'orig_cc_tok') 488 | self.assert_nonself_mapping(braintree.Transaction, 'orig_txn_id') 489 | 490 | 491 | class PatchCreditCardCreate(PatchCreateTest): 492 | def setUp(self): 493 | super(PatchCreditCardCreate, self).setUp() 494 | 495 | # cards can only be added to existing customers 496 | result = braintree.Customer.create(self.customer_params_no_id) 497 | self.assertTrue(result.is_success, result) 498 | self.customer_id = result.customer.id 499 | 500 | def test_create_with_token(self): 501 | params = copy.copy(self.card_params_no_token) 502 | params['customer_id'] = self.customer_id 503 | params['token'] = 'orig_tok' 504 | 505 | result = braintree.CreditCard.create(params) 506 | 507 | self.assertTrue(result.is_success, result) 508 | self.assertEqual(result.credit_card.token, "orig_tok") 509 | self.assert_nonself_mapping(braintree.CreditCard, 'orig_tok') 510 | 511 | def test_create_no_token(self): 512 | params = copy.copy(self.card_params_no_token) 513 | params['customer_id'] = self.customer_id 514 | 515 | result = braintree.CreditCard.create(params) 516 | 517 | self.assertTrue(result.is_success, result) 518 | server_tok = result.credit_card.token 519 | self.assert_self_mapping(braintree.CreditCard, server_tok) 520 | 521 | def test_same_class_stale_state_is_ignored(self): 522 | """Ensure creation ids from failed requests don't stick around.""" 523 | 524 | params = copy.copy(self.card_params_no_token) 525 | 526 | params['token'] = 'first_id' 527 | result = braintree.CreditCard.create(params) 528 | self.assertFalse(result.is_success, result) 529 | 530 | params['token'] = 'second_id' 531 | params['customer_id'] = self.customer_id 532 | result = braintree.CreditCard.create(params) 533 | self.assertTrue(result.is_success, result) 534 | 535 | self.assertTrue('first_id' not in self.id_maps()[braintree.CreditCard].fake_id_for) 536 | self.assertTrue('second_id' in self.id_maps()[braintree.CreditCard].fake_id_for) 537 | 538 | 539 | class PatchAdvancedSearch(NamespaceTest): 540 | def test_customer_advanced_search_on_id(self): 541 | with self.assertRaises(NamespaceError): 542 | braintree.Customer.search( 543 | braintree.CustomerSearch.id == 'my_id' 544 | ) 545 | 546 | def test_customer_advanced_search_on_payment_method_token(self): 547 | with self.assertRaises(NamespaceError): 548 | braintree.Customer.search( 549 | braintree.CustomerSearch.payment_method_token == 'my_tok' 550 | ) 551 | 552 | def test_customer_advanced_search_on_payment_method_token_with_duplicates(self): 553 | with self.assertRaises(NamespaceError): 554 | braintree.Customer.search( 555 | braintree.CustomerSearch.payment_method_token_with_duplicates == 'my_tok' 556 | ) 557 | 558 | def test_transaction_advanced_search_on_id(self): 559 | with self.assertRaises(NamespaceError): 560 | braintree.Transaction.search( 561 | braintree.TransactionSearch.id == 'my_id' 562 | ) 563 | 564 | def test_transaction_advanced_search_on_payment_method_token(self): 565 | with self.assertRaises(NamespaceError): 566 | braintree.Transaction.search( 567 | braintree.TransactionSearch.payment_method_token == 'my_tok' 568 | ) 569 | 570 | def test_transaction_advanced_search_on_customer_id(self): 571 | with self.assertRaises(NamespaceError): 572 | braintree.Transaction.search( 573 | braintree.TransactionSearch.customer_id == 'my_id' 574 | ) 575 | 576 | 577 | class PatchClientTokenGenerate(NamespaceTest): 578 | def test_client_token_generate_with_customer_id(self): 579 | result = braintree.Customer.create({ 580 | "id": "customer_id", 581 | "first_name": "Jen", 582 | "last_name": "Smith", 583 | "company": "Braintree", 584 | "email": "jen@example.com", 585 | "phone": "312.555.1234", 586 | "fax": "614.555.5678", 587 | "website": "www.example.com" 588 | }) 589 | self.assertTrue(result.is_success, result) 590 | client_token = braintree.ClientToken.generate({'customer_id': 'customer_id'}) 591 | self.assertIsNotNone(client_token) 592 | 593 | 594 | class PatchAllTest(TestCase): 595 | @staticmethod 596 | def _get_current_methods(): 597 | return [ 598 | braintree.Customer.__init__, 599 | braintree.Customer.find, 600 | braintree.Customer.create, 601 | braintree.Customer.delete, 602 | braintree.Customer.update, 603 | braintree.CreditCard.__init__, 604 | braintree.CreditCard.find, 605 | braintree.CreditCard.create, 606 | braintree.CreditCard.delete, 607 | braintree.CreditCard.update, 608 | braintree.Transaction.__init__, 609 | braintree.Transaction.find, 610 | braintree.Transaction.create, 611 | ] 612 | 613 | @staticmethod 614 | def _get_current_search_nodes(): 615 | return [ 616 | braintree.CustomerSearch.id, 617 | braintree.CustomerSearch.payment_method_token, 618 | braintree.CustomerSearch.payment_method_token_with_duplicates, 619 | braintree.TransactionSearch.id, 620 | braintree.TransactionSearch.payment_method_token, 621 | braintree.TransactionSearch.customer_id, 622 | ] 623 | 624 | def test_schema_methods_get_patched(self): 625 | original_methods = self._get_current_methods() 626 | 627 | with Namespace(): 628 | patched_methods = self._get_current_methods() 629 | 630 | unpatched_methods = self._get_current_methods() 631 | 632 | for original_method, patched_method, unpatched_method in \ 633 | zip(original_methods, patched_methods, unpatched_methods): 634 | self.assertEqual(original_method, unpatched_method) 635 | self.assertNotEqual(original_method, patched_method) 636 | 637 | def test_advanced_search_gets_patched(self): 638 | original_nodes = self._get_current_search_nodes() 639 | 640 | with Namespace(): 641 | patched_nodes = self._get_current_search_nodes() 642 | 643 | unpatched_nodes = self._get_current_search_nodes() 644 | 645 | # NamespaceError is raised on __getattribute__ for patched nodes. 646 | for orig_node, unpatched_node in zip(original_nodes, unpatched_nodes): 647 | self.assertIs(orig_node, unpatched_node) 648 | self.assertIsNone(getattr(orig_node, 'foo', None)) # should not raise NamespaceError 649 | 650 | for node in patched_nodes: 651 | with self.assertRaises(NamespaceError): 652 | node.foo 653 | 654 | if __name__ == '__main__': 655 | main() 656 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests in multiple virtualenvs. This configuration file helps 2 | # to run the test suite against different combinations of libraries and Python versions. 3 | # To use it locally, "pip install tox" and then run "tox --skip-missing-interpreters" from this directory. 4 | 5 | [tox] 6 | isolated_build = true 7 | minversion = 3.24.3 8 | envlist = py{27,35,37} 9 | 10 | [gh-actions] 11 | # Mapping of Python versions (MAJOR.MINOR) to Tox factors. 12 | # When running Tox inside GitHub Actions, the `tox-gh-actions` plugin automatically: 13 | # 1. Identifies the Python version used to run Tox. 14 | # 2. Determines the corresponding Tox factor for that Python version, based on the `python` mapping below. 15 | # 3. Narrows down the Tox `envlist` to environments that match the factor. 16 | # For more details, please see the `tox-gh-actions` README [0] and architecture documentation [1]. 17 | # [0] https://github.com/ymyzk/tox-gh-actions/tree/v2.8.1 18 | # [1] https://github.com/ymyzk/tox-gh-actions/blob/v2.8.1/ARCHITECTURE.md 19 | python = 20 | 2.7: py27 21 | 3.5: py35 22 | 3.7: py37 23 | 24 | [testenv] 25 | usedevelop = true 26 | deps = -rdev-requirements.txt 27 | commands = pytest -vv {posargs} 28 | passenv = 29 | BT_MERCHANT_ID 30 | BT_PUBLIC_KEY 31 | BT_PRIVATE_KEY 32 | --------------------------------------------------------------------------------