From db93c371a3805416479c908e909fa31efd6b89a1 Mon Sep 17 00:00:00 2001 From: Robert Coup Date: Thu, 8 Feb 2018 15:20:21 +0000 Subject: [PATCH 1/2] Simple global request mocking capability --- chargebee/__init__.py | 2 + chargebee/compat.py | 8 +-- chargebee/environment.py | 100 ++++++++++++++++++++++++++++++++++++++ chargebee/http_request.py | 14 ++++-- chargebee/main.py | 19 +++++++- 5 files changed, 134 insertions(+), 9 deletions(-) diff --git a/chargebee/__init__.py b/chargebee/__init__.py index ac2deef..b688fc3 100644 --- a/chargebee/__init__.py +++ b/chargebee/__init__.py @@ -8,3 +8,5 @@ def configure(api_key, site): 'api_key': api_key, 'site': site, }) + +mock = ChargeBee.mock diff --git a/chargebee/compat.py b/chargebee/compat.py index dd4808f..2618923 100644 --- a/chargebee/compat.py +++ b/chargebee/compat.py @@ -15,10 +15,10 @@ if py_major_v < 3: from urllib import urlencode - from urlparse import urlparse + from urlparse import urlparse, urlsplit, parse_qs from urllib2 import urlopen as _urlopen, Request elif py_major_v >= 3: - from urllib.parse import urlencode, urlparse + from urllib.parse import urlencode, urlparse, urlsplit, parse_qs from urllib.request import urlopen as _urlopen, Request @@ -26,12 +26,12 @@ try: SSLError = None ssl = None - + if Environment.chargebee_domain is None: HTTPSConnection = object else: HTTPConnection = object - + if py_major_v < 3: from httplib import HTTPConnection, HTTPSConnection, HTTPException else: diff --git a/chargebee/environment.py b/chargebee/environment.py index 6cc4e97..6b660fd 100644 --- a/chargebee/environment.py +++ b/chargebee/environment.py @@ -1,3 +1,7 @@ +import re +from collections import namedtuple + + class Environment(object): chargebee_domain = None @@ -15,3 +19,99 @@ def __init__(self, options): def api_url(self, url): return self.api_endpoint + url + + +class MockEnvironment(Environment): + Request = namedtuple('Request', ['method', 'url_path', 'payload']) + + def __init__(self, **options): + params = { + 'api_key': 'mock-api-key', + 'site': 'mock-test', + } + params.update(options) + super(MockEnvironment, self).__init__(params) + + self.clear() + + def clear(self): + """ Clear any expected and previous requests """ + self.expecting = [] + self.requests = [] + + def add(self, url_path, response_body, response_status=200, request_method=None): + """ + Add a mock response. + + url_path is the API path after the API version. eg. '/subscriptions/1mkVvvHQiQMbLBBf/cancel' + It can also be a regex pattern from re.compile() + response_body is the JSON response body to return. + It can be a string, a JSON-serializable dict, or a callable which + will be called with (url_path, payload, method) and should return a (json_body_string, http_status) tuple. + response_status is the HTTP response code to return. + This is ignored if response_body is a callable. + request_method optional HTTP method to check against the request + + >>> with chargebee.mock() as mock: + >>> mock.add('/subscriptions/1mkVvvHQiQMbLBBf/cancel', {"subscription": {...}}) + >>> result = chargebee.Subscription.cancel('1mkVvvHQiQMbLBBf') + >>> subscription = result.subscription + >>> mock.requests + [Request(method='POST', url_path='/subscriptions/1mkVvvHQiQMbLBBf/cancel', payload={})] + >>> chargebee.Subscription.list({}) + AssertionError: No more mock requests left: /subscriptions + """ + self.expecting.append((url_path, request_method, response_body, response_status)) + + def request(self, method, url, payload): + """ + Called for each Chargebee request + + Adds the request content to MockEnvironment.requests + Matches the next expected request and returns the associated content + + Returns a (json_body_string, http_status) tuple + """ + from chargebee.compat import urlsplit, parse_qs, json + + url_obj = urlsplit(url) + try: + url_path = url_obj.path.split('/api/%s' % self.API_VERSION)[1] + except IndexError: + raise ValueError("Request URL (%s) didn't match endpoint %s" % (url, self.api_endpoint)) + + # un-encode the body/querystring payload + payload = parse_qs(payload or url_obj.query, keep_blank_values=True) + + # save to MockEnvironment.requests + self.requests.append(MockEnvironment.Request(method, url_path, payload)) + + # check the next expected request + try: + req_path, req_method, resp_body, resp_status = self.expecting.pop(0) + except IndexError: + raise AssertionError("No more mock requests left: %s" % url_path) + + # Match the URL path + if isinstance(req_path, re._pattern_type): + # url_path is a regex + if not req_path.match(url_path): + raise AssertionError("Request URL %s does not match %s" % (url_path, req_path.pattern)) + elif req_path != url_path: + # url_path is a string + raise AssertionError("Request URL %s != %s" % (url_path, req_path)) + + # Match any request method + if req_method and method != req_method.upper(): + raise AssertionError("Request Method %s != %s (%s)" % (method, req_method, url_path)) + + # Return the response + if callable(resp_body): + # response_body is a callable, use it to generate the response + return resp_body(url_path, payload, method) + elif isinstance(resp_body, (dict, list, tuple)): + # response body is a dict/sequence, JSON-encode it + return (json.dumps(resp_body), resp_status) + else: + # string body + return (resp_body, resp_status) diff --git a/chargebee/http_request.py b/chargebee/http_request.py index e011acc..a21c589 100644 --- a/chargebee/http_request.py +++ b/chargebee/http_request.py @@ -2,9 +2,10 @@ import platform from chargebee import APIError,PaymentError,InvalidRequestError,OperationFailedError, compat from chargebee.main import ChargeBee -from chargebee.main import Environment +from chargebee.main import Environment, MockEnvironment from chargebee.version import VERSION + def _basic_auth_str(username): return 'Basic ' + base64.b64encode(('%s:' % username).encode('latin1')).strip().decode('latin1') @@ -23,6 +24,11 @@ def request(method, url, env, params=None, headers=None): payload = compat.urlencode(params) headers['Content-type'] = 'application/x-www-form-urlencoded' + if isinstance(env, MockEnvironment): + # we're running in a testing environment. It will provide responses + data, resp_status = env.request(method.upper(), url, payload) + return process_response(url, data, resp_status) + headers.update({ 'User-Agent': 'ChargeBee-Python-Client v%s' % VERSION, 'Accept': 'application/json', @@ -39,8 +45,8 @@ def request(method, url, env, params=None, headers=None): if Environment.protocol == "https": connection = compat.HTTPSConnection(meta.netloc) else: - connection = compat.HTTPConnection(meta.netloc) - + connection = compat.HTTPConnection(meta.netloc) + connection.request(method.upper(), meta.path + '?' + meta.query, payload, headers) try: response = connection.getresponse() @@ -56,7 +62,7 @@ def request(method, url, env, params=None, headers=None): def process_response(url,response, http_code): try: resp_json = compat.json.loads(response) - except Exception as ex: + except Exception as ex: raise Exception("Response not in JSON format. Probably not a chargebee error. \n URL is " + url + "\n Content is \n" + response) if http_code < 200 or http_code > 299: handle_api_resp_error(url,http_code, resp_json) diff --git a/chargebee/main.py b/chargebee/main.py index 630a8a0..eebcb57 100644 --- a/chargebee/main.py +++ b/chargebee/main.py @@ -1,6 +1,7 @@ import os.path +from contextlib import contextmanager -from chargebee.environment import Environment +from chargebee.environment import Environment, MockEnvironment class ChargeBee(object): @@ -13,3 +14,19 @@ class ChargeBee(object): @classmethod def configure(cls, options): cls.default_env = Environment(options) + + @classmethod + @contextmanager + def mock(cls, **options): + """ + Context manager for unit testing environments. + This changes the default environment, so saves passing custom environments around. + + See MockEnvironment for more details & example usage. + """ + prev_env = cls.default_env + try: + cls.default_env = MockEnvironment(**options) + yield cls.default_env + finally: + cls.default_env = prev_env From d6d4a9debf8cfb7c22bdca8279eb3d703f673055 Mon Sep 17 00:00:00 2001 From: Robert Coup Date: Wed, 7 Feb 2018 16:05:48 +0000 Subject: [PATCH 2/2] Add useful __repr__() methods to Models & Result Uses the Model.fields[0] by default, but can be overridden via `Model.repr_field` --- chargebee/model.py | 17 ++++++++++------- chargebee/models/customer.py | 1 + chargebee/models/invoice.py | 2 ++ chargebee/models/payment_source.py | 1 + chargebee/models/subscription.py | 1 + chargebee/models/subscription_estimate.py | 1 + chargebee/result.py | 4 +++- 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/chargebee/model.py b/chargebee/model.py index a75c18d..b572da5 100644 --- a/chargebee/model.py +++ b/chargebee/model.py @@ -2,21 +2,25 @@ class Model(object): - - fields = [] + fields = [] # field list + repr_field = None # field to use for repr(), default is fields[0] def __init__(self, values, sub_types=None, dependant_types=None): if sub_types is None: sub_types = {} if dependant_types is None: dependant_types = {} - + self.values = values self.sub_types = sub_types self.dependant_types = dependant_types for field in self.fields: setattr(self, field, None) + def __repr__(self): + repr_field = self.repr_field or self.fields[0] + return "".format(self.__class__.__name__, repr_field, getattr(self, repr_field)) + def __str__(self): return json.dumps(self.values, indent=4) @@ -40,16 +44,16 @@ def load(self, values): # Returns null for any attribute that starts with cf_ to access the custom fields. def __getattr__(self, name): - if( name[0:3] == "cf_"): + if( name[0:3] == "cf_"): return None - raise AttributeError("Attribute %s not found " % name) + raise AttributeError("Attribute %s not found " % name) @classmethod def construct(cls, values, sub_types=None, dependant_types=None): obj = cls(values, sub_types, dependant_types) obj.load(values) return obj - + def init_dependant(self, obj, type, sub_types={}): if obj.get(type) != None: if isinstance(obj, dict) and type in self.dependant_types: @@ -63,4 +67,3 @@ def init_dependant_list(self, obj, type, sub_types={}): set_val = [self.dependant_types[type].construct(dt, sub_types) for dt in obj[type]] setattr(self, type, set_val) - \ No newline at end of file diff --git a/chargebee/models/customer.py b/chargebee/models/customer.py index 835e369..80a9cb3 100644 --- a/chargebee/models/customer.py +++ b/chargebee/models/customer.py @@ -6,6 +6,7 @@ class Customer(Model): class BillingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass class ReferralUrl(Model): fields = ["external_customer_id", "referral_sharing_url", "created_at", "updated_at", "referral_campaign_id", "referral_account_id", "referral_external_campaign_id", "referral_system"] diff --git a/chargebee/models/invoice.py b/chargebee/models/invoice.py index edcf63d..eef7550 100644 --- a/chargebee/models/invoice.py +++ b/chargebee/models/invoice.py @@ -45,9 +45,11 @@ class Note(Model): pass class ShippingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass class BillingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass fields = ["id", "po_number", "customer_id", "subscription_id", "recurring", "status", "vat_number", \ diff --git a/chargebee/models/payment_source.py b/chargebee/models/payment_source.py index 2f58a9f..9c73f7f 100644 --- a/chargebee/models/payment_source.py +++ b/chargebee/models/payment_source.py @@ -6,6 +6,7 @@ class PaymentSource(Model): class Card(Model): fields = ["first_name", "last_name", "iin", "last4", "brand", "funding_type", "expiry_month", "expiry_year", "billing_addr1", "billing_addr2", "billing_city", "billing_state_code", "billing_state", "billing_country", "billing_zip", "masked_number"] + repr_field = "last4" pass class BankAccount(Model): fields = ["last4", "name_on_account", "bank_name", "mandate_id", "account_type", "echeck_type", "account_holder_type"] diff --git a/chargebee/models/subscription.py b/chargebee/models/subscription.py index 87625d4..fc41eae 100644 --- a/chargebee/models/subscription.py +++ b/chargebee/models/subscription.py @@ -18,6 +18,7 @@ class Coupon(Model): pass class ShippingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass class ReferralInfo(Model): fields = ["referral_code", "coupon_code", "referrer_id", "external_reference_id", "reward_status", "referral_system", "account_id", "campaign_id", "external_campaign_id", "friend_offer_type", "referrer_reward_type", "notify_referral_system", "destination_url", "post_purchase_widget_enabled"] diff --git a/chargebee/models/subscription_estimate.py b/chargebee/models/subscription_estimate.py index 10afcaa..cecaeb4 100644 --- a/chargebee/models/subscription_estimate.py +++ b/chargebee/models/subscription_estimate.py @@ -6,6 +6,7 @@ class SubscriptionEstimate(Model): class ShippingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass fields = ["id", "currency_code", "status", "next_billing_at", "pause_date", "resume_date", \ diff --git a/chargebee/result.py b/chargebee/result.py index 2f6b1ad..04b1aad 100644 --- a/chargebee/result.py +++ b/chargebee/result.py @@ -236,7 +236,7 @@ def invoices(self): def _get_list(self, type, cls, sub_types={}, dependant_types={}, dependant_sub_types={}): if not type in self._response: return None - + set_val = [] for obj in self._response[type]: if isinstance(obj, dict): @@ -260,6 +260,8 @@ def _get(self, type, cls, sub_types=None, dependant_types=None): def __str__(self): return json.dumps(self._response, indent=4) + def __repr__(self): + return "".format(";".join(self._response.keys())) class Content(Result): pass