Skip to content

Commit 39baad8

Browse files
committed
Added review comment changes
1 parent 75f7b65 commit 39baad8

5 files changed

Lines changed: 93 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
## 1.3.0
2-
- Streams the credentials cannot access (HTTP 401/403) are excluded from the catalog during discovery (discovery still fails if no parent streams are accessible). [#17](https://github.com/singer-io/tap-campaign-monitor/pull/17)
2+
- Streams the credentials cannot access (HTTP 403) are excluded from the catalog during discovery; invalid credentials (HTTP 401) fail discovery immediately, as does having no accessible parent stream. [#17](https://github.com/singer-io/tap-campaign-monitor/pull/17)
33
- Bump to requests==2.34.2
44

55
## 1.2.0

‎tap_campaign_monitor/__init__.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
LOGGER = singer.get_logger() # noqa
1717

1818

19-
def verify_credentials(config):
19+
def verify_credentials(config, load_timezone=True):
2020
LOGGER.info("Verifying credentials.")
2121
try:
22-
client = CampaignMonitorClient(config)
22+
client = CampaignMonitorClient(config, load_timezone=load_timezone)
2323
LOGGER.info("Credentials verified successfully.")
2424
return client
2525
except Exception as e:
@@ -112,7 +112,9 @@ def main():
112112
args = singer.utils.parse_args(
113113
required_config_keys=['client_id', 'refresh_token'])
114114

115-
client = verify_credentials(args.config)
115+
client = verify_credentials(
116+
args.config, load_timezone=not args.discover
117+
)
116118

117119
if args.discover:
118120
do_discover(args, client)

‎tap_campaign_monitor/client.py‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,49 @@ class CampaignMonitorForbiddenError(Exception):
3434

3535
class CampaignMonitorClient:
3636

37-
def __init__(self, config):
37+
def __init__(self, config, load_timezone=True):
3838
self.config = config
3939
self._retry_after = RETRY_RATE_LIMIT
4040
self.access_token = self.refresh_access_token()
41-
self.timezone = self.get_timezone()
42-
LOGGER.info("Client timezone is {}".format(self.timezone))
41+
self.timezone = self.get_timezone() if load_timezone else None
42+
if load_timezone:
43+
LOGGER.info("Client timezone is {}".format(self.timezone))
4344

4445
def refresh_access_token(self):
4546
LOGGER.info("Refreshing access token")
4647
url = "https://api.createsend.com/oauth/token"
4748
data = {'grant_type': 'refresh_token', 'refresh_token': self.config['refresh_token']}
4849
response = requests.request("POST", url, data=data)
4950
payload = response.json()
50-
if 'access_token' not in payload:
51+
oauth_error = payload.get('error')
52+
if response.status_code == 401 or (
53+
response.status_code == 400
54+
and oauth_error in {'invalid_client', 'invalid_grant',
55+
'unauthorized_client'}):
5156
raise CampaignMonitorUnauthorizedError(
5257
"HTTP-error-code: 401, Error: Invalid credentials: {}".format(
5358
payload.get('error_description') or payload.get('error') or response.text
5459
)
5560
)
61+
if response.status_code == 429:
62+
raise Server429Error(
63+
"HTTP-error-code: 429, Error: Rate limit exceeded. {}"
64+
.format(response.text)
65+
)
66+
if 500 <= response.status_code < 600:
67+
raise Server5xxError(
68+
"HTTP-error-code: {}, Error: {}"
69+
.format(response.status_code, response.text)
70+
)
71+
if response.status_code != 200:
72+
raise RuntimeError(
73+
"HTTP-error-code: {}, Error: {}"
74+
.format(response.status_code, response.text)
75+
)
76+
if 'access_token' not in payload:
77+
raise RuntimeError(
78+
"Invalid token response: access_token is missing."
79+
)
5680
return payload['access_token']
5781

5882
def get_timezone(self):

‎tests/unittests/test_client.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ def test_init_calls_refresh_and_timezone(self, mock_refresh, mock_tz):
2929
mock_refresh.assert_called_once()
3030
mock_tz.assert_called_once()
3131

32+
@patch('tap_campaign_monitor.client.CampaignMonitorClient.get_timezone')
33+
@patch('tap_campaign_monitor.client.CampaignMonitorClient.refresh_access_token')
34+
def test_init_can_skip_timezone(self, mock_refresh, mock_tz):
35+
"""Discovery can authenticate without requesting client details."""
36+
from tap_campaign_monitor.client import CampaignMonitorClient
37+
config = {'client_id': 'test_id', 'refresh_token': 'test_token'}
38+
39+
client = CampaignMonitorClient(config, load_timezone=False)
40+
41+
mock_refresh.assert_called_once()
42+
mock_tz.assert_not_called()
43+
self.assertIsNone(client.timezone)
44+
3245

3346
class TestRefreshAccessToken(unittest.TestCase):
3447
"""Test the refresh_access_token method."""
@@ -48,6 +61,36 @@ def test_refresh_access_token_missing_token_raises_unauthorized(self, mock_reque
4861
self.assertIn('401', str(ctx.exception))
4962
self.assertIn('Refresh token revoked', str(ctx.exception))
5063

64+
@parameterized.expand([
65+
(429, 'rate limited', 'Server429Error'),
66+
(500, 'server error', 'Server5xxError'),
67+
])
68+
@patch('tap_campaign_monitor.client.requests.request')
69+
def test_refresh_access_token_preserves_transient_error(
70+
self, status_code, response_text, exception_name, mock_request):
71+
"""Token-service transient failures are not reported as invalid credentials."""
72+
from tap_campaign_monitor import client as client_module
73+
from tap_campaign_monitor.client import CampaignMonitorClient
74+
mock_request.return_value = MockResponse(
75+
status_code, {'error': 'server_error'}, response_text
76+
)
77+
78+
with self.assertRaises(getattr(client_module, exception_name)):
79+
CampaignMonitorClient(
80+
{'client_id': 'test_id', 'refresh_token': 'test_token'}
81+
)
82+
83+
@patch('tap_campaign_monitor.client.requests.request')
84+
def test_refresh_access_token_rejects_malformed_success(self, mock_request):
85+
"""A successful response without a token is a response error, not a 401."""
86+
from tap_campaign_monitor.client import CampaignMonitorClient
87+
mock_request.return_value = MockResponse(200, {'unexpected': 'value'})
88+
89+
with self.assertRaisesRegex(RuntimeError, 'access_token is missing'):
90+
CampaignMonitorClient(
91+
{'client_id': 'test_id', 'refresh_token': 'test_token'}
92+
)
93+
5194
@patch('tap_campaign_monitor.client.CampaignMonitorClient.get_timezone')
5295
@patch('tap_campaign_monitor.client.requests.request')
5396
def test_refresh_access_token_success(self, mock_request, mock_tz):

‎tests/unittests/test_discovery.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ def _mock_client():
2929
return client
3030

3131

32+
class TestVerifyCredentials(unittest.TestCase):
33+
"""Unit tests for credential verification setup."""
34+
35+
@patch('tap_campaign_monitor.CampaignMonitorClient')
36+
def test_can_skip_timezone_for_discovery(self, mock_client_class):
37+
"""Discovery authentication does not request client timezone details."""
38+
from tap_campaign_monitor import verify_credentials
39+
40+
client = verify_credentials(CONFIG, load_timezone=False)
41+
42+
mock_client_class.assert_called_once_with(
43+
CONFIG, load_timezone=False
44+
)
45+
self.assertIs(client, mock_client_class.return_value)
46+
47+
3248
class TestCheckAccess(unittest.TestCase):
3349
"""Unit tests for BaseStream.check_access()."""
3450

0 commit comments

Comments
 (0)