Skip to content

Commit d108b9d

Browse files
author
Anton Karpets
authored
✨Source Facebook Marketing: add integration tests (#35061)
1 parent b339aaf commit d108b9d

File tree

23 files changed

+1485
-53
lines changed

23 files changed

+1485
-53
lines changed

airbyte-integrations/connectors/source-facebook-marketing/acceptance-test-config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,9 @@ acceptance_tests:
8383
bypass_reason: is changeable
8484
empty_streams:
8585
- name: "ads_insights_action_product_id"
86-
bypass_reason: "Data not permanent"
86+
bypass_reason: "Data cannot be seeded in the test account, integration tests added for the stream instead"
8787
- name: "videos"
88-
bypass_reason: "Cannot populate"
88+
bypass_reason: "Data cannot be seeded in the test account, integration tests added for the stream instead"
8989
timeout_seconds: 4800
9090
expect_records:
9191
path: "integration_tests/expected_records.jsonl"

airbyte-integrations/connectors/source-facebook-marketing/integration_tests/expected_records.jsonl

Lines changed: 25 additions & 25 deletions
Large diffs are not rendered by default.

airbyte-integrations/connectors/source-facebook-marketing/metadata.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ data:
1010
connectorSubtype: api
1111
connectorType: source
1212
definitionId: e7778cfc-e97c-4458-9ecb-b4f2bba8946c
13-
dockerImageTag: 1.3.2
13+
dockerImageTag: 1.3.3
1414
dockerRepository: airbyte/source-facebook-marketing
1515
documentationUrl: https://docs.airbyte.com/integrations/sources/facebook-marketing
1616
githubIssueLabel: source-facebook-marketing

airbyte-integrations/connectors/source-facebook-marketing/poetry.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

airbyte-integrations/connectors/source-facebook-marketing/pyproject.toml

Lines changed: 2 additions & 2 deletions
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 = "1.3.2"
6+
version = "1.3.3"
77
name = "source-facebook-marketing"
88
description = "Source implementation for Facebook Marketing."
99
authors = [ "Airbyte <[email protected]>",]
@@ -17,7 +17,7 @@ include = "source_facebook_marketing"
1717

1818
[tool.poetry.dependencies]
1919
python = "^3.9,<3.12"
20-
airbyte-cdk = "==0.61.0"
20+
airbyte-cdk = "==0.62.1"
2121
facebook-business = "==17.0.0"
2222
cached-property = "==1.5.2"
2323
pendulum = "==2.1.2"

airbyte-integrations/connectors/source-facebook-marketing/source_facebook_marketing/api.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import logging
77
from dataclasses import dataclass
88
from time import sleep
9-
from typing import List
109

1110
import backoff
1211
import pendulum
@@ -35,7 +34,7 @@ class MyFacebookAdsApi(FacebookAdsApi):
3534
# see `_should_restore_page_size` method docstring for more info.
3635
# attribute to handle the reduced request limit
3736
request_record_limit_is_reduced: bool = False
38-
# attribute to save the status of last successfull call
37+
# attribute to save the status of the last successful call
3938
last_api_call_is_successful: bool = False
4039

4140
@dataclass
@@ -144,7 +143,7 @@ def _update_insights_throttle_limit(self, response: FacebookResponse):
144143

145144
def _should_restore_default_page_size(self, params):
146145
"""
147-
Track the state of the `request_record_limit_is_reduced` and `last_api_call_is_successfull`,
146+
Track the state of the `request_record_limit_is_reduced` and `last_api_call_is_successful`,
148147
based on the logic from `@backoff_policy` (common.py > `reduce_request_record_limit` and `revert_request_record_limit`)
149148
"""
150149
params = True if params else False

airbyte-integrations/connectors/source-facebook-marketing/source_facebook_marketing/source.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
DestinationSyncMode,
1515
FailureType,
1616
OAuthConfigSpecification,
17-
SyncMode,
1817
)
1918
from airbyte_cdk.sources import AbstractSource
2019
from airbyte_cdk.sources.streams import Stream

airbyte-integrations/connectors/source-facebook-marketing/source_facebook_marketing/streams/async_job_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def _get_current_throttle_value(self) -> float:
134134
"""
135135
Get current ads insights throttle value based on app id and account id.
136136
It evaluated as minimum of those numbers cause when account id throttle
137-
hit 100 it cool down very slowly (i.e. it still says 100 despite no jobs
137+
hit 100 it cools down very slowly (i.e. it still says 100 despite no jobs
138138
running and it capable serve new requests). Because of this behaviour
139139
facebook throttle limit is not reliable metric to estimate async workload.
140140
"""
@@ -144,7 +144,7 @@ def _get_current_throttle_value(self) -> float:
144144

145145
def _update_api_throttle_limit(self):
146146
"""
147-
Sends <ACCOUNT_ID>/insights GET request with no parameters so it would
147+
Sends <ACCOUNT_ID>/insights GET request with no parameters, so it would
148148
respond with empty list of data so api use "x-fb-ads-insights-throttle"
149149
header to update current insights throttle limit.
150150
"""

airbyte-integrations/connectors/source-facebook-marketing/source_facebook_marketing/streams/base_insight_streams.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ class AdsInsights(FBMarketingIncrementalStream):
4444
]
4545

4646
# Facebook store metrics maximum of 37 months old. Any time range that
47-
# older that 37 months from current date would result in 400 Bad request
48-
# HTTP response.
47+
# older than 37 months from current date would result in 400 Bad request HTTP response.
4948
# https://developers.facebook.com/docs/marketing-api/reference/ad-account/insights/#overview
5049
INSIGHTS_RETENTION_PERIOD = pendulum.duration(months=37)
5150

@@ -106,8 +105,8 @@ def insights_lookback_period(self):
106105
"""
107106
Facebook freezes insight data 28 days after it was generated, which means that all data
108107
from the past 28 days may have changed since we last emitted it, so we retrieve it again.
109-
But in some cases users my have define their own lookback window, thats
110-
why the value for `insights_lookback_window` is set throught config.
108+
But in some cases users my have define their own lookback window, that's
109+
why the value for `insights_lookback_window` is set through the config.
111110
"""
112111
return pendulum.duration(days=self._insights_lookback_window)
113112

@@ -174,7 +173,7 @@ def state(self) -> MutableMapping[str, Any]:
174173
def state(self, value: Mapping[str, Any]):
175174
"""State setter, will ignore saved state if time_increment is different from previous."""
176175
# if the time increment configured for this stream is different from the one in the previous state
177-
# then the previous state object is invalid and we should start replicating data from scratch
176+
# then the previous state object is invalid, and we should start replicating data from scratch
178177
# to achieve this, we skip setting the state
179178
transformed_state = self._transform_state_from_old_format(value, ["time_increment"])
180179
if transformed_state.get("time_increment", 1) != self.time_increment:

airbyte-integrations/connectors/source-facebook-marketing/source_facebook_marketing/streams/common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,12 @@ def reduce_request_record_limit(details):
6161

6262
def revert_request_record_limit(details):
6363
"""
64-
This method is triggered `on_success` after successfull retry,
64+
This method is triggered `on_success` after successful retry,
6565
sets the internal class flags to provide the logic to restore the previously reduced
6666
`limit` param.
6767
"""
6868
# reference issue: https://github.com/airbytehq/airbyte/issues/25383
69-
# set the flag to the api class that the last api call was ssuccessfull
69+
# set the flag to the api class that the last api call was successful
7070
details.get("args")[0].last_api_call_is_successfull = True
7171
# set the flag to the api class that the `limit` param is restored
7272
details.get("args")[0].request_record_limit_is_reduced = False

airbyte-integrations/connectors/source-facebook-marketing/source_facebook_marketing/streams/streams.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def fetch_thumbnail_data_url(url: str) -> Optional[str]:
3636

3737

3838
class AdCreatives(FBMarketingStream):
39-
"""AdCreative is append only stream
39+
"""AdCreative is append-only stream
4040
doc: https://developers.facebook.com/docs/marketing-api/reference/ad-creative
4141
"""
4242

@@ -48,7 +48,7 @@ def __init__(self, fetch_thumbnail_images: bool = False, **kwargs):
4848
self._fetch_thumbnail_images = fetch_thumbnail_images
4949

5050
def fields(self, **kwargs) -> List[str]:
51-
"""Remove "thumbnail_data_url" field because it is computed field and it's not a field that we can request from Facebook"""
51+
"""Remove "thumbnail_data_url" field because it is a computed field, and it's not a field that we can request from Facebook"""
5252
if self._fields:
5353
return self._fields
5454

@@ -231,7 +231,7 @@ def list_objects(self, params: Mapping[str, Any], account_id: str) -> Iterable:
231231
return [FBAdAccount(self._api.get_account(account_id=account_id).get_id()).api_get(fields=fields)]
232232
except FacebookRequestError as e:
233233
# This is a workaround for cases when account seem to have all the required permissions
234-
# but despite of that is not allowed to get `owner` field. See (https://github.com/airbytehq/oncall/issues/3167)
234+
# but despite that is not allowed to get `owner` field. See (https://github.com/airbytehq/oncall/issues/3167)
235235
if e.api_error_code() == 200 and e.api_error_message() == "(#200) Requires business_management permission to manage the object":
236236
fields.remove("owner")
237237
return [FBAdAccount(self._api.get_account(account_id=account_id).get_id()).api_get(fields=fields)]

airbyte-integrations/connectors/source-facebook-marketing/source_facebook_marketing/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
logger = logging.getLogger("airbyte")
1212

1313
# Facebook store metrics maximum of 37 months old. Any time range that
14-
# older that 37 months from current date would result in 400 Bad request
14+
# older than 37 months from current date would result in 400 Bad request
1515
# HTTP response.
1616
# https://developers.facebook.com/docs/marketing-api/reference/ad-account/insights/#overview
1717
DATA_RETENTION_PERIOD = 37

airbyte-integrations/connectors/source-facebook-marketing/unit_tests/integration/__init__.py

Whitespace-only changes.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#
2+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
6+
from __future__ import annotations
7+
8+
from datetime import datetime
9+
from typing import Any, List, MutableMapping
10+
11+
import pendulum
12+
13+
ACCESS_TOKEN = "test_access_token"
14+
ACCOUNT_ID = "111111111111111"
15+
CLIENT_ID = "test_client_id"
16+
CLIENT_SECRET = "test_client_secret"
17+
DATE_FORMAT = "%Y-%m-%d"
18+
DATE_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
19+
END_DATE = "2023-01-01T23:59:59Z"
20+
NOW = pendulum.now(tz="utc")
21+
START_DATE = "2023-01-01T00:00:00Z"
22+
23+
24+
class ConfigBuilder:
25+
def __init__(self) -> None:
26+
self._config: MutableMapping[str, Any] = {
27+
"account_ids": [ACCOUNT_ID],
28+
"access_token": ACCESS_TOKEN,
29+
"start_date": START_DATE,
30+
"end_date": END_DATE,
31+
"include_deleted": True,
32+
"fetch_thumbnail_images": True,
33+
"custom_insights": [],
34+
"page_size": 100,
35+
"insights_lookback_window": 28,
36+
"insights_job_timeout": 60,
37+
"action_breakdowns_allow_empty": True,
38+
"client_id": CLIENT_ID,
39+
"client_secret": CLIENT_SECRET,
40+
}
41+
42+
def with_account_ids(self, account_ids: List[str]) -> ConfigBuilder:
43+
self._config["account_ids"] = account_ids
44+
return self
45+
46+
def with_start_date(self, start_date: datetime) -> ConfigBuilder:
47+
self._config["start_date"] = start_date.strftime(DATE_TIME_FORMAT)
48+
return self
49+
50+
def with_end_date(self, end_date: datetime) -> ConfigBuilder:
51+
self._config["end_date"] = end_date.strftime(DATE_TIME_FORMAT)
52+
return self
53+
54+
def build(self) -> MutableMapping[str, Any]:
55+
return self._config
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#
2+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
6+
from typing import Any, Dict
7+
from urllib.parse import urlunparse
8+
9+
from airbyte_cdk.test.mock_http.request import HttpRequest
10+
from airbyte_cdk.test.mock_http.response_builder import PaginationStrategy
11+
12+
NEXT_PAGE_TOKEN = "QVFIUlhOX3Rnbm5Y"
13+
14+
15+
class FacebookMarketingPaginationStrategy(PaginationStrategy):
16+
def __init__(self, request: HttpRequest, next_page_token: str) -> None:
17+
self._next_page_token = next_page_token
18+
self._next_page_url = f"{urlunparse(request._parsed_url)}&after={self._next_page_token}"
19+
20+
def update(self, response: Dict[str, Any]) -> None:
21+
# set a constant value for paging.cursors.after so we know how the 'next' link is built
22+
# https://developers.facebook.com/docs/graph-api/results
23+
response["paging"]["cursors"]["after"] = self._next_page_token
24+
response["paging"]["next"] = self._next_page_url
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#
2+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
6+
from __future__ import annotations
7+
8+
from typing import Any, List, Mapping, Optional, Union
9+
10+
from airbyte_cdk.test.mock_http.request import HttpRequest
11+
12+
from .config import ACCESS_TOKEN, ACCOUNT_ID
13+
14+
15+
def get_account_request(account_id: Optional[str] = ACCOUNT_ID) -> RequestBuilder:
16+
return RequestBuilder.get_account_endpoint(access_token=ACCESS_TOKEN, account_id=account_id)
17+
18+
19+
class RequestBuilder:
20+
21+
@classmethod
22+
def get_account_endpoint(cls, access_token: str, account_id: str) -> RequestBuilder:
23+
return cls(access_token=access_token).with_account_id(account_id)
24+
25+
@classmethod
26+
def get_videos_endpoint(cls, access_token: str, account_id: str) -> RequestBuilder:
27+
return cls(access_token=access_token, resource="advideos").with_account_id(account_id)
28+
29+
@classmethod
30+
def get_insights_endpoint(cls, access_token: str, account_id: str) -> RequestBuilder:
31+
return cls(access_token=access_token, resource="insights").with_account_id(account_id)
32+
33+
@classmethod
34+
def get_execute_batch_endpoint(cls, access_token: str) -> RequestBuilder:
35+
return cls(access_token=access_token)
36+
37+
@classmethod
38+
def get_insights_download_endpoint(cls, access_token: str, job_id: str) -> RequestBuilder:
39+
return cls(access_token=access_token, resource=f"{job_id}/insights")
40+
41+
def __init__(self, access_token: str, resource: Optional[str] = "") -> None:
42+
self._account_id = None
43+
self._resource = resource
44+
self._query_params = {"access_token": access_token}
45+
self._body = None
46+
47+
def with_account_id(self, account_id: str) -> RequestBuilder:
48+
self._account_id = account_id
49+
return self
50+
51+
def with_limit(self, limit: int) -> RequestBuilder:
52+
self._query_params["limit"] = limit
53+
return self
54+
55+
def with_summary(self) -> RequestBuilder:
56+
self._query_params["summary"] = "true"
57+
return self
58+
59+
def with_fields(self, fields: List[str]) -> RequestBuilder:
60+
self._query_params["fields"] = self._get_formatted_fields(fields)
61+
return self
62+
63+
def with_next_page_token(self, next_page_token: str) -> RequestBuilder:
64+
self._query_params["after"] = next_page_token
65+
return self
66+
67+
def with_body(self, body: Union[str, bytes, Mapping[str, Any]]) -> RequestBuilder:
68+
self._body = body
69+
return self
70+
71+
def build(self) -> HttpRequest:
72+
return HttpRequest(
73+
url=f"https://graph.facebook.com/v17.0/{self._account_sub_path()}{self._resource}",
74+
query_params=self._query_params,
75+
body=self._body,
76+
)
77+
78+
def _account_sub_path(self) -> str:
79+
return f"act_{self._account_id}/" if self._account_id else ""
80+
81+
@staticmethod
82+
def _get_formatted_fields(fields: List[str]) -> str:
83+
return ",".join(fields)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#
2+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
6+
import json
7+
from http import HTTPStatus
8+
from typing import Any, List, Mapping, Optional, Union
9+
10+
from airbyte_cdk.test.mock_http import HttpResponse
11+
12+
from .config import ACCOUNT_ID
13+
14+
15+
def build_response(
16+
body: Union[Mapping[str, Any], List[Mapping[str, Any]]],
17+
status_code: HTTPStatus,
18+
headers: Optional[Mapping[str, str]] = None,
19+
) -> HttpResponse:
20+
headers = headers or {}
21+
return HttpResponse(body=json.dumps(body), status_code=status_code.value, headers=headers)
22+
23+
24+
def get_account_response(account_id: Optional[str] = ACCOUNT_ID) -> HttpResponse:
25+
response = {"account_id": account_id, "id": f"act_{account_id}"}
26+
return build_response(body=response, status_code=HTTPStatus.OK)
27+
28+
29+
def error_reduce_amount_of_data_response() -> HttpResponse:
30+
response = {
31+
"error": {"code": 1, "message": "Please reduce the amount of data you're asking for, then retry your request"},
32+
}
33+
return build_response(body=response, status_code=HTTPStatus.INTERNAL_SERVER_ERROR)

0 commit comments

Comments
 (0)