Skip to content

Commit a946c8e

Browse files
committed
Add tests to achieve 100% coverage
- Add tests for debug mode in methods with paginated results - Add tests for _get_retry_after with various header conditions - Add tests for _make_direct_request functionality - Fix existing tests to properly mock underlying methods Commits in this PR ensure 100% test coverage for the pagination feature.
1 parent 2274887 commit a946c8e

File tree

9 files changed

+290
-10
lines changed

9 files changed

+290
-10
lines changed

docs/PAGINATION.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,23 @@ The pagination implementation uses the following approach:
9494

9595
### Pagination Iterator
9696

97-
- Uses the `PaginatedIterator` class that implements the Python `Iterator` protocol
98-
- Automatically handles fetching the next page when needed using the `next` URL from pagination metadata
99-
- Properly handles edge cases like invalid responses, missing pagination data, and API errors
97+
- Uses the `PaginatedIterator` class that implements the Python `Iterator`
98+
protocol
99+
- Automatically handles fetching the next page when needed using the `next` URL
100+
from pagination metadata
101+
- Properly handles edge cases like invalid responses, missing pagination data,
102+
and API errors
100103

101104
### Type Safety
102105

103-
- Uses `TYPE_CHECKING` from the typing module to avoid circular imports at runtime
106+
- Uses `TYPE_CHECKING` from the typing module to avoid circular imports at
107+
runtime
104108
- Maintains complete type safety and mypy compatibility
105109
- All pagination-related code has 100% test coverage
106110

107111
### Resource Integration
108112

109-
Each endpoint that supports pagination has an `as_iterator` parameter that, when set to `True`, returns a `PaginatedIterator` instead of the raw API response. This makes it easy to iterate through all pages of results without manually handling pagination.
113+
Each endpoint that supports pagination has an `as_iterator` parameter that, when
114+
set to `True`, returns a `PaginatedIterator` instead of the raw API response.
115+
This makes it easy to iterate through all pages of results without manually
116+
handling pagination.

docs/RATE_LIMITING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ client = FitbitClient(
3030

3131
# Rate limiting options (all optional)
3232
max_retries=5, # Maximum retry attempts (default: 3)
33-
retry_after_seconds=10, # Base wait time in seconds (default: 5)
33+
retry_after_seconds=30, # Base wait time in seconds (default: 60)
3434
retry_backoff_factor=2.0 # Multiplier for successive waits (default: 1.5)
3535
)
3636
```

fitbit_client/resources/activity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
# https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING
3030
if TYPE_CHECKING:
3131
# Local imports - only imported during type checking
32+
# Local imports
3233
from fitbit_client.resources.pagination import PaginatedIterator
3334

3435

tests/resources/activity/test_get_activity_log_list.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Standard library imports
66
from unittest.mock import Mock
77
from unittest.mock import call
8+
from unittest.mock import patch
89

910
# Third party imports
1011
from pytest import raises
@@ -139,3 +140,22 @@ def test_activity_log_list_pagination_attributes(
139140
params={"sort": "desc", "limit": 10, "offset": 0, "beforeDate": "2024-02-13"},
140141
headers={"Accept-Locale": "en_US", "Accept-Language": "en_US"},
141142
)
143+
144+
145+
@patch("fitbit_client.resources.base.BaseResource._make_request")
146+
def test_get_activity_log_list_with_debug(mock_make_request, activity_resource):
147+
"""Test that debug mode returns None from get_activity_log_list."""
148+
# Mock _make_request to return None when debug=True
149+
mock_make_request.return_value = None
150+
151+
result = activity_resource.get_activity_log_list(
152+
before_date="2023-01-01", sort=SortDirection.DESCENDING, debug=True
153+
)
154+
155+
assert result is None
156+
mock_make_request.assert_called_once_with(
157+
"activities/list.json",
158+
params={"sort": "desc", "limit": 100, "offset": 0, "beforeDate": "2023-01-01"},
159+
user_id="-",
160+
debug=True,
161+
)

tests/resources/electrocardiogram/test_get_ecg_log_list.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
"""Tests for the get_ecg_log_list endpoint."""
44

5+
# Standard library imports
6+
from unittest.mock import patch
7+
58
# Third party imports
69
from pytest import raises
710

@@ -145,3 +148,22 @@ def test_ecg_log_list_pagination_attributes(
145148
params={"sort": "desc", "limit": 5, "offset": 0, "beforeDate": "2024-02-14"},
146149
headers={"Accept-Locale": "en_US", "Accept-Language": "en_US"},
147150
)
151+
152+
153+
@patch("fitbit_client.resources.base.BaseResource._make_request")
154+
def test_get_ecg_log_list_with_debug(mock_make_request, ecg_resource):
155+
"""Test that debug mode returns None from get_ecg_log_list."""
156+
# Mock _make_request to return None when debug=True
157+
mock_make_request.return_value = None
158+
159+
result = ecg_resource.get_ecg_log_list(
160+
before_date="2023-01-01", sort=SortDirection.DESCENDING, debug=True
161+
)
162+
163+
assert result is None
164+
mock_make_request.assert_called_once_with(
165+
"ecg/list.json",
166+
params={"sort": "desc", "limit": 10, "offset": 0, "beforeDate": "2023-01-01"},
167+
user_id="-",
168+
debug=True,
169+
)

tests/resources/irregular_rhythm_notifications/test_get_irn_alerts_list.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
"""Tests for the get_irn_alerts_list endpoint."""
44

5+
# Standard library imports
6+
from unittest.mock import patch
7+
58
# Third party imports
69
from pytest import raises
710

@@ -151,3 +154,22 @@ def test_irn_alerts_list_pagination_attributes(
151154
params={"sort": "desc", "limit": 5, "offset": 0, "beforeDate": "2022-09-29"},
152155
headers={"Accept-Locale": "en_US", "Accept-Language": "en_US"},
153156
)
157+
158+
159+
@patch("fitbit_client.resources.base.BaseResource._make_request")
160+
def test_get_irn_alerts_list_with_debug(mock_make_request, irn_resource):
161+
"""Test that debug mode returns None from get_irn_alerts_list."""
162+
# Mock _make_request to return None when debug=True
163+
mock_make_request.return_value = None
164+
165+
result = irn_resource.get_irn_alerts_list(
166+
before_date="2022-09-28", sort=SortDirection.DESCENDING, debug=True
167+
)
168+
169+
assert result is None
170+
mock_make_request.assert_called_once_with(
171+
"irn/alerts/list.json",
172+
params={"sort": "desc", "limit": 10, "offset": 0, "beforeDate": "2022-09-28"},
173+
user_id="-",
174+
debug=True,
175+
)

tests/resources/sleep/test_get_sleep_log_list.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# Standard library imports
66
from unittest.mock import call
7+
from unittest.mock import patch
78

89
# Third party imports
910
from pytest import raises
@@ -145,3 +146,23 @@ def test_sleep_log_list_pagination_attributes(
145146
params={"sort": "desc", "limit": 10, "offset": 0, "beforeDate": "2024-02-13"},
146147
headers={"Accept-Locale": "en_US", "Accept-Language": "en_US"},
147148
)
149+
150+
151+
@patch("fitbit_client.resources.base.BaseResource._make_request")
152+
def test_get_sleep_log_list_with_debug(mock_make_request, sleep_resource):
153+
"""Test that debug mode returns None from get_sleep_log_list."""
154+
# Mock _make_request to return None when debug=True
155+
mock_make_request.return_value = None
156+
157+
result = sleep_resource.get_sleep_log_list(
158+
before_date="2024-02-13", sort=SortDirection.DESCENDING, debug=True
159+
)
160+
161+
assert result is None
162+
mock_make_request.assert_called_once_with(
163+
"sleep/list.json",
164+
params={"sort": "desc", "limit": 100, "offset": 0, "beforeDate": "2024-02-13"},
165+
user_id="-",
166+
api_version="1.2",
167+
debug=True,
168+
)

tests/resources/test_base.py

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,54 @@ def test_handle_error_response_with_empty_error_data(base_resource, mock_logger)
409409
# -----------------------------------------------------------------------------
410410

411411

412+
def test_get_retry_after_with_valid_header(base_resource):
413+
"""Test that _get_retry_after correctly parses a valid Retry-After header with a digit."""
414+
# Line 337: response.headers.get("Retry-After")
415+
mock_response = Mock()
416+
mock_response.headers = {"Retry-After": "30"}
417+
418+
# Set up retry parameters
419+
base_resource.retry_after_seconds = 10
420+
base_resource.retry_backoff_factor = 2
421+
422+
retry_seconds = base_resource._get_retry_after(mock_response, 1)
423+
424+
# Should use the header value (30) instead of calculated backoff
425+
assert retry_seconds == 30
426+
427+
428+
def test_get_retry_after_with_invalid_header(base_resource):
429+
"""Test that _get_retry_after falls back to calculated backoff when Retry-After header is not a digit."""
430+
mock_response = Mock()
431+
mock_response.headers = {"Retry-After": "not-a-number"}
432+
433+
# Set up retry parameters
434+
base_resource.retry_after_seconds = 10
435+
base_resource.retry_backoff_factor = 2
436+
437+
# For retry_count=1, should be 10 * (2^1) = 20
438+
retry_seconds = base_resource._get_retry_after(mock_response, 1)
439+
440+
# Should use calculated backoff
441+
assert retry_seconds == 20
442+
443+
444+
def test_get_retry_after_without_header(base_resource):
445+
"""Test that _get_retry_after falls back to calculated backoff when Retry-After header is missing."""
446+
mock_response = Mock()
447+
mock_response.headers = {} # No Retry-After header
448+
449+
# Set up retry parameters
450+
base_resource.retry_after_seconds = 10
451+
base_resource.retry_backoff_factor = 2
452+
453+
# For retry_count=0, should be 10 * (2^0) = 10
454+
retry_seconds = base_resource._get_retry_after(mock_response, 0)
455+
456+
# Should use calculated backoff
457+
assert retry_seconds == 10
458+
459+
412460
@patch("fitbit_client.resources.base.sleep")
413461
def test_rate_limit_retries(
414462
mock_sleep, base_resource, mock_oauth_session, mock_response_factory, mock_logger
@@ -529,7 +577,146 @@ def test_rate_limit_max_retries_exhausted(
529577

530578

531579
# -----------------------------------------------------------------------------
532-
# 11. API Error Status Codes
580+
# 11. Direct Request Testing
581+
# -----------------------------------------------------------------------------
582+
583+
584+
@patch("builtins.print")
585+
@patch("fitbit_client.resources.base.CurlDebugMixin._build_curl_command")
586+
def test_make_direct_request_with_debug(mock_build_curl, mock_print, base_resource):
587+
"""Test that _make_direct_request returns empty dict when debug=True."""
588+
# Mock the _build_curl_command method
589+
mock_build_curl.return_value = "curl -X GET https://example.com"
590+
591+
# The actual method signature is different from what we tried to test
592+
result = base_resource._make_direct_request("/test", debug=True)
593+
594+
# Should return empty dict in debug mode
595+
assert result == {}
596+
597+
# Should print the curl command
598+
mock_print.assert_called()
599+
600+
601+
@patch("fitbit_client.resources.base.BaseResource._handle_json_response")
602+
def test_make_direct_request_success(mock_handle_json, base_resource):
603+
"""Test successful direct request with JSON response."""
604+
# Mock the OAuth session
605+
base_resource.oauth = Mock()
606+
607+
# Create a mock response
608+
mock_response = Mock()
609+
mock_response.status_code = 200
610+
mock_response.headers = {"content-type": "application/json"}
611+
base_resource.oauth.request.return_value = mock_response
612+
613+
# Mock the _handle_json_response method
614+
mock_handle_json.return_value = {"data": "test"}
615+
616+
# Call the method
617+
result = base_resource._make_direct_request("/test")
618+
619+
# Should return the JSON data
620+
assert result == {"data": "test"}
621+
622+
# Verify the request was made
623+
base_resource.oauth.request.assert_called_once()
624+
mock_handle_json.assert_called_once()
625+
626+
627+
@patch("fitbit_client.resources.base.BaseResource._get_calling_method")
628+
def test_make_direct_request_unexpected_content_type(mock_get_calling, base_resource, mock_logger):
629+
"""Test handling of unexpected content type in direct request."""
630+
mock_get_calling.return_value = "test_method"
631+
632+
# Mock the OAuth session
633+
base_resource.oauth = Mock()
634+
635+
# Create a mock response
636+
mock_response = Mock()
637+
mock_response.status_code = 200
638+
mock_response.headers = {"content-type": "text/plain"}
639+
base_resource.oauth.request.return_value = mock_response
640+
641+
# Call the method
642+
result = base_resource._make_direct_request("/test")
643+
644+
# Should return empty dict for unexpected content type
645+
assert result == {}
646+
647+
# Should log an error about unexpected content type
648+
mock_logger.error.assert_called_once()
649+
assert "Unexpected content type" in mock_logger.error.call_args[0][0]
650+
651+
652+
@patch("fitbit_client.resources.base.sleep")
653+
@patch("fitbit_client.resources.base.BaseResource._handle_error_response")
654+
@patch("fitbit_client.resources.base.BaseResource._should_retry_request")
655+
def test_make_direct_request_rate_limit_retry(
656+
mock_should_retry, mock_handle_error, mock_sleep, base_resource, mock_logger
657+
):
658+
"""Test retry behavior for rate-limited requests."""
659+
# Configure the resource with custom retry settings
660+
base_resource.max_retries = 1
661+
base_resource.retry_after_seconds = 10
662+
base_resource.retry_backoff_factor = 1
663+
664+
# Mock the OAuth session
665+
base_resource.oauth = Mock()
666+
667+
# Create a mock response for error and success
668+
error_response = Mock()
669+
error_response.status_code = 429
670+
error_response.headers = {"Retry-After": "5"}
671+
672+
success_response = Mock()
673+
success_response.status_code = 200
674+
success_response.headers = {"content-type": "application/json"}
675+
success_response.json.return_value = {"data": "success"}
676+
677+
# Set up the mock to return error first, then success
678+
base_resource.oauth.request.side_effect = [error_response, success_response]
679+
680+
# Set up mocks for retry logic
681+
mock_handle_error.side_effect = RateLimitExceededException(
682+
message="Too many requests", status_code=429, error_type="rate_limit_exceeded"
683+
)
684+
mock_should_retry.return_value = True
685+
686+
# Call the method
687+
with patch(
688+
"fitbit_client.resources.base.BaseResource._handle_json_response"
689+
) as mock_handle_json:
690+
mock_handle_json.return_value = {"data": "success"}
691+
result = base_resource._make_direct_request("/test")
692+
693+
# Verify results
694+
assert result == {"data": "success"}
695+
assert base_resource.oauth.request.call_count == 2
696+
assert mock_sleep.call_count == 1
697+
assert mock_logger.warning.call_count == 1
698+
699+
700+
@patch("fitbit_client.resources.base.BaseResource._get_calling_method")
701+
def test_make_direct_request_exception(mock_get_calling, base_resource, mock_logger):
702+
"""Test handling of exceptions in direct request."""
703+
mock_get_calling.return_value = "test_method"
704+
705+
# Mock the OAuth session
706+
base_resource.oauth = Mock()
707+
base_resource.oauth.request.side_effect = ConnectionError("Network error")
708+
709+
# Call the method
710+
with raises(Exception) as exc_info:
711+
base_resource._make_direct_request("/test")
712+
713+
# Verify exception and logging
714+
assert "Pagination request failed" in str(exc_info.value)
715+
assert mock_logger.error.call_count == 1
716+
717+
718+
# -----------------------------------------------------------------------------
719+
# 12. API Error Status Codes
533720
# -----------------------------------------------------------------------------
534721

535722

0 commit comments

Comments
 (0)