Skip to content

Commit c9f4ad4

Browse files
authored
Source Hubspot: add integration tests (#35945)
1 parent a02c342 commit c9f4ad4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+2300
-22
lines changed

airbyte-integrations/connectors/source-hubspot/acceptance-test-config.yml

+15-15
Original file line numberDiff line numberDiff line change
@@ -29,35 +29,35 @@ acceptance_tests:
2929
timeout_seconds: 3600
3030
empty_streams:
3131
- name: engagements_calls
32-
bypass_reason: Unable to populate cost $20/month
32+
bypass_reason: Unable to populate (cost $20/month) - covered by integration tests
3333
- name: owners_archived
34-
bypass_reason: unable to populate
34+
bypass_reason: Unable to populate - covered by integration tests
3535
- name: tickets_web_analytics
36-
bypass_reason: Unable to populate
36+
bypass_reason: Unable to populate - covered by integration tests
3737
- name: deals_web_analytics
38-
bypass_reason: Unable to populate
38+
bypass_reason: Unable to populate - covered by integration tests
3939
- name: companies_web_analytics
40-
bypass_reason: Unable to populate
40+
bypass_reason: Unable to populate - covered by integration tests
4141
- name: engagements_calls_web_analytics
42-
bypass_reason: Unable to populate
42+
bypass_reason: Unable to populate - covered by integration tests
4343
- name: engagements_emails_web_analytics
44-
bypass_reason: Unable to populate
44+
bypass_reason: Unable to populate - covered by integration tests
4545
- name: engagements_meetings_web_analytics
46-
bypass_reason: Unable to populate
46+
bypass_reason: Unable to populate - covered by integration tests
4747
- name: engagements_notes_web_analytics
48-
bypass_reason: Unable to populate
48+
bypass_reason: Unable to populate - covered by integration tests
4949
- name: engagements_tasks_web_analytics
50-
bypass_reason: Unable to populate
50+
bypass_reason: Unable to populate - covered by integration tests
5151
- name: goals_web_analytics
52-
bypass_reason: Unable to populate
52+
bypass_reason: Unable to populate - covered by integration tests
5353
- name: line_items_web_analytics
54-
bypass_reason: Unable to populate
54+
bypass_reason: Unable to populate - covered by integration tests
5555
- name: products_web_analytics
56-
bypass_reason: Unable to populate
56+
bypass_reason: Unable to populate - covered by integration tests
5757
- name: pets_web_analytics
58-
bypass_reason: Unable to populate
58+
bypass_reason: Unable to populate - covered by integration tests
5959
- name: cars_web_analytics
60-
bypass_reason: Unable to populate
60+
bypass_reason: Unable to populate - covered by integration tests
6161
full_refresh:
6262
tests:
6363
- config_path: secrets/config.json

airbyte-integrations/connectors/source-hubspot/metadata.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ data:
1010
connectorSubtype: api
1111
connectorType: source
1212
definitionId: 36c891d9-4bd9-43ac-bad2-10e12756272c
13-
dockerImageTag: 4.1.0
13+
dockerImageTag: 4.1.1
1414
dockerRepository: airbyte/source-hubspot
1515
documentationUrl: https://docs.airbyte.com/integrations/sources/hubspot
1616
githubIssueLabel: source-hubspot

airbyte-integrations/connectors/source-hubspot/poetry.lock

+28-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

airbyte-integrations/connectors/source-hubspot/pyproject.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ requires = [ "poetry-core>=1.0.0",]
33
build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
6-
version = "4.1.0"
6+
version = "4.1.1"
77
name = "source-hubspot"
88
description = "Source implementation for HubSpot."
99
authors = [ "Airbyte <[email protected]>",]
@@ -27,3 +27,5 @@ requests-mock = "^1.9.3"
2727
mock = "^5.1.0"
2828
pytest-mock = "^3.6"
2929
pytest = "^6.2"
30+
pytz = "2024.1"
31+
freezegun = "0.3.4"

airbyte-integrations/connectors/source-hubspot/source_hubspot/source.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,12 @@ def streams(self, config: Mapping[str, Any]) -> List[Stream]:
197197
self.logger.info("No scopes to grant when authenticating with API key.")
198198
available_streams = streams
199199

200-
available_streams.extend(self.get_custom_object_streams(api=api, common_params=common_params))
200+
custom_object_streams = list(self.get_custom_object_streams(api=api, common_params=common_params))
201+
available_streams.extend(custom_object_streams)
201202

202203
if enable_experimental_streams:
203204
custom_objects_web_analytics_streams = self.get_web_analytics_custom_objects_stream(
204-
custom_object_stream_instances=self.get_custom_object_streams(api=api, common_params=common_params),
205+
custom_object_stream_instances=custom_object_streams,
205206
common_params=common_params,
206207
)
207208
available_streams.extend(custom_objects_web_analytics_streams)

airbyte-integrations/connectors/source-hubspot/source_hubspot/streams.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,7 @@ def _get_field_props(field_type: str) -> Mapping[str, List[str]]:
794794
@property
795795
@lru_cache()
796796
def properties(self) -> Mapping[str, Any]:
797-
"""Some entities has dynamic set of properties, so we trying to resolve those at runtime"""
797+
"""Some entities have dynamic set of properties, so we're trying to resolve those at runtime"""
798798
props = {}
799799
if not self.entity:
800800
return props

airbyte-integrations/connectors/source-hubspot/unit_tests/conftest.py

+5
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,8 @@ def fake_properties_list():
8585
@pytest.fixture(name="api")
8686
def api(some_credentials):
8787
return API(some_credentials)
88+
89+
90+
@pytest.fixture
91+
def http_mocker():
92+
return None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
3+
import copy
4+
from datetime import datetime, timedelta
5+
from typing import Any, Dict, List, Optional
6+
7+
import freezegun
8+
import pytz
9+
from airbyte_cdk.test.catalog_builder import CatalogBuilder
10+
from airbyte_cdk.test.entrypoint_wrapper import EntrypointOutput, read
11+
from airbyte_cdk.test.mock_http import HttpMocker
12+
from airbyte_cdk.test.mock_http.response_builder import FieldPath, HttpResponseBuilder, RecordBuilder, create_record_builder, find_template
13+
from airbyte_protocol.models import AirbyteStateMessage, SyncMode
14+
from source_hubspot import SourceHubspot
15+
16+
from .config_builder import ConfigBuilder
17+
from .request_builders.api import CustomObjectsRequestBuilder, OAuthRequestBuilder, PropertiesRequestBuilder, ScopesRequestBuilder
18+
from .request_builders.streams import CRMStreamRequestBuilder, IncrementalCRMStreamRequestBuilder, WebAnalyticsRequestBuilder
19+
from .response_builder.helpers import RootHttpResponseBuilder
20+
from .response_builder.api import ScopesResponseBuilder
21+
from .response_builder.streams import GenericResponseBuilder, HubspotStreamResponseBuilder
22+
23+
24+
@freezegun.freeze_time("2024-03-03T14:42:00Z")
25+
class HubspotTestCase:
26+
DT_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
27+
OBJECT_ID = "testID"
28+
ACCESS_TOKEN = "new_access_token"
29+
CURSOR_FIELD = "occurredAt"
30+
PROPERTIES = {
31+
"closed_date": "datetime",
32+
"createdate": "datetime",
33+
}
34+
35+
@classmethod
36+
def now(cls):
37+
return datetime.now(pytz.utc)
38+
39+
@classmethod
40+
def start_date(cls):
41+
return cls.now() - timedelta(days=30)
42+
43+
@classmethod
44+
def updated_at(cls):
45+
return cls.now() - timedelta(days=1)
46+
47+
@classmethod
48+
def dt_str(cls, dt: datetime.date) -> str:
49+
return dt.strftime(cls.DT_FORMAT)
50+
51+
@classmethod
52+
def oauth_config(cls, start_date: Optional[str] = None) -> Dict[str, Any]:
53+
start_date = start_date or cls.dt_str(cls.start_date())
54+
return ConfigBuilder().with_start_date(start_date).with_auth(
55+
{
56+
"credentials_title": "OAuth Credentials",
57+
"redirect_uri": "https://airbyte.io",
58+
"client_id": "client_id",
59+
"client_secret": "client_secret",
60+
"refresh_token": "refresh_token",
61+
}
62+
).build()
63+
64+
@classmethod
65+
def private_token_config(cls, token: str, start_date: Optional[str] = None) -> Dict[str, Any]:
66+
start_date = start_date or cls.dt_str(cls.start_date())
67+
return ConfigBuilder().with_start_date(start_date).with_auth(
68+
{
69+
"credentials_title": "Private App Credentials",
70+
"access_token": token,
71+
}
72+
).build()
73+
74+
@classmethod
75+
def mock_oauth(cls, http_mocker: HttpMocker, token: str):
76+
creds = cls.oauth_config()["credentials"]
77+
req = OAuthRequestBuilder().with_client_id(
78+
creds["client_id"]
79+
).with_client_secret(
80+
creds["client_secret"]
81+
).with_refresh_token(
82+
creds["refresh_token"]
83+
).build()
84+
response = GenericResponseBuilder().with_value("access_token", token).with_value("expires_in", 7200).build()
85+
http_mocker.post(req, response)
86+
87+
@classmethod
88+
def mock_scopes(cls, http_mocker: HttpMocker, token: str, scopes: List[str]):
89+
http_mocker.get(ScopesRequestBuilder().with_access_token(token).build(), ScopesResponseBuilder(scopes).build())
90+
91+
@classmethod
92+
def mock_custom_objects(cls, http_mocker: HttpMocker):
93+
http_mocker.get(
94+
CustomObjectsRequestBuilder().build(),
95+
HttpResponseBuilder({}, records_path=FieldPath("results"), pagination_strategy=None).build()
96+
)
97+
98+
@classmethod
99+
def mock_properties(cls, http_mocker: HttpMocker, object_type: str, properties: Dict[str, str]):
100+
templates = find_template("properties", __file__)
101+
record_builder = lambda: RecordBuilder(copy.deepcopy(templates[0]), id_path=None, cursor_path=None)
102+
103+
response_builder = RootHttpResponseBuilder(templates)
104+
for name, type in properties.items():
105+
record = record_builder().with_field(FieldPath("name"), name).with_field(FieldPath("type"), type)
106+
response_builder = response_builder.with_record(record)
107+
108+
http_mocker.get(
109+
PropertiesRequestBuilder().for_entity(object_type).build(),
110+
response_builder.build()
111+
)
112+
113+
@classmethod
114+
def mock_response(cls, http_mocker: HttpMocker, request, responses, method: str = "get"):
115+
if not isinstance(responses, (list, tuple)):
116+
responses = [responses]
117+
getattr(http_mocker, method)(request, responses)
118+
119+
@classmethod
120+
def record_builder(cls, stream: str, record_cursor_path):
121+
return create_record_builder(
122+
find_template(stream, __file__), records_path=FieldPath("results"), record_id_path=None, record_cursor_path=record_cursor_path
123+
)
124+
125+
@classmethod
126+
def catalog(cls, stream: str, sync_mode: SyncMode):
127+
return CatalogBuilder().with_stream(stream, sync_mode).build()
128+
129+
@classmethod
130+
def read_from_stream(
131+
cls, cfg, stream: str, sync_mode: SyncMode, state: Optional[List[AirbyteStateMessage]] = None, expecting_exception: bool = False
132+
) -> EntrypointOutput:
133+
return read(SourceHubspot(), cfg, cls.catalog(stream, sync_mode), state, expecting_exception)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
3+
from typing import Any, Mapping
4+
5+
6+
class ConfigBuilder:
7+
def __init__(self):
8+
self._config = {
9+
"enable_experimental_streams": True
10+
}
11+
12+
def with_start_date(self, start_date: str):
13+
self._config["start_date"] = start_date
14+
return self
15+
16+
def with_auth(self, credentials: Mapping[str, str]):
17+
self._config["credentials"] = credentials
18+
return self
19+
20+
def build(self) -> Mapping[str, Any]:
21+
return self._config
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
3+
import abc
4+
5+
6+
class AbstractRequestBuilder:
7+
@abc.abstractmethod
8+
def build(self):
9+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
3+
from airbyte_cdk.test.mock_http import HttpRequest
4+
5+
from . import AbstractRequestBuilder
6+
7+
8+
class OAuthRequestBuilder(AbstractRequestBuilder):
9+
URL = "https://api.hubapi.com/oauth/v1/token"
10+
11+
def __init__(self):
12+
self._params = {}
13+
14+
def with_client_id(self, client_id: str):
15+
self._params["client_id"] = client_id
16+
return self
17+
18+
def with_client_secret(self, client_secret: str):
19+
self._params["client_secret"] = client_secret
20+
return self
21+
22+
def with_refresh_token(self, refresh_token: str):
23+
self._params["refresh_token"] = refresh_token
24+
return self
25+
26+
def build(self) -> HttpRequest:
27+
client_id, client_secret, refresh_token = self._params["client_id"], self._params["client_secret"], self._params["refresh_token"]
28+
return HttpRequest(
29+
url=self.URL,
30+
body=f"grant_type=refresh_token&client_id={client_id}&client_secret={client_secret}&refresh_token={refresh_token}"
31+
)
32+
33+
34+
class ScopesRequestBuilder(AbstractRequestBuilder):
35+
URL = "https://api.hubapi.com/oauth/v1/access-tokens/{token}"
36+
37+
def __init__(self):
38+
self._token = None
39+
40+
def with_access_token(self, token: str):
41+
self._token = token
42+
return self
43+
44+
def build(self) -> HttpRequest:
45+
return HttpRequest(url=self.URL.format(token=self._token))
46+
47+
48+
class CustomObjectsRequestBuilder(AbstractRequestBuilder):
49+
URL = "https://api.hubapi.com/crm/v3/schemas"
50+
51+
def build(self) -> HttpRequest:
52+
return HttpRequest(url=self.URL)
53+
54+
55+
class PropertiesRequestBuilder(AbstractRequestBuilder):
56+
URL = "https://api.hubapi.com/properties/v2/{resource}/properties"
57+
58+
def __init__(self):
59+
self._resource = None
60+
61+
def for_entity(self, entity):
62+
self._resource = entity
63+
return self
64+
65+
def build(self) -> HttpRequest:
66+
return HttpRequest(url=self.URL.format(resource=self._resource))

0 commit comments

Comments
 (0)