Skip to content

Commit 9e47b8b

Browse files
Fix access token refresh issue (#69)
* fixes to access token and projects data fetching * fix integration tests * close asana client in tests * update testcase for asana * remove unused test * update tests * add comments for projects * handles 401 error * handles unauthorized with build_client * Add Asana API error handling to recursive subtask fetching by decorating fetch_children with asana_error_handling * fix pylint * adds unit test case for substasks error handling * fix unit test --------- Co-authored-by: mukeshbhatt18gl <mukesh.bhatt@qlik.com>
1 parent 0f285a0 commit 9e47b8b

12 files changed

+216
-9
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 2.4.1
4+
* Add Asana API error handling to recursive subtask fetching by decorating fetch_children with asana_error_handling.[#69](https://github.com/singer-io/tap-asana/pull/69)
5+
6+
37
## 2.4.0
48
* Upgrade asana SDK version to 5.1.0. [#61](https://github.com/singer-io/tap-asana/pull/61)
59

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33

44
setup(
55
name="tap-asana",
6-
version="2.4.0",
6+
version="2.4.1",
77
description="Singer.io tap for extracting Asana data",
88
author="Stitch",
99
url="http://github.com/singer-io/tap-asana",
1010
classifiers=["Programming Language :: Python :: 3 :: Only"],
1111
py_modules=["tap_asana"],
1212
install_requires=[
1313
"asana==5.1.0",
14+
"parameterized",
1415
"requests==2.32.4",
1516
"singer-python==6.1.1"
1617
],

tap_asana/streams/projects.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def get_objects(self):
4747
bookmark = self.get_bookmark()
4848
session_bookmark = bookmark
4949

50+
5051
workspaces = self.fetch_workspaces()
5152

5253
# Use ProjectsApi to fetch projects

tap_asana/streams/subtasks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asana
22
import singer
33
from tap_asana.context import Context
4-
from tap_asana.streams.base import Stream
4+
from tap_asana.streams.base import asana_error_handling, Stream
55

66
LOGGER = singer.get_logger()
77

@@ -85,6 +85,7 @@ def get_objects(self):
8585
# Update the bookmark after processing all subtasks
8686
self.update_bookmark(session_bookmark)
8787

88+
@asana_error_handling
8889
def fetch_children(self, p_task, opt_fields):
8990
subtasks_children = []
9091
resource = asana.TasksApi(Context.asana.client)

tests/test_bookmarks.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,21 @@ def name(self):
1313

1414
def test_run(self):
1515
# running sync with multiple date timestamps accross different streams due to differences in bookmark values
16-
self.run_test("2021-11-09T00:00:00Z", "2023-11-10T00:00:00Z", {"projects",})
1716
self.run_test("2023-11-28T00:00:00Z", "2023-11-30T00:00:00Z", {"subtasks",})
1817
# Removing Portfolios as they are only available for users in an Enterprise or Business plan.
18+
# Removing Projects as data is available for the same date only.
1919
self.run_test("2019-01-28T00:00:00Z", "2023-11-30T00:00:00Z", self.expected_streams() - {"subtasks","projects","portfolios"})
2020

21+
def parse_start_date_ts(self, start_date):
22+
"""Parse either midnight-only or full timestamp start dates into epoch seconds."""
23+
for dt_format in (self.START_DATE_FORMAT, "%Y-%m-%dT%H:%M:%SZ", self.BOOKMARK_FOMAT):
24+
try:
25+
return self.dt_to_ts(start_date, dt_format)
26+
except ValueError:
27+
continue
28+
29+
raise ValueError(f"Unsupported start_date format: {start_date}")
30+
2131

2232
def run_test(self, start_date_1, start_date_2, streams):
2333
"""
@@ -135,7 +145,7 @@ def run_test(self, start_date_1, start_date_2, streams):
135145
# We have added 'second_start_date' as the bookmark, it is more recent than
136146
# the default start date and it will work as a simulated bookmark
137147
self.assertGreaterEqual(
138-
replication_key_value_parsed, self.dt_to_ts(self.second_start_date, self.START_DATE_FORMAT),
148+
replication_key_value_parsed, self.parse_start_date_ts(self.second_start_date),
139149
msg="Second sync did not respect the bookmark, \
140150
a record with a smaller replication-key value was synced."
141151
)

tests/test_start_date.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,21 @@ def name(self):
1212

1313
def test_run(self):
1414
# running sync with multiple date timestamps accross different streams due to differences in bookmark values
15-
self.run_test("2021-11-09T00:00:00Z", "2023-11-10T00:00:00Z", {"projects",})
1615
self.run_test("2023-11-28T00:00:00Z", "2023-11-30T00:00:00Z", {"subtasks",})
1716
# Removing Portfolios as they are only available for users in an Enterprise or Business plan.
17+
# Removing Projects as data is available for the same date only.
1818
self.run_test("2019-01-28T00:00:00Z", "2023-11-30T00:00:00Z", self.expected_streams() - {"subtasks","projects","portfolios"})
19-
19+
20+
def parse_start_date_ts(self, start_date):
21+
"""Parse either midnight-only or full timestamp start dates into epoch seconds."""
22+
for dt_format in (self.START_DATE_FORMAT, "%Y-%m-%dT%H:%M:%SZ", self.BOOKMARK_FOMAT):
23+
try:
24+
return self.dt_to_ts(start_date, dt_format)
25+
except ValueError:
26+
continue
27+
28+
raise ValueError(f"Unsupported start_date format: {start_date}")
29+
2030
def run_test(self, start_date_1, start_date_2, streams):
2131
"""
2232
Testing that the tap respects the start date
@@ -39,8 +49,8 @@ def run_test(self, start_date_1, start_date_2, streams):
3949
expected_streams = streams
4050

4151

42-
start_date_1_epoch = self.dt_to_ts(self.first_start_date, self.START_DATE_FORMAT)
43-
start_date_2_epoch = self.dt_to_ts(self.second_start_date, self.START_DATE_FORMAT)
52+
start_date_1_epoch = self.parse_start_date_ts(self.first_start_date)
53+
start_date_2_epoch = self.parse_start_date_ts(self.second_start_date)
4454

4555
##########################################################################
4656
# Update Start Date for 1st sync

tests/unittests/test_asana_auth.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import unittest
2+
from unittest import mock
3+
4+
from tap_asana.asana import Asana
5+
6+
7+
class TestAsanaAuth(unittest.TestCase):
8+
def tearDown(self):
9+
if hasattr(self, "asana_client") and self.asana_client.client:
10+
try:
11+
self.asana_client.client.pool.close()
12+
self.asana_client.client.pool.join()
13+
except Exception:
14+
pass
15+
16+
@mock.patch("tap_asana.asana.requests.post")
17+
def test_refresh_access_token_success_updates_client_and_token(self, mocked_post):
18+
response = mock.Mock()
19+
response.status_code = 200
20+
response.json.return_value = {"access_token": "new_token"}
21+
mocked_post.return_value = response
22+
23+
self.asana_client = Asana("id", "secret", "uri", "refresh", "old_token")
24+
25+
token = self.asana_client.refresh_access_token()
26+
27+
self.assertEqual(token, "new_token")
28+
self.assertEqual(self.asana_client.access_token, "new_token")
29+
self.assertIsNotNone(self.asana_client.client)
30+
31+
@mock.patch("tap_asana.asana.requests.post")
32+
def test_refresh_access_token_missing_access_token_returns_none(self, mocked_post):
33+
response = mock.Mock()
34+
response.status_code = 200
35+
response.json.return_value = {"token_type": "bearer"}
36+
mocked_post.return_value = response
37+
38+
self.asana_client = Asana("id", "secret", "uri", "refresh", "old_token")
39+
40+
token = self.asana_client.refresh_access_token()
41+
42+
self.assertIsNone(token)
43+
self.assertEqual(self.asana_client.access_token, "old_token")
44+
45+
@mock.patch("tap_asana.asana.requests.post")
46+
def test_refresh_access_token_non_200_returns_none(self, mocked_post):
47+
response = mock.Mock()
48+
response.status_code = 401
49+
response.text = '{"errors":[{"message":"expired"}]}'
50+
mocked_post.return_value = response
51+
52+
self.asana_client = Asana("id", "secret", "uri", "refresh", "old_token")
53+
54+
token = self.asana_client.refresh_access_token()
55+
56+
self.assertIsNone(token)
57+
self.assertEqual(self.asana_client.access_token, "old_token")

tests/unittests/test_custom_exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ def setUp(self):
1616
"""Set up the Asana client and mock context."""
1717
Context.asana = Asana("test", "test", "test", "test", "test")
1818

19+
def tearDown(self):
20+
"""Close the Asana client to suppress ApiClient.__del__ warnings."""
21+
if hasattr(Context.asana, 'client') and Context.asana.client:
22+
try:
23+
Context.asana.client.pool.close()
24+
Context.asana.client.pool.join()
25+
except Exception:
26+
pass
27+
1928
@mock.patch("asana.WorkspacesApi.get_workspaces")
2029
def test_call_api_invalid_token_error(self, mocked_get_workspaces):
2130
"""Test call_api raises InvalidTokenError."""
@@ -80,6 +89,15 @@ def setUp(self):
8089
"""Set up the Asana client and mock context."""
8190
Context.asana = Asana("test", "test", "test", "test", "test")
8291

92+
def tearDown(self):
93+
"""Close the Asana client to suppress ApiClient.__del__ warnings."""
94+
if hasattr(Context.asana, 'client') and Context.asana.client:
95+
try:
96+
Context.asana.client.pool.close()
97+
Context.asana.client.pool.join()
98+
except Exception:
99+
pass
100+
83101
@mock.patch("asana.WorkspacesApi.get_workspaces")
84102
def test_call_api_retry_invalid_token_error(self, mocked_get_workspaces):
85103
"""Test call_api retries on InvalidTokenError."""

tests/unittests/test_project_id_caching.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ def mock_call_api(*args, **kwargs):
5454
@mock.patch("tap_asana.streams.base.Stream.call_api")
5555
class TestProjectIdCaching(unittest.TestCase):
5656

57+
def tearDown(self):
58+
"""Close the Asana client to suppress ApiClient.__del__ warnings."""
59+
if hasattr(Context, 'asana') and hasattr(Context.asana, 'client') and Context.asana.client:
60+
try:
61+
Context.asana.client.pool.close()
62+
Context.asana.client.pool.join()
63+
except Exception:
64+
pass
65+
5766
def test_sections(self, mocked_call_api, mocked_fetch_projects, mocked_fetch_workspaces, mocked_sleep, mocked_refresh_access_token):
5867
# Set config file
5968
Context.config = {'start_date': '2021-01-01T00:00:00Z'}

tests/unittests/test_refersh_access_token.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ def token_expired_error_raiser(*args, **kwargs):
1919
@mock.patch("time.sleep")
2020
class TestRefreshAccessToken(unittest.TestCase):
2121

22+
def tearDown(self):
23+
"""Close the Asana client to suppress ApiClient.__del__ warnings."""
24+
if hasattr(Context, 'asana') and hasattr(Context.asana, 'client') and Context.asana.client:
25+
try:
26+
Context.asana.client.pool.close()
27+
Context.asana.client.pool.join()
28+
except Exception:
29+
pass
30+
2231
def test_invalid_token_error_for_get_initial(self, mocked_sleep, mocked_get_tasks, mocked_refresh_access_token):
2332
"""
2433
Verify that refresh_access_token is called five times due to InvalidTokenError during the initial API call.

0 commit comments

Comments
 (0)