diff --git a/hubspot3/base.py b/hubspot3/base.py index eb42750..a8aa280 100644 --- a/hubspot3/base.py +++ b/hubspot3/base.py @@ -174,13 +174,13 @@ def _execute_request(self, conn, request): def _digest_result(self, data): if data: + if isinstance(data, bytes): + data = utils.force_utf8(data) if isinstance(data, str): try: data = json.loads(data) except ValueError: pass - elif isinstance(data, bytes): - data = json.loads(utils.force_utf8(data)) return data diff --git a/hubspot3/contacts.py b/hubspot3/contacts.py index 6343ef7..1156841 100644 --- a/hubspot3/contacts.py +++ b/hubspot3/contacts.py @@ -1,11 +1,12 @@ """ hubspot contacts api """ +import warnings +from typing import Union + from hubspot3 import logging_helper from hubspot3.base import BaseClient from hubspot3.utils import prettify -from typing import Union - CONTACTS_API_VERSION = "1" @@ -30,30 +31,35 @@ def __init__(self, *args, **kwargs): def _get_path(self, subpath): return "contacts/v{}/{}".format(CONTACTS_API_VERSION, subpath) - def create_or_update_a_contact(self, email, data=None, **options): - """Creates or Updates a client with the supplied data.""" - data = data or {} + def get_by_id(self, contact_id: str, **options): + """Get contact specified by ID""" return self._call( - "contact/createOrUpdate/email/{email}".format(email=email), - data=data, - method="POST", - **options + "contact/vid/{}/profile".format(contact_id), method="GET", **options ) - def get_contact_by_email(self, email, **options): - """Gets contact specified by email address.""" + def get_by_email(self, email, **options): + """Get contact specified by email address.""" return self._call( "contact/email/{email}/profile".format(email=email), method="GET", **options ) - def get_contact_by_id(self, contact_id: str, **options): - """Gets contact specified by ID""" + def create(self, data=None, **options): + """create a contact""" + data = data or {} + return self._call("contact", data=data, method="POST", **options) + + def create_or_update_by_email(self, email, data=None, **options): + """Create or Updates a client with the supplied data.""" + data = data or {} return self._call( - "contact/vid/{}/profile".format(contact_id), method="GET", **options + "contact/createOrUpdate/email/{email}".format(email=email), + data=data, + method="POST", + **options ) - def update_a_contact(self, contact_id, data=None, **options): - """Updates the contact by contact_id with the given data.""" + def update_by_id(self, contact_id, data=None, **options): + """Update the contact by contact_id with the given data.""" data = data or {} return self._call( "contact/vid/{contact_id}/profile".format(contact_id=contact_id), @@ -62,47 +68,54 @@ def update_a_contact(self, contact_id, data=None, **options): **options ) - def delete_a_contact(self, contact_id: str, **options): - """Deletes a contact by contact_id.""" + def update_by_email(self, email: str, data=None, **options): + """update the concat for the given email address with the given data""" + data = data or {} + + return self._call( + "contact/email/{}/profile".format(email), + data=data, + method="POST", + **options + ) + + def delete_by_id(self, contact_id: str, **options): + """Delete a contact by contact_id.""" return self._call( "contact/vid/{contact_id}".format(contact_id=contact_id), method="DELETE", **options ) - def create(self, data=None, **options): - """create a contact""" - data = data or {} - return self._call("contact", data=data, method="POST", **options) + def merge(self, primary_id: int, secondary_id: int, **options): + """merge the data from the secondary_id into the data of the primary_id""" + data = dict(vidToMerge=secondary_id) - def update(self, contact_id: str, data=None, **options): - """update the given vid with the given data""" - if not data: - data = {} - - return self._call( - "contact/vid/{}/profile".format(contact_id), + self._call( + "contact/merge-vids/{}/".format(primary_id), data=data, method="POST", **options ) + default_batch_properties = [ + "email", + "firstname", + "lastname", + "company", + "website", + "phone", + "address", + "city", + "state", + "zip", + "associatedcompanyid", + ] + def get_batch(self, ids, extra_properties: Union[list, str] = None): """given a batch of vids, get more of their info""" # default properties to fetch - properties = [ - "email", - "firstname", - "lastname", - "company", - "website", - "phone", - "address", - "city", - "state", - "zip", - "associatedcompanyid", - ] + properties = self.default_batch_properties # append extras if they exist if extra_properties: @@ -163,7 +176,7 @@ def _get_recent( **options ): """ - returns a list of either recently created or recently modified/created contacts + return a list of either recently created or recently modified/created contacts """ finished = False output = [] @@ -209,3 +222,51 @@ def get_recently_modified(self, limit: int = 100): :see: https://developers.hubspot.com/docs/methods/contacts/get_recently_updated_contacts """ return self._get_recent(ContactsClient.Recency.MODIFIED, limit=limit) + + def get_contact_by_id(self, contact_id: str, **options): + warnings.warn( + "ContactsClient.get_contact_by_id is deprecated in favor of " + "ContactsClient.get_by_id", + DeprecationWarning, + ) + return self.get_by_id(contact_id, **options) + + def get_contact_by_email(self, email, **options): + warnings.warn( + "ContactsClient.get_contact_by_email is deprecated in favor of " + "ContactsClient.get_by_email", + DeprecationWarning, + ) + return self.get_by_email(email, **options) + + def create_or_update_a_contact(self, email, data=None, **options): + warnings.warn( + "ContactsClient.create_or_update_a_contact is deprecated in favor of " + "ContactsClient.create_or_update_by_email", + DeprecationWarning, + ) + return self.create_or_update_by_email(email, data, **options) + + def update(self, contact_id: str, data=None, **options): + warnings.warn( + "ContactsClient.update is deprecated in favor of " + "ContactsClient.update_by_id", + DeprecationWarning, + ) + return self.update_by_id(contact_id, data, **options) + + def update_a_contact(self, contact_id, data=None, **options): + warnings.warn( + "ContactsClient.update_a_contact is deprecated in favor of " + "ContactsClient.update_by_id", + DeprecationWarning, + ) + return self.update_by_id(contact_id, data, **options) + + def delete_a_contact(self, contact_id: str, **options): + warnings.warn( + "ContactsClient.delete_a_contact is deprecated in favor of " + "ContactsClient.delete_by_id", + DeprecationWarning, + ) + return self.delete_by_id(contact_id, **options) diff --git a/hubspot3/test/conftest.py b/hubspot3/test/conftest.py index fab0b57..55aa567 100644 --- a/hubspot3/test/conftest.py +++ b/hubspot3/test/conftest.py @@ -23,41 +23,29 @@ def assert_num_requests(number): connection.assert_num_requests = assert_num_requests - def assert_has_request(method, url, data=None): + def assert_has_request(method, url, data=None, **params): """ - Assert that at least one request with the exact combination of method, URL and body data - was performed. + Assert that at least one request with the exact combination of method, URL, body data, and + query parameters was performed. """ data = json.dumps(data) if data else None for args, kwargs in connection.request.call_args_list: - if args[0] == method and args[1] == url and args[2] == data: - break - else: - raise AssertionError( - "No {method} request to URL '{url}' with data '{data}' was performed.'".format( - method=method, url=url, data=data - ) - ) - - connection.assert_has_request = assert_has_request - - def assert_query_parameters_in_request(**params): - """ - Assert that at least one request using all of the given query parameters was performed. - """ - for args, kwargs in connection.request.call_args_list: - url = args[1] - if all( - urlencode({name: value}, doseq=True) in url + url_check = args[1] == url if not params else args[1].startswith(url) + params_check = all( + urlencode({name: value}, doseq=True) in args[1] for name, value in params.items() - ): + ) + if args[0] == method and url_check and args[2] == data and params_check: break else: raise AssertionError( - "No request contains all given query parameters: {}".format(params) + ( + "No {method} request to URL '{url}' with data '{data}' and with parameters " + "'{params}' was performed.'" + ).format(method=method, url=url, data=data, params=params) ) - connection.assert_query_parameters_in_request = assert_query_parameters_in_request + connection.assert_has_request = assert_has_request def set_response(status_code, body): """Set the response status code and body for all mocked requests.""" @@ -70,8 +58,8 @@ def set_response(status_code, body): def set_responses(response_tuples): """ Set multiple responses for consecutive mocked requests via tuples of (status code, body). - The first request will result in the first response, the second request in the second - response and so on. + The first request will result in the first response, the second request in the + second response and so on. """ responses = [] for status_code, body in response_tuples: diff --git a/hubspot3/test/test_contacts.py b/hubspot3/test/test_contacts.py index b8050c7..d88933e 100644 --- a/hubspot3/test/test_contacts.py +++ b/hubspot3/test/test_contacts.py @@ -1,112 +1,275 @@ """ testing hubspot3.contacts """ +import json +import warnings +from unittest.mock import Mock, patch + import pytest -from hubspot3.contacts import ContactsClient -from hubspot3.error import HubspotConflict, HubspotNotFound, HubspotBadRequest -from hubspot3.test.globals import TEST_KEY - - -CONTACTS = ContactsClient(TEST_KEY) - -# since we need to have an id to submit and to attempt to get a contact, -# we need to be hacky here and fetch one upon loading this file. -BASE_CONTACT = CONTACTS.get_all(limit=1)[0] - - -def test_create_contact(): - """ - attempts to create a contact via the demo api - :see: https://developers.hubspot.com/docs/methods/contacts/create_contact - """ - with pytest.raises(HubspotBadRequest): - CONTACTS.create(data={}) - - with pytest.raises(HubspotConflict): - CONTACTS.create( - data={ - "properties": [ - {"property": "email", "value": BASE_CONTACT["email"]}, - {"property": "firstname", "value": "Adrian"}, - {"property": "lastname", "value": "Mott"}, - {"property": "website", "value": "http://hubspot.com"}, - {"property": "company", "value": "HubSpot"}, - {"property": "phone", "value": "555-122-2323"}, - {"property": "address", "value": "25 First Street"}, - {"property": "city", "value": "Cambridge"}, - {"property": "state", "value": "MA"}, - {"property": "zip", "value": "02139"}, - ] - } + +from hubspot3 import contacts + + +@pytest.fixture +def contacts_client(mock_connection): + client = contacts.ContactsClient(disable_auth=True) + client.options["connection_type"] = Mock(return_value=mock_connection) + return client + + +class TestContactsClient(object): + def test_get_path(self): + client = contacts.ContactsClient(disable_auth=True) + subpath = "contact" + assert client._get_path(subpath) == "contacts/v1/contact" + + def test_create_or_update_by_email(self, contacts_client, mock_connection): + email = "mail@email.com" + data = {"properties": [{"property": "firstname", "value": "hub"}]} + response_body = {"isNew": False, "vid": 3234574} + mock_connection.set_response(200, json.dumps(response_body)) + resp = contacts_client.create_or_update_by_email(email, data) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "POST", "/contacts/v1/contact/createOrUpdate/email/{}?".format(email), data + ) + assert resp == response_body + + def test_get_by_email(self, contacts_client, mock_connection): + email = "mail@email.com" + response_body = { + "vid": 3234574, + "canonical-vid": 3234574, + "merged-vids": [], + "portal-id": 62515, + "is-contact": True, + "profile-token": "AO_T-m", + "profile-url": "https://app.hubspot.com/contacts/62515/lists/public/contact/_AO_T-m/", + "properties": {}, + } + mock_connection.set_response(200, json.dumps(response_body)) + resp = contacts_client.get_by_email(email) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "GET", "/contacts/v1/contact/email/{}/profile?".format(email) + ) + assert resp == response_body + + def test_get_by_id(self, contacts_client, mock_connection): + vid = "234" + response_body = { + "vid": 3234574, + "canonical-vid": 3234574, + "merged-vids": [], + "portal-id": 62515, + "is-contact": True, + "profile-token": "AO_T-m", + "profile-url": "https://app.hubspot.com/contacts/62515/lists/public/contact/_AO_T-m/", + "properties": {}, + } + mock_connection.set_response(200, json.dumps(response_body)) + resp = contacts_client.get_by_id(vid) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "GET", "/contacts/v1/contact/vid/{}/profile?".format(vid) + ) + assert resp == response_body + + def test_update_by_id(self, contacts_client, mock_connection): + contact_id = "234" + data = {"properties": [{"property": "firstname", "value": "hub"}]} + response_body = "" + mock_connection.set_response(204, json.dumps(response_body)) + resp = contacts_client.update_by_id(contact_id, data) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "POST", "/contacts/v1/contact/vid/{}/profile?".format(contact_id), data ) + assert resp == response_body + def test_delete_by_id(self, contacts_client, mock_connection): + contact_id = "234" + response_body = {"vid": contact_id, "deleted": True, "reason": "OK"} + mock_connection.set_response(204, json.dumps(response_body)) + resp = contacts_client.delete_by_id(contact_id) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "DELETE", "/contacts/v1/contact/vid/{}?".format(contact_id) + ) + assert resp == response_body + + def test_create(self, contacts_client, mock_connection): + data = {"properties": [{"property": "email", "value": "hub@spot.com"}]} + response_body = { + "identity-profiles": [ + { + "identities": [ + { + "timestamp": 1331075050646, + "type": "EMAIL", + "value": "fumanchu@hubspot.com", + }, + { + "timestamp": 1331075050681, + "type": "LEAD_GUID", + "value": "22a26060-c9d7-44b0-9f07-aa40488cfa3a", + }, + ], + "vid": 61574, + } + ], + "properties": {}, + } + mock_connection.set_response(200, json.dumps(response_body)) + resp = contacts_client.create(data) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request("POST", "/contacts/v1/contact?", data) + assert resp == response_body + + def test_update_by_email(self, contacts_client, mock_connection): + email = "mail@email.com" + data = {"properties": [{"property": "firstname", "value": "hub"}]} + response_body = "" + mock_connection.set_response(204, json.dumps(response_body)) + resp = contacts_client.update_by_email(email, data) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "POST", "/contacts/v1/contact/email/{}/profile?".format(email), data + ) + assert resp == response_body + + def test_merge(self, contacts_client, mock_connection): + primary_id, secondary_id = 10, 20 + mock_connection.set_response(200, "SUCCESS") + resp = contacts_client.merge(primary_id, secondary_id) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "POST", + "/contacts/v1/contact/merge-vids/{}/?".format(primary_id), + dict(vidToMerge=secondary_id), + ) + assert resp is None + + @pytest.mark.parametrize( + "limit, query_limit, extra_properties", + [ + (0, 100, None), + (10, 10, None), + (20, 20, None), + (150, 100, None), # keep query limit at max 100 + (10, 10, ["hs_analytics_last_url", "hs_analytics_revenue"]), + (10, 10, "lead_source"), + ], + ) + def test_get_all( + self, contacts_client, mock_connection, limit, query_limit, extra_properties + ): + response_body = { + "contacts": [ + { + "addedAt": 1390574181854, + "vid": 204727, + "canonical-vid": 204727, + "merged-vids": [], + "properties": {}, + } + ], + "has-more": False, + "vid-offset": 204727, + } + get_batch_return = [dict(id=204727 + i) for i in range(30)] + get_batch_mock = Mock(return_value=get_batch_return) + contacts_client.get_batch = get_batch_mock + mock_connection.set_response(200, json.dumps(response_body)) + resp = contacts_client.get_all(limit=limit, extra_properties=extra_properties) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "GET", "/contacts/v1/lists/all/contacts/all", count=query_limit, vidOffset=0 + ) + assert resp == get_batch_return if not limit else get_batch_return[:limit] + get_batch_mock.assert_called_once_with( + [contact["vid"] for contact in response_body["contacts"]], + extra_properties=extra_properties, + ) + + @pytest.mark.parametrize( + "extra_properties_given, extra_properties_as_list", + [ + (None, []), + ("lead_source", ["lead_source"]), + ( + ["hs_analytics_last_url", "hs_analytics_revenue"], + ["hs_analytics_last_url", "hs_analytics_revenue"], + ), + ], + ) + def test_get_batch( + self, + contacts_client, + mock_connection, + extra_properties_given, + extra_properties_as_list, + ): + response_body = { + "3234574": {"vid": 3234574, "canonical-vid": 3234574, "properties": {}}, + "3234575": {"vid": 3234575, "canonical-vid": 3234575, "properties": {}}, + } + ids = ["3234574", "3234575"] + properties = contacts_client.default_batch_properties.copy() + properties.extend(extra_properties_as_list) + params = {"property": p for p in properties} + params.update({"vid": vid for vid in ids}) + + mock_connection.set_response(200, json.dumps(response_body)) + resp = contacts_client.get_batch(ids, extra_properties_given) + mock_connection.assert_num_requests(1) + mock_connection.assert_has_request( + "GET", "/contacts/v1/contact/vids/batch", **params + ) + assert len(resp) == 2 + assert {"id": 3234574} in resp + assert {"id": 3234575} in resp -def _is_contact(contact: dict) -> bool: - """tests some stuff on the contact return object""" - assert contact - assert contact["is-contact"] - assert contact["vid"] - assert contact["properties"] - return True - - -def test_get_contact_by_email(): - """ - attempts to fetch a contact via email - :see: https://developers.hubspot.com/docs/methods/contacts/get_contact_by_email - """ - with pytest.raises(HubspotNotFound): - contact = CONTACTS.get_contact_by_email("thisemaildoesnotexist@test.com") - - contact = CONTACTS.get_contact_by_email(BASE_CONTACT["email"]) - assert _is_contact(contact) - - -def test_get_contact_by_id(): - """ - attempts to fetch a contact via id - :see: https://developers.hubspot.com/docs/methods/contacts/get_contact_by_id - """ - with pytest.raises(HubspotNotFound): - contact = CONTACTS.get_contact_by_id("-1") - - # since their demo data api doesn't seem stable, attempt to find one - contact = CONTACTS.get_contact_by_email(BASE_CONTACT["email"]) - contact_check = CONTACTS.get_contact_by_id(contact["vid"]) - assert _is_contact(contact) - assert _is_contact(contact_check) - - -def test_get_all(): - """ - tests getting all contacts - :see: https://developers.hubspot.com/docs/methods/contacts/get_contacts - """ - contacts = CONTACTS.get_all(limit=20) - assert contacts - assert len(contacts) <= 20 - assert contacts[0] - assert contacts[0]["email"] - assert contacts[0]["id"] - - -def test_get_recently_created(): - """ - gets recently created deals - :see: https://developers.hubspot.com/docs/methods/contacts/get_recently_created_contacts - """ - new_contacts = CONTACTS.get_recently_created(limit=20) - assert new_contacts - assert len(new_contacts) <= 20 - assert _is_contact(new_contacts[0]) - - -def test_get_recently_modified(): - """ - gets recently modified deals - :see: https://developers.hubspot.com/docs/methods/contacts/get_recently_updated_contacts - """ - modified_contacts = CONTACTS.get_recently_created(limit=20) - assert modified_contacts - assert len(modified_contacts) <= 20 - assert _is_contact(modified_contacts[0]) + @pytest.mark.parametrize( + "deprecated_name, new_name, args, kwargs", + [ + ("get_contact_by_id", "get_by_id", ("123",), {"timeout": 5}), + ( + "get_contact_by_email", + "get_by_email", + ("test@mail.com",), + {"timeout": 5}, + ), + ( + "create_or_update_a_contact", + "create_or_update_by_email", + ("test@mail.com", {"properties": []}), + {"timeout": 5}, + ), + ("update", "update_by_id", ("123", {"properties": []}), {"timeout": 5}), + ( + "update_a_contact", + "update_by_id", + ("123", {"properties": []}), + {"timeout": 5}, + ), + ("delete_a_contact", "delete_by_id", ("123",), {"timeout": 5}), + ], + ) + def test_deprecated_methods( + self, contacts_client, deprecated_name, new_name, args, kwargs + ): + with patch.object(contacts_client, new_name) as new_method_mock: + deprecated_method = getattr(contacts_client, deprecated_name) + with warnings.catch_warnings(record=True) as warning_instance: + deprecated_method(*args, **kwargs) + new_method_mock.assert_called_once_with(*args, **kwargs) + assert len(warning_instance) == 1 + assert issubclass(warning_instance[-1].category, DeprecationWarning) + message = str(warning_instance[-1].message) + assert ( + "{old_name} is deprecated".format(old_name=deprecated_name) + in message + ) + new_name_part = message.find("favor of") + assert new_name in message[new_name_part:] diff --git a/hubspot3/test/test_ecommerce_bridge.py b/hubspot3/test/test_ecommerce_bridge.py index aed3d89..0ce86fc 100644 --- a/hubspot3/test/test_ecommerce_bridge.py +++ b/hubspot3/test/test_ecommerce_bridge.py @@ -145,7 +145,9 @@ def test_get_sync_errors( if object_type: common_params["objectType"] = object_type for page in expected_pages: - mock_connection.assert_query_parameters_in_request( + mock_connection.assert_has_request( + "GET", + "/extensions/ecomm/v2/{}".format(subpath), **dict(common_params, page=page) )