diff --git a/sources/hubspot/__init__.py b/sources/hubspot/__init__.py index 60f1ee5ed..dfc61fcc1 100644 --- a/sources/hubspot/__init__.py +++ b/sources/hubspot/__init__.py @@ -26,30 +26,33 @@ from typing import Any, Dict, List, Literal, Sequence, Iterator from urllib.parse import quote +from itertools import chain import dlt from dlt.common import pendulum -from dlt.common.typing import TDataItems +from dlt.common.typing import TDataItems, TDataItem from dlt.extract.source import DltResource -from .helpers import fetch_data - +from .helpers import ( + fetch_data, + _get_property_names, + fetch_property_history, +) from .settings import ( STARTDATE, - CRM_CONTACTS_ENDPOINT, - CRM_COMPANIES_ENDPOINT, - CRM_DEALS_ENDPOINT, - CRM_TICKETS_ENDPOINT, - CRM_PRODUCTS_ENDPOINT, WEB_ANALYTICS_EVENTS_ENDPOINT, - CRM_QUOTES_ENDPOINT, + OBJECT_TYPE_SINGULAR, + CRM_OBJECT_ENDPOINTS, + OBJECT_TYPE_PLURAL, ) THubspotObjectType = Literal["company", "contact", "deal", "ticket", "product", "quote"] @dlt.source(name="hubspot") -def hubspot() -> Sequence[DltResource]: +def hubspot( + api_key: str = dlt.secrets.value, include_history: bool = False +) -> Sequence[DltResource]: """ A DLT source that retrieves data from the HubSpot API using the specified API key. @@ -58,6 +61,7 @@ def hubspot() -> Sequence[DltResource]: Args: api_key (str, optional): The API key used to authenticate with the HubSpot API. Defaults to dlt.secrets.value. + include_history: Whether to load history of property changes along with entities. The history entries are loaded to separate tables. Returns: tuple: A tuple of Dlt resources, one for each HubSpot API endpoint. @@ -67,49 +71,84 @@ def hubspot() -> Sequence[DltResource]: is passed to `fetch_data` as the `api_key` argument. """ return [ - companies(), - contacts(), - deals(), - tickets(), - products(), - quotes(), + companies(include_history=include_history), + contacts(include_history=include_history), + deals(include_history=include_history), + tickets(include_history=include_history), + products(include_history=include_history), + quotes(include_history=include_history), ] +def crm_objects( + object_type: str, + api_key: str = dlt.secrets.value, + include_history: bool = False, +) -> Iterator[TDataItems]: + """Building blocks for CRM resources.""" + props = ",".join(_get_property_names(api_key, object_type)) + params = {"properties": props, "limit": 100} + + yield from fetch_data(CRM_OBJECT_ENDPOINTS[object_type], api_key, params=params) + if include_history: + # Get history separately, as requesting both all properties and history together + # is likely to hit hubspot's URL length limit + for history_entries in fetch_property_history( + CRM_OBJECT_ENDPOINTS[object_type], + api_key, + props, + ): + yield dlt.mark.with_table_name( + history_entries, OBJECT_TYPE_PLURAL[object_type] + "_property_history" + ) + + @dlt.resource(name="companies", write_disposition="replace") -def companies(api_key: str = dlt.secrets.value) -> Iterator[TDataItems]: +def companies( + api_key: str = dlt.secrets.value, include_history: bool = False +) -> Iterator[TDataItems]: """Hubspot companies resource""" - yield from fetch_data(CRM_COMPANIES_ENDPOINT, api_key=api_key) + yield from crm_objects("company", api_key, include_history=False) @dlt.resource(name="contacts", write_disposition="replace") -def contacts(api_key: str = dlt.secrets.value) -> Iterator[TDataItems]: +def contacts( + api_key: str = dlt.secrets.value, include_history: bool = False +) -> Iterator[TDataItems]: """Hubspot contacts resource""" - yield from fetch_data(CRM_CONTACTS_ENDPOINT, api_key=api_key) + yield from crm_objects("contact", api_key, include_history) @dlt.resource(name="deals", write_disposition="replace") -def deals(api_key: str = dlt.secrets.value) -> Iterator[TDataItems]: +def deals( + api_key: str = dlt.secrets.value, include_history: bool = False +) -> Iterator[TDataItems]: """Hubspot deals resource""" - yield from fetch_data(CRM_DEALS_ENDPOINT, api_key=api_key) + yield from crm_objects("deal", api_key, include_history) @dlt.resource(name="tickets", write_disposition="replace") -def tickets(api_key: str = dlt.secrets.value) -> Iterator[TDataItems]: +def tickets( + api_key: str = dlt.secrets.value, include_history: bool = False +) -> Iterator[TDataItems]: """Hubspot tickets resource""" - yield from fetch_data(CRM_TICKETS_ENDPOINT, api_key=api_key) + yield from crm_objects("ticket", api_key, include_history) @dlt.resource(name="products", write_disposition="replace") -def products(api_key: str = dlt.secrets.value) -> Iterator[TDataItems]: +def products( + api_key: str = dlt.secrets.value, include_history: bool = False +) -> Iterator[TDataItems]: """Hubspot products resource""" - yield from fetch_data(CRM_PRODUCTS_ENDPOINT, api_key=api_key) + yield from crm_objects("product", api_key, include_history) @dlt.resource(name="quotes", write_disposition="replace") -def quotes(api_key: str = dlt.secrets.value) -> Iterator[TDataItems]: +def quotes( + api_key: str = dlt.secrets.value, include_history: bool = False +) -> Iterator[TDataItems]: """Hubspot quotes resource""" - yield from fetch_data(CRM_QUOTES_ENDPOINT, api_key=api_key) + yield from crm_objects("quote", api_key, include_history) @dlt.resource diff --git a/sources/hubspot/helpers.py b/sources/hubspot/helpers.py index 0417d561d..946d6b6ba 100644 --- a/sources/hubspot/helpers.py +++ b/sources/hubspot/helpers.py @@ -1,15 +1,17 @@ """Hubspot source helpers""" import urllib.parse -from typing import Generator, Dict, Any, List +from typing import Iterator, Dict, Any, List, Optional, Iterable, Tuple from dlt.sources.helpers import requests +from .settings import OBJECT_TYPE_PLURAL BASE_URL = "https://api.hubapi.com/" -def get_url(endpoint: str, **kwargs: Any) -> str: - return urllib.parse.urljoin(BASE_URL, endpoint.format(**kwargs)) +def get_url(endpoint: str) -> str: + """Get absolute hubspot endpoint URL""" + return urllib.parse.urljoin(BASE_URL, endpoint) def _get_headers(api_key: str) -> Dict[str, str]: @@ -28,76 +30,75 @@ def _get_headers(api_key: str) -> Dict[str, str]: return dict(authorization=f"Bearer {api_key}") -def _parse_response( - r: requests.Response, **kwargs: str -) -> Generator[List[Dict[str, Any]], None, None]: - """ - Parse a JSON response from HUBSPOT and yield the properties of each result. +def extract_property_history(objects: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]: + for item in objects: + history = item.get("propertiesWithHistory") + if not history: + return + # Yield a flat list of property history entries + for key, changes in history.items(): + if not changes: + continue + for entry in changes: + yield {"object_id": item["id"], "property_name": key, **entry} + + +def fetch_property_history( + endpoint: str, api_key: str, props: str, params: Optional[Dict[str, Any]] = None +) -> Iterator[List[Dict[str, Any]]]: + """Fetch property history from the given CRM endpoint. Args: - r (requests.Response): The response object from the API call. - **kwargs: Additional keyword arguments to pass to the `fetch_data` function. + endpoint: The endpoint to fetch data from, as a string. + api_key: The API key to use for authentication, as a string. + props: A comma separated list of properties to retrieve the history for + params: Optional dict of query params to include in the request Yields: - dict: The properties of each result in the API response. - - Notes: - This method assumes that the API response is in JSON format, and that the results are contained - within the "results" key of the JSON object. If the response does not contain any results, a - `ValueError` will be raised. + List of property history entries (dicts) + """ + # Construct the URL and headers for the API request + url = get_url(endpoint) + headers = _get_headers(api_key) - If the response contains pagination information in the "paging" key of the JSON object, this method - will follow the "next" link in the pagination information and yield the properties of each result in - the subsequent pages. The `fetch_data` function is used to retrieve the subsequent pages, and any - additional keyword arguments passed to this method will be passed on to the `fetch_data` function. + params = dict(params or {}) + params["propertiesWithHistory"] = props + params["limit"] = 50 + # Make the API request + r = requests.get(url, headers=headers, params=params) + # Parse the API response and yield the properties of each result - """ # Parse the response JSON data _data = r.json() + while _data is not None: + if "results" in _data: + yield list(extract_property_history(_data["results"])) - # Yield the properties of each result in the API response - if "results" in _data: - _objects: List[Dict[str, Any]] = [] - for _result in _data["results"]: - _obj = _result["properties"] - if "associations" in _result: - for association in _result["associations"]: - __values = [ - {"value": _obj["hs_object_id"], f"{association}_id": __r["id"]} - for __r in _result["associations"][association]["results"] - ] - - # remove duplicates from list of dicts - __values = [dict(t) for t in {tuple(d.items()) for d in __values}] - - _obj[association] = __values - _objects.append(_obj) - if _objects: - yield _objects - - # Follow pagination links if they exist - if "paging" in _data: - _next = _data["paging"].get("next", None) + # Follow pagination links if they exist + _next = _data.get("paging", {}).get("next", None) if _next: - # Replace the base URL with an empty string to get the relative URL for the next page - next_url = _next["link"].replace(BASE_URL, "") - # Recursively call the `fetch_data` function to get the next page of results - yield from fetch_data(next_url, **kwargs) + next_url = _next["link"] + # Get the next page response + r = requests.get(next_url, headers=headers) + _data = r.json() + else: + _data = None def fetch_data( - endpoint: str, api_key: str, **kwargs: str -) -> Generator[List[Dict[str, Any]], None, None]: + endpoint: str, api_key: str, params: Optional[Dict[str, Any]] = None +) -> Iterator[List[Dict[str, Any]]]: """ Fetch data from HUBSPOT endpoint using a specified API key and yield the properties of each result. + For paginated endpoint this function yields item from all pages. Args: endpoint (str): The endpoint to fetch data from, as a string. api_key (str): The API key to use for authentication, as a string. - **kwargs: Additional keyword arguments to pass to the `_parse_response` function. + params: Optional dict of query params to include in the request Yields: - List[dict]: The properties of each result in the API response. + A List of CRM object dicts Raises: requests.exceptions.HTTPError: If the API returns an HTTP error status code. @@ -108,19 +109,76 @@ def fetch_data( 404 Not Found), a `requests.exceptions.HTTPError` exception will be raised. The `endpoint` argument should be a relative URL, which will be appended to the base URL for the - API. The `**kwargs` argument is used to pass additional keyword arguments to the `_parse_response` - function, such as any parameters that need to be included in the API request. + API. The `params` argument is used to pass additional query parameters to the request This function also includes a retry decorator that will automatically retry the API call up to 3 times with a 5-second delay between retries, using an exponential backoff strategy. - """ # Construct the URL and headers for the API request - url = get_url(endpoint, **kwargs) + url = get_url(endpoint) headers = _get_headers(api_key) # Make the API request - r = requests.get(url, headers=headers) - + r = requests.get(url, headers=headers, params=params) # Parse the API response and yield the properties of each result - return _parse_response(r, api_key=api_key, **kwargs) + # Parse the response JSON data + _data = r.json() + # Yield the properties of each result in the API response + while _data is not None: + if "results" in _data: + _objects: List[Dict[str, Any]] = [] + for _result in _data["results"]: + _obj = _result.get("properties", _result) + if "id" not in _obj and "id" in _result: + # Move id from properties to top level + _obj["id"] = _result["id"] + if "associations" in _result: + for association in _result["associations"]: + __values = [ + { + "value": _obj["hs_object_id"], + f"{association}_id": __r["id"], + } + for __r in _result["associations"][association]["results"] + ] + + # remove duplicates from list of dicts + __values = [ + dict(t) for t in {tuple(d.items()) for d in __values} + ] + + _obj[association] = __values + _objects.append(_obj) + yield _objects + + # Follow pagination links if they exist + _next = _data.get("paging", {}).get("next", None) + if _next: + next_url = _next["link"] + # Get the next page response + r = requests.get(next_url, headers=headers) + _data = r.json() + else: + _data = None + + +def _get_property_names(api_key: str, object_type: str) -> List[str]: + """ + Retrieve property names for a given entity from the HubSpot API. + + Args: + entity: The entity name for which to retrieve property names. + + Returns: + A list of property names. + + Raises: + Exception: If an error occurs during the API request. + """ + properties = [] + endpoint = f"/crm/v3/properties/{OBJECT_TYPE_PLURAL[object_type]}" + + for page in fetch_data(endpoint, api_key): + properties.extend([prop["name"] for prop in page]) + + return properties diff --git a/sources/hubspot/settings.py b/sources/hubspot/settings.py index 3eeaf4226..87669aa81 100644 --- a/sources/hubspot/settings.py +++ b/sources/hubspot/settings.py @@ -5,11 +5,34 @@ STARTDATE = pendulum.datetime(year=2000, month=1, day=1) CRM_CONTACTS_ENDPOINT = ( - "/crm/v3/objects/contacts?associations=deals,products,tickets,quotes&limit=100" + "/crm/v3/objects/contacts?associations=deals,products,tickets,quotes" ) -CRM_COMPANIES_ENDPOINT = "/crm/v3/objects/companies?associations=contacts,deals,products,tickets,quotes&limit=100" -CRM_DEALS_ENDPOINT = "/crm/v3/objects/deals?limit=100" -CRM_PRODUCTS_ENDPOINT = "/crm/v3/objects/products?limit=100" -CRM_TICKETS_ENDPOINT = "/crm/v3/objects/tickets?limit=100" -CRM_QUOTES_ENDPOINT = "/crm/v3/objects/quotes?limit=100" +CRM_COMPANIES_ENDPOINT = ( + "/crm/v3/objects/companies?associations=contacts,deals,products,tickets,quotes" +) +CRM_DEALS_ENDPOINT = "/crm/v3/objects/deals" +CRM_PRODUCTS_ENDPOINT = "/crm/v3/objects/products" +CRM_TICKETS_ENDPOINT = "/crm/v3/objects/tickets" +CRM_QUOTES_ENDPOINT = "/crm/v3/objects/quotes" + +CRM_OBJECT_ENDPOINTS = { + "contact": CRM_CONTACTS_ENDPOINT, + "company": CRM_COMPANIES_ENDPOINT, + "deal": CRM_DEALS_ENDPOINT, + "product": CRM_PRODUCTS_ENDPOINT, + "ticket": CRM_TICKETS_ENDPOINT, + "quote": CRM_QUOTES_ENDPOINT, +} + WEB_ANALYTICS_EVENTS_ENDPOINT = "/events/v3/events?objectType={objectType}&objectId={objectId}&occurredAfter={occurredAfter}&occurredBefore={occurredBefore}&sort=-occurredAt" + +OBJECT_TYPE_SINGULAR = { + "companies": "company", + "contacts": "contact", + "deals": "deal", + "tickets": "ticket", + "products": "product", + "quotes": "quote", +} + +OBJECT_TYPE_PLURAL = {v: k for k, v in OBJECT_TYPE_SINGULAR.items()} diff --git a/sources/hubspot_pipeline.py b/sources/hubspot_pipeline.py index d93052cd6..0c1308f95 100644 --- a/sources/hubspot_pipeline.py +++ b/sources/hubspot_pipeline.py @@ -27,6 +27,33 @@ def load_crm_data() -> None: print(info) +def load_crm_data_with_history() -> None: + """ + Loads all HubSpot CRM resources and property change history for each entity. + The history entries are loaded to a tables per resource `{resource_name}_property_history`, e.g. `contacts_property_history` + + Returns: + None + """ + + # Create a DLT pipeline object with the pipeline name, dataset name, and destination database type + # Add full_refresh=(True or False) if you need your pipeline to create the dataset in your destination + p = dlt.pipeline( + pipeline_name="hubspot_pipeline", + dataset_name="hubspot", + destination="postgres", + ) + + # Configure the source with `include_history` to enable property history load, history is disabled by default + data = hubspot(include_history=True) + + # Run the pipeline with the HubSpot source connector + info = p.run(data) + + # Print information about the pipeline run + print(info) + + def load_web_analytics_events( object_type: THubspotObjectType, object_ids: List[str] ) -> None: @@ -57,4 +84,5 @@ def load_web_analytics_events( if __name__ == "__main__": # Call the functions to load HubSpot data into the database with and without company events enabled load_crm_data() + # load_crm_data_with_history() # load_web_analytics_events("company", ["7086461639", "7086464459"]) diff --git a/tests/hubspot/mock_data.py b/tests/hubspot/mock_data.py index 30cb70ae5..c2d100a47 100644 --- a/tests/hubspot/mock_data.py +++ b/tests/hubspot/mock_data.py @@ -190,3 +190,724 @@ } ] } + + +mock_contacts_with_history = { + "results": [ + { + "id": "1", + "properties": { + "address": None, + "annualrevenue": None, + "associatedcompanyid": None, + "associatedcompanylastupdated": None, + "city": "Brisbane", + "closedate": None, + "company": "HubSpot", + "company_size": None, + "country": None, + "createdate": "2023-06-28T13:55:47.572Z", + "currentlyinworkflow": None, + "date_of_birth": None, + "days_to_close": None, + "degree": None, + "email": "emailmaria@hubspot.com", + "engagements_last_meeting_booked": None, + "engagements_last_meeting_booked_campaign": None, + "engagements_last_meeting_booked_medium": None, + "engagements_last_meeting_booked_source": None, + "fax": None, + "field_of_study": None, + "first_conversion_date": None, + "first_conversion_event_name": None, + "first_deal_created_date": "2023-07-01T21:28:00.771Z", + "firstname": "Maria", + "followercount": None, + "gender": None, + "graduation_date": None, + "hs_additional_emails": None, + "hs_all_accessible_team_ids": None, + "hs_all_assigned_business_unit_ids": None, + "hs_all_contact_vids": "1", + "hs_all_owner_ids": None, + "hs_all_team_ids": None, + "hs_analytics_average_page_views": "0", + "hs_analytics_first_referrer": None, + "hs_analytics_first_timestamp": "2023-06-28T13:55:47.558Z", + "hs_analytics_first_touch_converting_campaign": None, + "hs_analytics_first_url": None, + "hs_analytics_first_visit_timestamp": None, + "hs_analytics_last_referrer": None, + "hs_analytics_last_timestamp": None, + "hs_analytics_last_touch_converting_campaign": None, + "hs_analytics_last_url": None, + "hs_analytics_last_visit_timestamp": None, + "hs_analytics_num_event_completions": "0", + "hs_analytics_num_page_views": "0", + "hs_analytics_num_visits": "0", + "hs_analytics_revenue": "0.0", + "hs_analytics_source": "OFFLINE", + "hs_analytics_source_data_1": "API", + "hs_analytics_source_data_2": "sample-contact", + "hs_avatar_filemanager_key": None, + "hs_buying_role": None, + "hs_calculated_form_submissions": None, + "hs_calculated_merged_vids": None, + "hs_calculated_mobile_number": None, + "hs_calculated_phone_number": None, + "hs_calculated_phone_number_area_code": None, + "hs_calculated_phone_number_country_code": None, + "hs_calculated_phone_number_region_code": None, + "hs_clicked_linkedin_ad": None, + "hs_content_membership_email": None, + "hs_content_membership_email_confirmed": None, + "hs_content_membership_follow_up_enqueued_at": None, + "hs_content_membership_notes": None, + "hs_content_membership_registered_at": None, + "hs_content_membership_registration_domain_sent_to": None, + "hs_content_membership_registration_email_sent_at": None, + "hs_content_membership_status": None, + "hs_conversations_visitor_email": None, + "hs_count_is_unworked": None, + "hs_count_is_worked": None, + "hs_created_by_conversations": None, + "hs_created_by_user_id": None, + "hs_createdate": None, + "hs_date_entered_customer": None, + "hs_date_entered_evangelist": None, + "hs_date_entered_lead": "2023-06-28T13:55:47.558Z", + "hs_date_entered_marketingqualifiedlead": None, + "hs_date_entered_opportunity": "2023-07-01T21:28:01.332Z", + "hs_date_entered_other": None, + "hs_date_entered_salesqualifiedlead": None, + "hs_date_entered_subscriber": None, + "hs_date_exited_customer": None, + "hs_date_exited_evangelist": None, + "hs_date_exited_lead": "2023-07-01T21:28:01.332Z", + "hs_date_exited_marketingqualifiedlead": None, + "hs_date_exited_opportunity": None, + "hs_date_exited_other": None, + "hs_date_exited_salesqualifiedlead": None, + "hs_date_exited_subscriber": None, + "hs_document_last_revisited": None, + "hs_email_bad_address": None, + "hs_email_bounce": None, + "hs_email_click": None, + "hs_email_customer_quarantined_reason": None, + "hs_email_delivered": None, + "hs_email_domain": "hubspot.com", + "hs_email_first_click_date": None, + "hs_email_first_open_date": None, + "hs_email_first_reply_date": None, + "hs_email_first_send_date": None, + "hs_email_hard_bounce_reason": None, + "hs_email_hard_bounce_reason_enum": None, + "hs_email_is_ineligible": None, + "hs_email_last_click_date": None, + "hs_email_last_email_name": None, + "hs_email_last_open_date": None, + "hs_email_last_reply_date": None, + "hs_email_last_send_date": None, + "hs_email_open": None, + "hs_email_optout": None, + "hs_email_optout_193660790": None, + "hs_email_optout_193660800": None, + "hs_email_quarantined": None, + "hs_email_quarantined_reason": None, + "hs_email_recipient_fatigue_recovery_time": None, + "hs_email_replied": None, + "hs_email_sends_since_last_engagement": None, + "hs_emailconfirmationstatus": None, + "hs_facebook_ad_clicked": None, + "hs_facebook_click_id": None, + "hs_facebookid": None, + "hs_feedback_last_nps_follow_up": None, + "hs_feedback_last_nps_rating": None, + "hs_feedback_last_survey_date": None, + "hs_feedback_show_nps_web_survey": None, + "hs_first_engagement_object_id": None, + "hs_first_outreach_date": None, + "hs_first_subscription_create_date": None, + "hs_google_click_id": None, + "hs_googleplusid": None, + "hs_has_active_subscription": None, + "hs_ip_timezone": None, + "hs_is_contact": "true", + "hs_is_unworked": "true", + "hs_language": None, + "hs_last_sales_activity_date": None, + "hs_last_sales_activity_timestamp": None, + "hs_last_sales_activity_type": None, + "hs_lastmodifieddate": None, + "hs_latest_meeting_activity": None, + "hs_latest_sequence_ended_date": None, + "hs_latest_sequence_enrolled": None, + "hs_latest_sequence_enrolled_date": None, + "hs_latest_sequence_finished_date": None, + "hs_latest_sequence_unenrolled_date": None, + "hs_latest_source": "OFFLINE", + "hs_latest_source_data_1": "API", + "hs_latest_source_data_2": "sample-contact", + "hs_latest_source_timestamp": "2023-06-28T13:55:47.640Z", + "hs_latest_subscription_create_date": None, + "hs_lead_status": None, + "hs_legal_basis": None, + "hs_lifecyclestage_customer_date": None, + "hs_lifecyclestage_evangelist_date": None, + "hs_lifecyclestage_lead_date": "2023-06-28T13:55:47.558Z", + "hs_lifecyclestage_marketingqualifiedlead_date": None, + "hs_lifecyclestage_opportunity_date": "2023-07-01T21:28:01.332Z", + "hs_lifecyclestage_other_date": None, + "hs_lifecyclestage_salesqualifiedlead_date": None, + "hs_lifecyclestage_subscriber_date": None, + "hs_linkedin_ad_clicked": None, + "hs_linkedinid": None, + "hs_marketable_reason_id": "Sample Contact", + "hs_marketable_reason_type": "SAMPLE_CONTACT", + "hs_marketable_status": "true", + "hs_marketable_until_renewal": "true", + "hs_merged_object_ids": None, + "hs_object_id": "1", + }, + "propertiesWithHistory": { + "city": [ + { + "value": "Brisbane", + "timestamp": "2023-06-28T13:55:47.558Z", + "sourceType": "CONTACTS_WEB", + "sourceId": "sample-contact", + } + ], + "company": [ + { + "value": "HubSpot", + "timestamp": "2023-06-28T13:55:47.558Z", + "sourceType": "CONTACTS_WEB", + "sourceId": "sample-contact", + } + ], + "createdate": [ + { + "value": "2023-06-28T13:55:47.572Z", + "timestamp": "2023-06-28T13:55:47.572Z", + "sourceType": "API", + "sourceId": "sample-contact", + } + ], + "hs_analytics_first_timestamp": [ + { + "value": "2023-06-28T13:55:47.558Z", + "timestamp": "2023-06-28T13:55:52.465Z", + "sourceType": "ANALYTICS", + "sourceId": "ContactAnalyticsDetailsUpdateWorker", + } + ], + "lastmodifieddate": [ + { + "value": "2023-07-01T21:28:22.147Z", + "timestamp": "2023-07-01T21:28:22.147Z", + "sourceType": "AI_GROUP", + }, + { + "value": "2023-07-01T21:28:01.332Z", + "timestamp": "2023-07-01T21:28:01.332Z", + "sourceType": "API", + }, + { + "value": "2023-07-01T21:28:01.331Z", + "timestamp": "2023-07-01T21:28:01.331Z", + "sourceType": "CALCULATED", + }, + { + "value": "2023-06-28T13:55:52.465Z", + "timestamp": "2023-06-28T13:55:52.465Z", + "sourceType": "API", + }, + { + "value": "2023-06-28T13:55:48.435Z", + "timestamp": "2023-06-28T13:55:48.435Z", + "sourceType": "API", + }, + { + "value": "2023-06-28T13:55:47.809Z", + "timestamp": "2023-06-28T13:55:47.809Z", + "sourceType": "API", + }, + { + "value": "2023-06-28T13:55:47.572Z", + "timestamp": "2023-06-28T13:55:47.572Z", + "sourceType": "API", + "sourceId": "sample-contact", + }, + ], + }, + "createdAt": "2023-06-28T13:55:47.572Z", + "updatedAt": "2023-07-01T21:28:22.147Z", + "archived": False, + "associations": { + "deals": {"results": [{"id": "8078784221", "type": "contact_to_deal"}]} + }, + }, + { + "id": "51", + "properties": { + "address": None, + "annualrevenue": None, + "associatedcompanyid": None, + "associatedcompanylastupdated": None, + "city": "Cambridge", + "closedate": None, + "company": "HubSpot", + "company_size": None, + "country": None, + "createdate": "2023-06-28T13:55:47.881Z", + "currentlyinworkflow": None, + "date_of_birth": None, + "days_to_close": None, + "degree": None, + "email": "thisisnewemail@hubspot.com", + "engagements_last_meeting_booked": None, + "engagements_last_meeting_booked_campaign": None, + "engagements_last_meeting_booked_medium": None, + "engagements_last_meeting_booked_source": None, + "fax": None, + "field_of_study": None, + "first_conversion_date": None, + "first_conversion_event_name": None, + "first_deal_created_date": "2023-07-01T21:28:00.771Z", + "firstname": "Brian", + "followercount": None, + "gender": None, + "graduation_date": None, + "hs_additional_emails": None, + "hs_all_accessible_team_ids": None, + "hs_all_assigned_business_unit_ids": None, + "hs_all_contact_vids": "51", + "hs_all_owner_ids": None, + "hs_all_team_ids": None, + "hs_analytics_average_page_views": "0", + "hs_object_id": "1", + }, + "propertiesWithHistory": { + "address": [], + "annualrevenue": [], + "associatedcompanyid": [], + "associatedcompanylastupdated": [], + "city": [ + { + "value": "Cambridge", + "timestamp": "2023-06-28T13:55:47.558Z", + "sourceType": "CONTACTS_WEB", + "sourceId": "sample-contact", + } + ], + "closedate": [], + "company": [ + { + "value": "HubSpot", + "timestamp": "2023-06-28T13:55:47.558Z", + "sourceType": "CONTACTS_WEB", + "sourceId": "sample-contact", + } + ], + "company_size": [], + "country": [], + "createdate": [ + { + "value": "2023-06-28T13:55:47.881Z", + "timestamp": "2023-06-28T13:55:47.881Z", + "sourceType": "API", + "sourceId": "sample-contact", + } + ], + "currentlyinworkflow": [], + "date_of_birth": [], + "days_to_close": [], + "degree": [], + "email": [ + { + "value": "thisisnewemail@hubspot.com", + "timestamp": "2023-07-01T23:34:57.837Z", + "sourceType": "CRM_UI", + "sourceId": "userId:52368047", + "updatedByUserId": 52368047, + }, + { + "value": "bh@hubspot.com", + "timestamp": "2023-06-28T13:55:47.558Z", + "sourceType": "API", + "sourceId": "sample-contact", + }, + ], + "engagements_last_meeting_booked": [], + "engagements_last_meeting_booked_campaign": [], + "engagements_last_meeting_booked_medium": [], + "engagements_last_meeting_booked_source": [], + "fax": [], + "field_of_study": [], + "first_conversion_date": [], + "first_conversion_event_name": [], + "first_deal_created_date": [ + { + "value": "2023-07-01T21:28:00.771Z", + "timestamp": "2023-07-01T21:28:01.398Z", + "sourceType": "CALCULATED", + "sourceId": "RollupProperties", + } + ], + "firstname": [ + { + "value": "Brian", + "timestamp": "2023-06-28T13:55:47.558Z", + "sourceType": "CONTACTS_WEB", + "sourceId": "sample-contact", + } + ], + "followercount": [], + "gender": [], + "graduation_date": [], + "hs_additional_emails": [], + "hs_all_accessible_team_ids": [], + "hs_all_assigned_business_unit_ids": [], + "hs_all_contact_vids": [ + { + "value": "51", + "timestamp": "2023-06-28T13:55:47.881Z", + "sourceType": "API", + "sourceId": "sample-contact", + } + ], + "hs_all_owner_ids": [], + "hs_all_team_ids": [], + "hs_analytics_average_page_views": [ + { + "value": "0", + "timestamp": "2023-06-28T13:55:57.617Z", + "sourceType": "ANALYTICS", + "sourceId": "ContactAnalyticsDetailsUpdateWorker", + } + ], + "hs_analytics_first_referrer": [], + "hs_analytics_first_timestamp": [ + { + "value": "2023-06-28T13:55:47.881Z", + "timestamp": "2023-06-28T13:55:57.617Z", + "sourceType": "ANALYTICS", + "sourceId": "ContactAnalyticsDetailsUpdateWorker", + } + ], + "hs_analytics_first_touch_converting_campaign": [], + "hs_analytics_first_url": [], + "hs_analytics_first_visit_timestamp": [], + "hs_analytics_last_referrer": [], + "hs_analytics_last_timestamp": [], + "hs_analytics_last_touch_converting_campaign": [], + "hs_analytics_last_url": [], + "hs_analytics_last_visit_timestamp": [], + "hs_analytics_num_event_completions": [ + { + "value": "0", + "timestamp": "2023-06-28T13:55:57.617Z", + "sourceType": "ANALYTICS", + "sourceId": "ContactAnalyticsDetailsUpdateWorker", + } + ], + }, + "createdAt": "2023-06-28T13:55:47.881Z", + "updatedAt": "2023-07-01T23:34:57.865Z", + "archived": False, + "associations": { + "deals": {"results": [{"id": "8078784221", "type": "contact_to_deal"}]} + }, + }, + ] +} + + +mock_contacts_properties = { + "results": [ + {"name": "company_size"}, + {"name": "date_of_birth"}, + {"name": "days_to_close"}, + {"name": "degree"}, + {"name": "field_of_study"}, + {"name": "first_conversion_date"}, + {"name": "first_conversion_event_name"}, + {"name": "first_deal_created_date"}, + {"name": "gender"}, + {"name": "graduation_date"}, + {"name": "hs_additional_emails"}, + {"name": "hs_all_assigned_business_unit_ids"}, + {"name": "hs_all_contact_vids"}, + {"name": "hs_analytics_first_touch_converting_campaign"}, + {"name": "hs_analytics_last_touch_converting_campaign"}, + {"name": "hs_avatar_filemanager_key"}, + {"name": "hs_buying_role"}, + {"name": "hs_calculated_form_submissions"}, + {"name": "hs_calculated_merged_vids"}, + {"name": "hs_calculated_mobile_number"}, + {"name": "hs_calculated_phone_number"}, + {"name": "hs_calculated_phone_number_area_code"}, + {"name": "hs_calculated_phone_number_country_code"}, + {"name": "hs_calculated_phone_number_region_code"}, + {"name": "hs_clicked_linkedin_ad"}, + {"name": "hs_content_membership_email"}, + {"name": "hs_content_membership_email_confirmed"}, + {"name": "hs_content_membership_follow_up_enqueued_at"}, + {"name": "hs_content_membership_notes"}, + {"name": "hs_content_membership_registered_at"}, + {"name": "hs_content_membership_registration_domain_sent_to"}, + {"name": "hs_content_membership_registration_email_sent_at"}, + {"name": "hs_content_membership_status"}, + {"name": "hs_conversations_visitor_email"}, + {"name": "hs_count_is_unworked"}, + {"name": "hs_count_is_worked"}, + {"name": "hs_created_by_conversations"}, + {"name": "hs_created_by_user_id"}, + {"name": "hs_createdate"}, + {"name": "hs_date_entered_customer"}, + {"name": "hs_date_entered_evangelist"}, + {"name": "hs_date_entered_lead"}, + {"name": "hs_date_entered_marketingqualifiedlead"}, + {"name": "hs_date_entered_opportunity"}, + {"name": "hs_date_entered_other"}, + {"name": "hs_date_entered_salesqualifiedlead"}, + {"name": "hs_date_entered_subscriber"}, + {"name": "hs_date_exited_customer"}, + {"name": "hs_date_exited_evangelist"}, + {"name": "hs_date_exited_lead"}, + {"name": "hs_date_exited_marketingqualifiedlead"}, + {"name": "hs_date_exited_opportunity"}, + {"name": "hs_date_exited_other"}, + {"name": "hs_date_exited_salesqualifiedlead"}, + {"name": "hs_date_exited_subscriber"}, + {"name": "hs_document_last_revisited"}, + {"name": "hs_email_bad_address"}, + {"name": "hs_email_customer_quarantined_reason"}, + {"name": "hs_email_domain"}, + {"name": "hs_email_hard_bounce_reason"}, + {"name": "hs_email_hard_bounce_reason_enum"}, + {"name": "hs_email_quarantined"}, + {"name": "hs_email_quarantined_reason"}, + {"name": "hs_email_recipient_fatigue_recovery_time"}, + {"name": "hs_email_sends_since_last_engagement"}, + {"name": "hs_emailconfirmationstatus"}, + {"name": "hs_facebook_ad_clicked"}, + {"name": "hs_facebook_click_id"}, + {"name": "hs_facebookid"}, + {"name": "hs_feedback_last_nps_follow_up"}, + {"name": "hs_feedback_last_nps_rating"}, + {"name": "hs_feedback_last_survey_date"}, + {"name": "hs_feedback_show_nps_web_survey"}, + {"name": "hs_first_engagement_object_id"}, + {"name": "hs_first_outreach_date"}, + {"name": "hs_first_subscription_create_date"}, + {"name": "hs_google_click_id"}, + {"name": "hs_googleplusid"}, + {"name": "hs_has_active_subscription"}, + {"name": "hs_ip_timezone"}, + {"name": "hs_is_contact"}, + {"name": "hs_is_unworked"}, + {"name": "hs_last_sales_activity_date"}, + {"name": "hs_last_sales_activity_timestamp"}, + {"name": "hs_last_sales_activity_type"}, + {"name": "hs_lastmodifieddate"}, + {"name": "hs_latest_sequence_ended_date"}, + {"name": "hs_latest_sequence_enrolled"}, + {"name": "hs_latest_sequence_enrolled_date"}, + {"name": "hs_latest_sequence_finished_date"}, + {"name": "hs_latest_sequence_unenrolled_date"}, + {"name": "hs_latest_source_timestamp"}, + {"name": "hs_latest_subscription_create_date"}, + {"name": "hs_lead_status"}, + {"name": "hs_legal_basis"}, + {"name": "hs_linkedin_ad_clicked"}, + {"name": "hs_linkedinid"}, + {"name": "hs_marketable_reason_id"}, + {"name": "hs_marketable_reason_type"}, + {"name": "hs_marketable_status"}, + {"name": "hs_marketable_until_renewal"}, + {"name": "hs_merged_object_ids"}, + {"name": "hs_object_id"}, + {"name": "hs_pinned_engagement_id"}, + {"name": "hs_pipeline"}, + {"name": "hs_predictivecontactscore_v2"}, + {"name": "hs_predictivescoringtier"}, + {"name": "hs_read_only"}, + {"name": "hs_sa_first_engagement_date"}, + {"name": "hs_sa_first_engagement_descr"}, + {"name": "hs_sa_first_engagement_object_type"}, + {"name": "hs_sales_email_last_clicked"}, + {"name": "hs_sales_email_last_opened"}, + {"name": "hs_searchable_calculated_international_mobile_number"}, + {"name": "hs_searchable_calculated_international_phone_number"}, + {"name": "hs_searchable_calculated_mobile_number"}, + {"name": "hs_searchable_calculated_phone_number"}, + {"name": "hs_sequences_actively_enrolled_count"}, + {"name": "hs_sequences_enrolled_count"}, + {"name": "hs_sequences_is_enrolled"}, + {"name": "hs_source_object_id"}, + {"name": "hs_source_portal_id"}, + {"name": "hs_testpurge"}, + {"name": "hs_testrollback"}, + {"name": "hs_time_between_contact_creation_and_deal_close"}, + {"name": "hs_time_between_contact_creation_and_deal_creation"}, + {"name": "hs_time_in_customer"}, + {"name": "hs_time_in_evangelist"}, + {"name": "hs_time_in_lead"}, + {"name": "hs_time_in_marketingqualifiedlead"}, + {"name": "hs_time_in_opportunity"}, + {"name": "hs_time_in_other"}, + {"name": "hs_time_in_salesqualifiedlead"}, + {"name": "hs_time_in_subscriber"}, + {"name": "hs_time_to_first_engagement"}, + {"name": "hs_time_to_move_from_lead_to_customer"}, + {"name": "hs_time_to_move_from_marketingqualifiedlead_to_customer"}, + {"name": "hs_time_to_move_from_opportunity_to_customer"}, + {"name": "hs_time_to_move_from_salesqualifiedlead_to_customer"}, + {"name": "hs_time_to_move_from_subscriber_to_customer"}, + {"name": "hs_timezone"}, + {"name": "hs_twitterid"}, + {"name": "hs_unique_creation_key"}, + {"name": "hs_updated_by_user_id"}, + {"name": "hs_user_ids_of_all_notification_followers"}, + {"name": "hs_user_ids_of_all_notification_unfollowers"}, + {"name": "hs_user_ids_of_all_owners"}, + {"name": "hs_was_imported"}, + {"name": "hs_whatsapp_phone_number"}, + {"name": "hubspot_owner_assigneddate"}, + {"name": "ip_city"}, + {"name": "ip_country"}, + {"name": "ip_country_code"}, + {"name": "ip_latlon"}, + {"name": "ip_state"}, + {"name": "ip_state_code"}, + {"name": "ip_zipcode"}, + {"name": "job_function"}, + {"name": "lastmodifieddate"}, + {"name": "marital_status"}, + {"name": "military_status"}, + {"name": "num_associated_deals"}, + {"name": "num_conversion_events"}, + {"name": "num_unique_conversion_events"}, + {"name": "recent_conversion_date"}, + {"name": "recent_conversion_event_name"}, + {"name": "recent_deal_amount"}, + {"name": "recent_deal_close_date"}, + {"name": "relationship_status"}, + {"name": "school"}, + {"name": "seniority"}, + {"name": "start_date"}, + {"name": "total_revenue"}, + {"name": "work_email"}, + {"name": "firstname"}, + {"name": "hs_analytics_first_url"}, + {"name": "hs_email_delivered"}, + {"name": "hs_email_optout_193660790"}, + {"name": "hs_email_optout_193660800"}, + {"name": "twitterhandle"}, + {"name": "currentlyinworkflow"}, + {"name": "followercount"}, + {"name": "hs_analytics_last_url"}, + {"name": "hs_email_open"}, + {"name": "lastname"}, + {"name": "hs_analytics_num_page_views"}, + {"name": "hs_email_click"}, + {"name": "salutation"}, + {"name": "twitterprofilephoto"}, + {"name": "email"}, + {"name": "hs_analytics_num_visits"}, + {"name": "hs_email_bounce"}, + {"name": "hs_persona"}, + {"name": "hs_social_last_engagement"}, + {"name": "hs_analytics_num_event_completions"}, + {"name": "hs_email_optout"}, + {"name": "hs_social_twitter_clicks"}, + {"name": "mobilephone"}, + {"name": "phone"}, + {"name": "fax"}, + {"name": "hs_analytics_first_timestamp"}, + {"name": "hs_email_last_email_name"}, + {"name": "hs_email_last_send_date"}, + {"name": "hs_social_facebook_clicks"}, + {"name": "address"}, + {"name": "engagements_last_meeting_booked"}, + {"name": "engagements_last_meeting_booked_campaign"}, + {"name": "engagements_last_meeting_booked_medium"}, + {"name": "engagements_last_meeting_booked_source"}, + {"name": "hs_analytics_first_visit_timestamp"}, + {"name": "hs_email_last_open_date"}, + {"name": "hs_latest_meeting_activity"}, + {"name": "hs_sales_email_last_replied"}, + {"name": "hs_social_linkedin_clicks"}, + {"name": "hubspot_owner_id"}, + {"name": "notes_last_contacted"}, + {"name": "notes_last_updated"}, + {"name": "notes_next_activity_date"}, + {"name": "num_contacted_notes"}, + {"name": "num_notes"}, + {"name": "owneremail"}, + {"name": "ownername"}, + {"name": "surveymonkeyeventlastupdated"}, + {"name": "webinareventlastupdated"}, + {"name": "city"}, + {"name": "hs_analytics_last_timestamp"}, + {"name": "hs_email_last_click_date"}, + {"name": "hs_social_google_plus_clicks"}, + {"name": "hubspot_team_id"}, + {"name": "linkedinbio"}, + {"name": "twitterbio"}, + {"name": "hs_all_owner_ids"}, + {"name": "hs_analytics_last_visit_timestamp"}, + {"name": "hs_email_first_send_date"}, + {"name": "hs_social_num_broadcast_clicks"}, + {"name": "state"}, + {"name": "hs_all_team_ids"}, + {"name": "hs_analytics_source"}, + {"name": "hs_email_first_open_date"}, + {"name": "hs_latest_source"}, + {"name": "zip"}, + {"name": "country"}, + {"name": "hs_all_accessible_team_ids"}, + {"name": "hs_analytics_source_data_1"}, + {"name": "hs_email_first_click_date"}, + {"name": "hs_latest_source_data_1"}, + {"name": "linkedinconnections"}, + {"name": "hs_analytics_source_data_2"}, + {"name": "hs_email_is_ineligible"}, + {"name": "hs_language"}, + {"name": "hs_latest_source_data_2"}, + {"name": "kloutscoregeneral"}, + {"name": "hs_analytics_first_referrer"}, + {"name": "hs_email_first_reply_date"}, + {"name": "jobtitle"}, + {"name": "photo"}, + {"name": "hs_analytics_last_referrer"}, + {"name": "hs_email_last_reply_date"}, + {"name": "message"}, + {"name": "closedate"}, + {"name": "hs_analytics_average_page_views"}, + {"name": "hs_email_replied"}, + {"name": "hs_analytics_revenue"}, + {"name": "hs_lifecyclestage_lead_date"}, + {"name": "hs_lifecyclestage_marketingqualifiedlead_date"}, + {"name": "hs_lifecyclestage_opportunity_date"}, + {"name": "lifecyclestage"}, + {"name": "hs_lifecyclestage_salesqualifiedlead_date"}, + {"name": "createdate"}, + {"name": "hs_lifecyclestage_evangelist_date"}, + {"name": "hs_lifecyclestage_customer_date"}, + {"name": "hubspotscore"}, + {"name": "company"}, + {"name": "hs_lifecyclestage_subscriber_date"}, + {"name": "hs_lifecyclestage_other_date"}, + {"name": "website"}, + {"name": "numemployees"}, + {"name": "annualrevenue"}, + {"name": "industry"}, + {"name": "associatedcompanyid"}, + {"name": "associatedcompanylastupdated"}, + {"name": "hs_predictivecontactscorebucket"}, + {"name": "hs_predictivecontactscore"}, + ] +} diff --git a/tests/hubspot/test_hubspot_source.py b/tests/hubspot/test_hubspot_source.py index caea1d7b9..8e671222e 100644 --- a/tests/hubspot/test_hubspot_source.py +++ b/tests/hubspot/test_hubspot_source.py @@ -1,11 +1,15 @@ -from unittest.mock import patch +from unittest.mock import patch, ANY, call import dlt import pytest +from itertools import chain +from typing import Any +from urllib.parse import urljoin +from dlt.common import pendulum from dlt.sources.helpers import requests -from sources.hubspot import hubspot, hubspot_events_for_objects -from sources.hubspot.helpers import fetch_data +from sources.hubspot import hubspot, hubspot_events_for_objects, contacts +from sources.hubspot.helpers import fetch_data, BASE_URL from sources.hubspot.settings import ( CRM_CONTACTS_ENDPOINT, CRM_COMPANIES_ENDPOINT, @@ -21,6 +25,8 @@ mock_products_data, mock_tickets_data, mock_quotes_data, + mock_contacts_with_history, + mock_contacts_properties, ) from tests.utils import ( ALL_DESTINATIONS, @@ -110,6 +116,59 @@ def test_fetch_data_quotes(mock_response): assert data == expected_data +@pytest.mark.parametrize("destination_name", ALL_DESTINATIONS) +def test_resource_contacts_with_history(destination_name: str, mock_response) -> None: + prop_names = [p["name"] for p in mock_contacts_properties["results"]] + prop_string = ",".join(prop_names) + + def fake_get(url: str, *args, **kwargs) -> Any: # type: ignore[no-untyped-def] + if "/properties" in url: + return mock_response(json_data=mock_contacts_properties) + return mock_response(json_data=mock_contacts_with_history) + + expected_rows = [] + for contact in mock_contacts_with_history["results"]: + for items in contact["propertiesWithHistory"].values(): # type: ignore[attr-defined] + expected_rows.extend(items) + + with patch("dlt.sources.helpers.requests.get", side_effect=fake_get) as m: + pipeline = dlt.pipeline( + pipeline_name="hubspot", + destination=destination_name, + dataset_name="hubspot_data", + full_refresh=True, + ) + load_info = pipeline.run(contacts(api_key="fake_key", include_history=True)) + assert_load_info(load_info) + + assert m.call_count == 3 + + # Check that API is called with all properties listed + m.assert_has_calls( + [ + call( + urljoin(BASE_URL, "/crm/v3/properties/contacts"), + headers=ANY, + params=None, + ), + call( + urljoin(BASE_URL, CRM_CONTACTS_ENDPOINT), + headers=ANY, + params={"properties": prop_string, "limit": 100}, + ), + call( + urljoin(BASE_URL, CRM_CONTACTS_ENDPOINT), + headers=ANY, + params={"propertiesWithHistory": prop_string, "limit": 50}, + ), + ] + ) + + assert load_table_counts(pipeline, "contacts_property_history") == { + "contacts_property_history": len(expected_rows) + } + + @pytest.mark.parametrize("destination_name", ALL_DESTINATIONS) def test_all_resources(destination_name: str) -> None: pipeline = dlt.pipeline( @@ -118,10 +177,15 @@ def test_all_resources(destination_name: str) -> None: dataset_name="hubspot_data", full_refresh=True, ) - load_info = pipeline.run(hubspot()) + load_info = pipeline.run(hubspot(include_history=True)) print(load_info) assert_load_info(load_info) - table_names = [t["name"] for t in pipeline.default_schema.data_tables()] + table_names = [ + t["name"] + for t in pipeline.default_schema.data_tables() + if not t["name"].endswith("_property_history") and not t.get("parent") + ] + # make sure no duplicates (ie. pages wrongly overlap) assert ( load_table_counts(pipeline, *table_names) @@ -129,6 +193,53 @@ def test_all_resources(destination_name: str) -> None: == {"companies": 200, "deals": 500, "contacts": 402} ) + history_table_names = [ + t["name"] + for t in pipeline.default_schema.data_tables() + if t["name"].endswith("_property_history") + ] + # Check history tables + assert load_table_counts(pipeline, *history_table_names) == { + "contacts_property_history": 16826, + "deals_property_history": 13849, + } + + # Check property from couple of contacts against known data + with pipeline.sql_client() as client: + rows = [ + list(row) + for row in client.execute_sql( + """ + SELECT ch.property_name, ch.value, ch.source_type, ch.source_type, ch.timestamp + FROM contacts + JOIN + contacts_property_history AS ch ON contacts.id = ch.object_id + WHERE contacts.id IN ('51', '1') AND property_name = 'email'; + """ + ) + ] + for row in rows: + row[-1] = pendulum.instance(row[-1]) + + assert set((tuple(row) for row in rows)) == set( + [ + ( + "email", + "emailmaria@hubspot.com", + "API", + "API", + pendulum.parse("2022-06-15 08:51:51.399+00"), + ), + ( + "email", + "bh@hubspot.com", + "API", + "API", + pendulum.parse("2022-06-15 08:51:51.399+00"), + ), + ] + ) + @pytest.mark.parametrize("destination_name", ALL_DESTINATIONS) def test_event_resources(destination_name: str) -> None: