300 lines
12 KiB
Python
300 lines
12 KiB
Python
#
|
|
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
|
|
#
|
|
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
from urllib.parse import unquote
|
|
|
|
import pendulum
|
|
import pytest
|
|
from airbyte_cdk.models import SyncMode, Type
|
|
from freezegun import freeze_time
|
|
from source_google_analytics_v4.source import (
|
|
DATA_IS_NOT_GOLDEN_MSG,
|
|
RESULT_IS_SAMPLED_MSG,
|
|
GoogleAnalyticsV4IncrementalObjectsBase,
|
|
GoogleAnalyticsV4Stream,
|
|
GoogleAnalyticsV4TypesList,
|
|
SourceGoogleAnalyticsV4,
|
|
)
|
|
|
|
expected_metrics_dimensions_type_map = (
|
|
{"ga:users": "INTEGER", "ga:newUsers": "INTEGER"},
|
|
{"ga:date": "STRING", "ga:country": "STRING"},
|
|
)
|
|
|
|
|
|
def test_metrics_dimensions_type_list(mock_metrics_dimensions_type_list_link):
|
|
test_metrics, test_dimensions = GoogleAnalyticsV4TypesList().read_records(sync_mode=None)
|
|
|
|
assert test_metrics, test_dimensions == expected_metrics_dimensions_type_map
|
|
|
|
|
|
def get_metrics_dimensions_mapping():
|
|
test_metrics_dimensions_map = {
|
|
"metric": [("ga:users", "integer"), ("ga:newUsers", "integer")],
|
|
"dimension": [("ga:dimension", "string"), ("ga:dateHourMinute", "integer")],
|
|
}
|
|
for field_type, attribute_expected_pairs in test_metrics_dimensions_map.items():
|
|
for attribute_expected_pair in attribute_expected_pairs:
|
|
attribute, expected = attribute_expected_pair
|
|
yield field_type, attribute, expected
|
|
|
|
|
|
@pytest.mark.parametrize("metrics_dimensions_mapping", get_metrics_dimensions_mapping())
|
|
def test_lookup_metrics_dimensions_data_type(test_config, metrics_dimensions_mapping, mock_metrics_dimensions_type_list_link):
|
|
field_type, attribute, expected = metrics_dimensions_mapping
|
|
g = GoogleAnalyticsV4Stream(config=test_config)
|
|
test = g.lookup_data_type(field_type, attribute)
|
|
assert test == expected
|
|
|
|
|
|
def test_data_is_not_golden_is_logged_as_warning(
|
|
mock_api_returns_is_data_golden_false,
|
|
test_config,
|
|
configured_catalog,
|
|
mock_metrics_dimensions_type_list_link,
|
|
mock_auth_call,
|
|
caplog,
|
|
):
|
|
source = SourceGoogleAnalyticsV4()
|
|
list(source.read(logging.getLogger(), test_config, configured_catalog))
|
|
assert DATA_IS_NOT_GOLDEN_MSG in caplog.text
|
|
|
|
|
|
def test_sampled_result_is_logged_as_warning(
|
|
mock_api_returns_sampled_results,
|
|
test_config,
|
|
configured_catalog,
|
|
mock_metrics_dimensions_type_list_link,
|
|
mock_auth_call,
|
|
caplog,
|
|
):
|
|
source = SourceGoogleAnalyticsV4()
|
|
list(source.read(logging.getLogger(), test_config, configured_catalog))
|
|
assert RESULT_IS_SAMPLED_MSG in caplog.text
|
|
|
|
|
|
def test_no_regressions_for_result_is_sampled_and_data_is_golden_warnings(
|
|
mock_api_returns_valid_records,
|
|
test_config,
|
|
configured_catalog,
|
|
mock_metrics_dimensions_type_list_link,
|
|
mock_auth_call,
|
|
caplog,
|
|
):
|
|
source = SourceGoogleAnalyticsV4()
|
|
list(source.read(logging.getLogger(), test_config, configured_catalog))
|
|
assert RESULT_IS_SAMPLED_MSG not in caplog.text
|
|
assert DATA_IS_NOT_GOLDEN_MSG not in caplog.text
|
|
|
|
|
|
@patch("source_google_analytics_v4.source.jwt")
|
|
def test_check_connection_fails_jwt(
|
|
jwt_encode_mock,
|
|
test_config_auth_service,
|
|
requests_mock,
|
|
mock_metrics_dimensions_type_list_link,
|
|
mock_auth_call
|
|
):
|
|
"""
|
|
check_connection fails because of the API returns no records,
|
|
then we assume than user doesn't have permission to read requested `view`
|
|
"""
|
|
source = SourceGoogleAnalyticsV4()
|
|
requests_mock.register_uri("POST", "https://analyticsreporting.googleapis.com/v4/reports:batchGet",
|
|
[{"status_code": 403,
|
|
"json": {"results": [],
|
|
"error": "User does not have sufficient permissions for this profile."}}])
|
|
|
|
is_success, msg = source.check_connection(MagicMock(), test_config_auth_service)
|
|
assert is_success is False
|
|
assert (
|
|
msg
|
|
== f"Please check the permissions for the requested view_id: {test_config_auth_service['view_id']}. "
|
|
f"User does not have sufficient permissions for this profile."
|
|
)
|
|
jwt_encode_mock.encode.assert_called()
|
|
assert mock_auth_call.called
|
|
|
|
|
|
@patch("source_google_analytics_v4.source.jwt")
|
|
def test_check_connection_success_jwt(
|
|
jwt_encode_mock,
|
|
test_config_auth_service,
|
|
mocker,
|
|
mock_metrics_dimensions_type_list_link,
|
|
mock_auth_call,
|
|
mock_api_returns_valid_records,
|
|
):
|
|
"""
|
|
check_connection succeeds because of the API returns valid records for the latest date based slice,
|
|
then we assume than user has permission to read requested `view`
|
|
"""
|
|
source = SourceGoogleAnalyticsV4()
|
|
is_success, msg = source.check_connection(MagicMock(), test_config_auth_service)
|
|
assert is_success is True
|
|
assert msg is None
|
|
jwt_encode_mock.encode.assert_called()
|
|
assert mock_auth_call.called
|
|
assert mock_api_returns_valid_records.called
|
|
|
|
|
|
@patch("source_google_analytics_v4.source.jwt")
|
|
def test_check_connection_fails_oauth(
|
|
jwt_encode_mock,
|
|
test_config,
|
|
mock_metrics_dimensions_type_list_link,
|
|
mock_auth_call,
|
|
requests_mock
|
|
):
|
|
"""
|
|
check_connection fails because of the API returns no records,
|
|
then we assume than user doesn't have permission to read requested `view`
|
|
"""
|
|
source = SourceGoogleAnalyticsV4()
|
|
requests_mock.register_uri("POST", "https://analyticsreporting.googleapis.com/v4/reports:batchGet",
|
|
[{"status_code": 403,
|
|
"json": {"results": [],
|
|
"error": "User does not have sufficient permissions for this profile."}}])
|
|
is_success, msg = source.check_connection(MagicMock(), test_config)
|
|
assert is_success is False
|
|
assert (
|
|
msg == f"Please check the permissions for the requested view_id: {test_config['view_id']}."
|
|
f" User does not have sufficient permissions for this profile."
|
|
)
|
|
jwt_encode_mock.encode.assert_not_called()
|
|
assert "https://www.googleapis.com/auth/analytics.readonly" in unquote(mock_auth_call.last_request.body)
|
|
assert "client_id_val" in unquote(mock_auth_call.last_request.body)
|
|
assert "client_secret_val" in unquote(mock_auth_call.last_request.body)
|
|
assert "refresh_token_val" in unquote(mock_auth_call.last_request.body)
|
|
assert mock_auth_call.called
|
|
|
|
|
|
@patch("source_google_analytics_v4.source.jwt")
|
|
def test_check_connection_success_oauth(
|
|
jwt_encode_mock,
|
|
test_config,
|
|
mocker,
|
|
mock_metrics_dimensions_type_list_link,
|
|
mock_auth_call,
|
|
mock_api_returns_valid_records,
|
|
):
|
|
"""
|
|
check_connection succeeds because of the API returns valid records for the latest date based slice,
|
|
then we assume than user has permission to read requested `view`
|
|
"""
|
|
source = SourceGoogleAnalyticsV4()
|
|
is_success, msg = source.check_connection(MagicMock(), test_config)
|
|
assert is_success is True
|
|
assert msg is None
|
|
jwt_encode_mock.encode.assert_not_called()
|
|
assert "https://www.googleapis.com/auth/analytics.readonly" in unquote(mock_auth_call.last_request.body)
|
|
assert "client_id_val" in unquote(mock_auth_call.last_request.body)
|
|
assert "client_secret_val" in unquote(mock_auth_call.last_request.body)
|
|
assert "refresh_token_val" in unquote(mock_auth_call.last_request.body)
|
|
assert mock_auth_call.called
|
|
assert mock_api_returns_valid_records.called
|
|
|
|
|
|
def test_unknown_metrics_or_dimensions_error_validation(
|
|
mocker, test_config, mock_metrics_dimensions_type_list_link, mock_unknown_metrics_or_dimensions_error
|
|
):
|
|
records = GoogleAnalyticsV4Stream(test_config).read_records(sync_mode=None)
|
|
assert list(records) == []
|
|
|
|
|
|
def test_daily_request_limit_error_validation(mocker, test_config, mock_metrics_dimensions_type_list_link, mock_daily_request_limit_error):
|
|
records = GoogleAnalyticsV4Stream(test_config).read_records(sync_mode=None)
|
|
assert list(records) == []
|
|
|
|
|
|
@freeze_time("2021-11-30")
|
|
def test_stream_slice_limits(test_config, mock_metrics_dimensions_type_list_link):
|
|
test_config["window_in_days"] = 14
|
|
g = GoogleAnalyticsV4IncrementalObjectsBase(config=test_config)
|
|
stream_state = {"ga_date": "2021-11-25"}
|
|
slices = g.stream_slices(stream_state=stream_state)
|
|
current_date = pendulum.now().date().strftime("%Y-%m-%d")
|
|
expected_start_date = "2021-11-24" # always resync two days back
|
|
expected_end_date = current_date # do not try to sync future dates
|
|
assert slices == [{"startDate": expected_start_date, "endDate": expected_end_date}]
|
|
|
|
|
|
@freeze_time("2021-11-30")
|
|
def test_empty_stream_slice_if_abnormal_state_is_passed(test_config, mock_metrics_dimensions_type_list_link):
|
|
g = GoogleAnalyticsV4IncrementalObjectsBase(config=test_config)
|
|
stream_state = {"ga_date": "2050-05-01"}
|
|
slices = g.stream_slices(stream_state=stream_state)
|
|
assert slices == [None]
|
|
|
|
|
|
def test_empty_slice_produces_no_records(test_config, mock_metrics_dimensions_type_list_link):
|
|
g = GoogleAnalyticsV4IncrementalObjectsBase(config=test_config)
|
|
records = g.read_records(sync_mode=SyncMode.incremental, stream_slice=None, stream_state={g.cursor_field: g.start_date})
|
|
assert next(iter(records), None) is None
|
|
|
|
|
|
def test_state_saved_after_each_record(test_config, mock_metrics_dimensions_type_list_link):
|
|
today_dt = pendulum.now().date()
|
|
before_yesterday = today_dt.subtract(days=2).strftime("%Y-%m-%d")
|
|
today = today_dt.strftime("%Y-%m-%d")
|
|
record = {"ga_date": today}
|
|
g = GoogleAnalyticsV4IncrementalObjectsBase(config=test_config)
|
|
state = {g.cursor_field: before_yesterday}
|
|
assert g.get_updated_state(state, record) == {g.cursor_field: today}
|
|
|
|
|
|
def test_connection_fail_invalid_reports_json(test_config):
|
|
source = SourceGoogleAnalyticsV4()
|
|
test_config["custom_reports"] = '[{{"name": "test", "dimensions": [], "metrics": []}}]'
|
|
ok, error = source.check_connection(logging.getLogger(), test_config)
|
|
assert not ok
|
|
assert "Invalid custom reports json structure." in error
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "json_resp"),
|
|
(
|
|
(403, {"error": "Your role is not not granted the permission for accessing this resource"}),
|
|
(500, {"error": "Internal server error, please contact support"}),
|
|
),
|
|
)
|
|
def test_connection_fail_due_to_http_status(
|
|
mocker, test_config, requests_mock, mock_auth_call, mock_metrics_dimensions_type_list_link, status, json_resp
|
|
):
|
|
mocker.patch("time.sleep")
|
|
requests_mock.post("https://analyticsreporting.googleapis.com/v4/reports:batchGet", status_code=status, json=json_resp)
|
|
source = SourceGoogleAnalyticsV4()
|
|
ok, error = source.check_connection(logging.getLogger(), test_config)
|
|
assert not ok
|
|
if status == 403:
|
|
assert "Please check the permissions for the requested view_id" in error
|
|
assert test_config["view_id"] in error
|
|
assert json_resp["error"] in error
|
|
|
|
|
|
def test_is_data_golden_flag_missing_equals_false(
|
|
mock_api_returns_is_data_golden_false, test_config, configured_catalog, mock_metrics_dimensions_type_list_link, mock_auth_call
|
|
):
|
|
source = SourceGoogleAnalyticsV4()
|
|
for message in source.read(logging.getLogger(), test_config, configured_catalog):
|
|
if message.type == Type.RECORD:
|
|
assert message.record.data["isDataGolden"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"configured_response, expected_token",
|
|
(
|
|
({}, None),
|
|
({"reports": []}, None),
|
|
({"reports": [{"data": {}, "columnHeader": {}}]}, None),
|
|
({"reports": [{"data": {}, "columnHeader": {}, "nextPageToken": 100000}]}, {"pageToken": 100000}),
|
|
),
|
|
)
|
|
def test_next_page_token(test_config, configured_response, expected_token):
|
|
response = MagicMock(json=MagicMock(return_value=configured_response))
|
|
token = GoogleAnalyticsV4Stream(test_config).next_page_token(response)
|
|
assert token == expected_token
|