diff --git a/.gitignore b/.gitignore index 5d37faf..7a24f86 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ coverage.xml htmlcov/ .pytest_cache/ .mypy_cache/ +*,cover # Mac .DS_Store diff --git a/TODO.md b/TODO.md index 222ccd8..0cff1f3 100644 --- a/TODO.md +++ b/TODO.md @@ -1,49 +1,31 @@ # Project TODO and Notes -## Refactoring TODOs +## TODOs: -- Typing +- PyPi deployment - - Try to get rid of `Optional[Dict[str, Any]]` args +- For all `create_...`methods, add the ID from the response to logs and maybe + something human readable, like the first n characters of the name??. Right + now: + +```log +[2025-02-05 06:09:34,828] INFO [fitbit_client.NutritionResource] create_food_log succeeded for foods/log.json (status 201) +``` - base.py: reorganize and see what we can move out. - Rename to `_base`? Files it first, makes it clearer that everything in it is private - - Move the methods for building `curl` commands into a mixin? It's a lot of - code for an isolated and tightly scoped feature. - - refactor `_make_request`. - - do we need both `data` and `json`? Also, could we simplify a lot of typing - if we separated GET, POST, and DELETE methods? Maybe even a separate, - second non-auth GET? Could use `@overload` - - we had to makee a `ParamDict` type in `nutrition.py`. Use this everywhere? - - start by looking at how many methods use which params - client.py: - Creat and Test that all methods have an alias in `Client` and that the signatures match -```python -if not food_id and not (food_name and calories): - raise ClientValidationException( - "Must provide either food_id or (food_name and calories)" - ) -``` - -- nutrition.py: -- It doesn't seem like this should be passing tests when CALORIES is not an int: +- CI: -```python - # Handle both enum and string nutritional values - for key, value in nutritional_values.items(): - if isinstance(key, NutritionalValue): - params[key.value] = float(value) - else: - params[str(key)] = float(value) -``` - -see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource) +* Read and implement: + https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/configuring-advanced-setup-for-code-scanning#configuring-advanced-setup-for-code-scanning-with-codeql - exceptions.py Consider: - Add automatic token refresh for ExpiredTokenException @@ -77,14 +59,6 @@ see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource) be reused. - We may need a public version of a generic `make_request` method. -- For all `create_...`methods, add the ID from the response to logs and maybe - something human readable, like the first n characters of the name??. Right - now: - -```log -[2025-02-05 06:09:34,828] INFO [fitbit_client.NutritionResource] create_food_log succeeded for foods/log.json (status 201) -``` - - Form to change scopes are part of OAuth flow? Maybe get rid of the cut and paste method altogether? It's less to test... @@ -101,19 +75,4 @@ see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource) one. (there might be several of these that make sense--just take an ID and then the signature of the "create" method). -- PyPI deployment - - Enum for units? (it'll be big, maybe just common ones?) - -## CI/CD/Linting - -- GitHub Actions Setup - - - Linting - - black - - isort - - mdformat - - mypy - - Test running (TBD) - - Coverage reporting (TBD) - - Automated PyPI deployment diff --git a/fitbit_client/exceptions.py b/fitbit_client/exceptions.py index 708df35..721848c 100644 --- a/fitbit_client/exceptions.py +++ b/fitbit_client/exceptions.py @@ -6,6 +6,9 @@ from typing import List from typing import Optional +# Local imports +from fitbit_client.utils.types import JSONDict + class FitbitAPIException(Exception): """Base exception for all Fitbit API errors""" @@ -15,7 +18,7 @@ def __init__( message: str, error_type: str, status_code: Optional[int] = None, - raw_response: Optional[Dict[str, Any]] = None, + raw_response: Optional[JSONDict] = None, field_name: Optional[str] = None, ): self.message = message diff --git a/fitbit_client/resources/activity.py b/fitbit_client/resources/activity.py index 9114cd9..83f9f29 100644 --- a/fitbit_client/resources/activity.py +++ b/fitbit_client/resources/activity.py @@ -18,6 +18,7 @@ from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict from fitbit_client.utils.types import JSONList +from fitbit_client.utils.types import ParamDict class ActivityResource(BaseResource): @@ -88,7 +89,7 @@ def create_activity_goals( field_name="value", ) - params = {"type": type.value, "value": value} + params: ParamDict = {"type": type.value, "value": value} result = self._make_request( f"activities/goals/{period.value}.json", params=params, @@ -152,24 +153,26 @@ def create_activity_log( - Start time should be in 24-hour format (e.g., "14:30" for 2:30 PM) """ if activity_id: - params = { + activity_params: ParamDict = { "activityId": activity_id, "startTime": start_time, "durationMillis": duration_millis, "date": date, } if distance is not None: - params["distance"] = distance + activity_params["distance"] = distance if distance_unit: - params["distanceUnit"] = distance_unit + activity_params["distanceUnit"] = distance_unit + params = activity_params elif activity_name and manual_calories: - params = { + name_params: ParamDict = { "activityName": activity_name, "manualCalories": manual_calories, "startTime": start_time, "durationMillis": duration_millis, "date": date, } + params = name_params else: raise MissingParameterException( message="Must provide either activity_id or (activity_name and manual_calories)", @@ -229,7 +232,7 @@ def get_activity_log_list( - The source field indicates whether the activity was logged manually by the user or automatically by a Fitbit device """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date if after_date: @@ -651,7 +654,9 @@ def get_activity_tcx( - Not all activities have TCX data available (e.g., manually logged activities) - To check if an activity has GPS data, look for hasGps=True in the activity log """ - params = {"includePartialTCX": include_partial_tcx} if include_partial_tcx else None + params: Optional[ParamDict] = ( + {"includePartialTCX": include_partial_tcx} if include_partial_tcx else None + ) result = self._make_request( f"activities/{log_id}.tcx", params=params, user_id=user_id, debug=debug ) diff --git a/fitbit_client/resources/base.py b/fitbit_client/resources/base.py index e423e26..4bf1be0 100644 --- a/fitbit_client/resources/base.py +++ b/fitbit_client/resources/base.py @@ -11,6 +11,7 @@ from typing import Optional from typing import Set from typing import cast +from typing import overload from urllib.parse import urlencode # Third party imports @@ -21,7 +22,12 @@ from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS from fitbit_client.exceptions import FitbitAPIException from fitbit_client.exceptions import STATUS_CODE_EXCEPTIONS +from fitbit_client.utils.curl_debug_mixin import CurlDebugMixin +from fitbit_client.utils.types import FormDataDict +from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import JSONList from fitbit_client.utils.types import JSONType +from fitbit_client.utils.types import ParamDict # Constants for important fields to track in logging IMPORTANT_RESPONSE_FIELDS: Set[str] = { @@ -41,7 +47,7 @@ } -class BaseResource: +class BaseResource(CurlDebugMixin): """Provides foundational functionality for all Fitbit API resource classes. The BaseResource class implements core functionality that all specific resource @@ -61,7 +67,7 @@ class BaseResource: - Request handling with comprehensive error management - Response parsing with type safety - Detailed logging of requests, responses, and errors - - Debug capabilities for API troubleshooting + - Debug capabilities for API troubleshooting (via CurlDebugMixin) - OAuth2 authentication management Note: @@ -134,7 +140,7 @@ def _build_url( return f"{self.API_BASE}/{api_version}/user/{user_id}/{endpoint}" return f"{self.API_BASE}/{api_version}/{endpoint}" - def _extract_important_fields(self, data: Dict[str, JSONType]) -> Dict[str, int | str]: + def _extract_important_fields(self, data: JSONDict) -> Dict[str, JSONType]: """ Extract important fields from response data for logging. @@ -150,7 +156,7 @@ def _extract_important_fields(self, data: Dict[str, JSONType]) -> Dict[str, int """ extracted = {} - def extract_recursive(d: Dict[str, Any], prefix: str = "") -> None: + def extract_recursive(d: JSONDict, prefix: str = "") -> None: for key, value in d.items(): full_key = f"{prefix}.{key}" if prefix else key @@ -189,69 +195,6 @@ def _get_calling_method(self) -> str: frame = frame.f_back return "unknown" - def _build_curl_command( - self, - url: str, - http_method: str, - data: Optional[Dict[str, Any]] = None, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> str: - """ - Build a curl command string for debugging API requests. - - Args: - url: Full API URL - http_method: HTTP method (GET, POST, DELETE) - data: Optional form data for POST requests - json: Optional JSON data for POST requests - params: Optional query parameters for GET requests - - Returns: - Complete curl command as a multi-line string - - The generated command includes: - - The HTTP method (for non-GET requests) - - Authorization header with OAuth token - - Request body (if data or json is provided) - - Query parameters (if provided) - - The command is formatted with line continuations for readability and - can be copied directly into a terminal for testing. - - Example output: - curl \\ - -X POST \\ - -H "Authorization: Bearer " \\ - -H "Content-Type: application/json" \\ - -d '{"name": "value"}' \\ - 'https://api.fitbit.com/1/user/-/foods/log.json' - """ - # Start with base command - cmd_parts = ["curl -v"] - - # Add method - if http_method != "GET": - cmd_parts.append(f"-X {http_method}") - - # Add auth header - cmd_parts.append(f'-H "Authorization: Bearer {self.oauth.token["access_token"]}"') - - # Add data if present - if json: - cmd_parts.append(f"-d '{dumps(json)}'") - cmd_parts.append('-H "Content-Type: application/json"') - elif data: - cmd_parts.append(f"-d '{urlencode(data)}'") - cmd_parts.append('-H "Content-Type: application/x-www-form-urlencoded"') - - # Add URL with parameters if present - if params: - url = f"{url}?{urlencode(params)}" - cmd_parts.append(f"'{url}'") - - return " \\\n ".join(cmd_parts) - def _log_response( self, calling_method: str, endpoint: str, response: Response, content: Optional[Dict] = None ) -> None: @@ -391,10 +334,10 @@ def _handle_error_response(self, response: Response) -> None: def _make_request( self, endpoint: str, - data: Optional[Dict[str, Any]] = None, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - headers: Dict[str, Any] = {}, + data: Optional[FormDataDict] = None, + json: Optional[JSONDict] = None, + params: Optional[ParamDict] = None, + headers: Dict[str, str] = {}, user_id: str = "-", requires_user_id: bool = True, http_method: str = "GET", @@ -421,7 +364,7 @@ def _make_request( Returns: JSONType: The API response in one of these formats: - - Dict[str, Any]: For most JSON object responses + - JSONDict: For most JSON object responses - List[Any]: For endpoints that return JSON arrays - str: For XML/TCX responses - None: For successful DELETE operations or debug mode diff --git a/fitbit_client/resources/body.py b/fitbit_client/resources/body.py index 42cc63b..fceae50 100644 --- a/fitbit_client/resources/body.py +++ b/fitbit_client/resources/body.py @@ -9,6 +9,7 @@ from fitbit_client.resources.constants import BodyGoalType from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class BodyResource(BaseResource): @@ -101,7 +102,7 @@ def create_bodyfat_log( The 'source' field will be set to "API" for entries created through this endpoint. Multiple entries can be logged for the same day with different timestamps. """ - params = {"fat": fat, "date": date} + params: ParamDict = {"fat": fat, "date": date} if time: params["time"] = time result = self._make_request( @@ -151,7 +152,7 @@ def create_weight_goal( - If target > start: "GAIN" - If target = start: "MAINTAIN" """ - params = {"startDate": start_date, "startWeight": start_weight} + params: ParamDict = {"startDate": start_date, "startWeight": start_weight} if weight is not None: params["weight"] = weight result = self._make_request( @@ -208,7 +209,7 @@ def create_weight_log( The 'source' field will be set to "API" for entries created through this endpoint. Multiple weight entries can be logged for the same day with different timestamps. """ - params = {"weight": weight, "date": date} + params: ParamDict = {"weight": weight, "date": date} if time: params["time"] = time result = self._make_request( diff --git a/fitbit_client/resources/electrocardiogram.py b/fitbit_client/resources/electrocardiogram.py index dc058d3..df4de3f 100644 --- a/fitbit_client/resources/electrocardiogram.py +++ b/fitbit_client/resources/electrocardiogram.py @@ -12,6 +12,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class ElectrocardiogramResource(BaseResource): @@ -56,7 +57,7 @@ def get_ecg_log_list( offset: int = 0, user_id: str = "-", debug: bool = False, - ) -> Dict[str, Any]: + ) -> JSONDict: """Returns a list of user's ECG log entries before or after a given day. API Reference: https://dev.fitbit.com/build/reference/web-api/electrocardiogram/get-ecg-log-list/ @@ -92,7 +93,7 @@ def get_ecg_log_list( - resultClassification indicates the assessment outcome (normal, afib, inconclusive) - For research purposes only, not for clinical or diagnostic use """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date diff --git a/fitbit_client/resources/heartrate_timeseries.py b/fitbit_client/resources/heartrate_timeseries.py index aa7d8e7..2d89c48 100644 --- a/fitbit_client/resources/heartrate_timeseries.py +++ b/fitbit_client/resources/heartrate_timeseries.py @@ -12,6 +12,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.date_validation import validate_date_range_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class HeartrateTimeSeriesResource(BaseResource): @@ -105,7 +106,7 @@ def get_heartrate_timeseries_by_date( message="Only 'UTC' timezone is supported", field_name="timezone" ) - params = {"timezone": timezone} if timezone else None + params: Optional[ParamDict] = {"timezone": timezone} if timezone else None result = self._make_request( f"activities/heart/date/{date}/{period.value}.json", params=params, @@ -169,7 +170,7 @@ def get_heartrate_timeseries_by_date_range( message="Only 'UTC' timezone is supported", field_name="timezone" ) - params = {"timezone": timezone} if timezone else None + params: Optional[ParamDict] = {"timezone": timezone} if timezone else None result = self._make_request( f"activities/heart/date/{start_date}/{end_date}.json", params=params, diff --git a/fitbit_client/resources/irregular_rhythm_notifications.py b/fitbit_client/resources/irregular_rhythm_notifications.py index 5b072b8..db2ef9c 100644 --- a/fitbit_client/resources/irregular_rhythm_notifications.py +++ b/fitbit_client/resources/irregular_rhythm_notifications.py @@ -10,6 +10,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class IrregularRhythmNotificationsResource(BaseResource): @@ -93,7 +94,7 @@ def get_irn_alerts_list( - The alertTime is when the notification was generated, while detectedTime is when the irregular rhythm was detected (usually during sleep) """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date diff --git a/fitbit_client/resources/nutrition.py b/fitbit_client/resources/nutrition.py index 695c432..ed70e09 100644 --- a/fitbit_client/resources/nutrition.py +++ b/fitbit_client/resources/nutrition.py @@ -384,10 +384,15 @@ def create_meal( Food IDs can be obtained from food search results or the user's custom foods. """ # snakes to camels - foods = [{to_camel_case(k): v for k, v in d.items()} for d in foods] - data = {"name": name, "description": description, "mealFoods": foods} + foods_list = [{to_camel_case(k): v for k, v in d.items()} for d in foods] + # Use cast to handle the complex structure + data_dict = {"name": name, "description": description, "mealFoods": foods_list} result = self._make_request( - "meals.json", json=data, user_id=user_id, http_method="POST", debug=debug + "meals.json", + json=cast(JSONDict, data_dict), + user_id=user_id, + http_method="POST", + debug=debug, ) return cast(JSONDict, result) @@ -1179,10 +1184,15 @@ def update_meal( Meal IDs can be obtained from the get_meals method. Updating a meal does not affect any food logs that were previously created using this meal. """ - foods = [{to_camel_case(k): v for k, v in d.items()} for d in foods] - data = {"name": name, "description": description, "mealFoods": foods} + foods_list = [{to_camel_case(k): v for k, v in d.items()} for d in foods] + # Use cast to handle the complex structure + data_dict = {"name": name, "description": description, "mealFoods": foods_list} result = self._make_request( - f"meals/{meal_id}.json", json=data, user_id=user_id, http_method="POST", debug=debug + f"meals/{meal_id}.json", + json=cast(JSONDict, data_dict), + user_id=user_id, + http_method="POST", + debug=debug, ) return cast(JSONDict, result) diff --git a/fitbit_client/resources/sleep.py b/fitbit_client/resources/sleep.py index 3d4457c..02fe6f2 100644 --- a/fitbit_client/resources/sleep.py +++ b/fitbit_client/resources/sleep.py @@ -14,6 +14,7 @@ from fitbit_client.utils.date_validation import validate_date_range_params from fitbit_client.utils.pagination_validation import validate_pagination_params from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class SleepResource(BaseResource): @@ -124,7 +125,7 @@ def create_sleep_log( message="duration_millis must be positive", field_name="duration_millis" ) - params = {"startTime": start_time, "duration": duration_millis, "date": date} + params: ParamDict = {"startTime": start_time, "duration": duration_millis, "date": date} result = self._make_request( "sleep.json", params=params, @@ -355,7 +356,7 @@ def get_sleep_log_list( This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. """ - params = {"sort": sort.value, "limit": limit, "offset": offset} + params: ParamDict = {"sort": sort.value, "limit": limit, "offset": offset} if before_date: params["beforeDate"] = before_date if after_date: diff --git a/fitbit_client/resources/user.py b/fitbit_client/resources/user.py index 2a6efaf..cb4b13f 100644 --- a/fitbit_client/resources/user.py +++ b/fitbit_client/resources/user.py @@ -11,6 +11,7 @@ from fitbit_client.resources.constants import StartDayOfWeek from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict class UserResource(BaseResource): @@ -173,7 +174,8 @@ def update_profile( "strideLengthRunning": stride_length_running, } - params = {key: value for key, value in updates.items() if value is not None} + # Create a ParamDict from the non-None values + params: ParamDict = {key: value for key, value in updates.items() if value is not None} result = self._make_request( "profile.json", params=params, user_id=user_id, http_method="POST", debug=debug ) diff --git a/fitbit_client/utils/curl_debug_mixin.py b/fitbit_client/utils/curl_debug_mixin.py new file mode 100644 index 0000000..43855e5 --- /dev/null +++ b/fitbit_client/utils/curl_debug_mixin.py @@ -0,0 +1,94 @@ +# fitbit_client/utils/curl_debug_mixin.py + +""" +Mixin for generating curl commands for API debugging. +""" + +# Standard library imports +from json import dumps +from typing import Optional +from urllib.parse import urlencode + +# Local imports +from fitbit_client.utils.types import FormDataDict +from fitbit_client.utils.types import JSONDict +from fitbit_client.utils.types import ParamDict + + +class CurlDebugMixin: + """Mixin that provides curl command generation for debugging API requests. + + This mixin can be used with API client classes to add the ability to generate + equivalent curl commands for debugging purposes. It helps with: + - Testing API endpoints directly + - Debugging authentication/scope issues + - Verifying request structure + - Troubleshooting permission problems + """ + + def _build_curl_command( + self, + url: str, + http_method: str, + data: Optional[FormDataDict] = None, + json: Optional[JSONDict] = None, + params: Optional[ParamDict] = None, + ) -> str: + """ + Build a curl command string for debugging API requests. + + Args: + url: Full API URL + http_method: HTTP method (GET, POST, DELETE) + data: Optional form data for POST requests + json: Optional JSON data for POST requests + params: Optional query parameters for GET requests + + Returns: + Complete curl command as a multi-line string + + The generated command includes: + - The HTTP method (for non-GET requests) + - Authorization header with OAuth token + - Request body (if data or json is provided) + - Query parameters (if provided) + + The command is formatted with line continuations for readability and + can be copied directly into a terminal for testing. + + Example output: + curl \\ + -X POST \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"name": "value"}' \\ + 'https://api.fitbit.com/1/user/-/foods/log.json' + """ + # Start with base command + cmd_parts = ["curl -v"] + + # Add method + if http_method != "GET": + cmd_parts.append(f"-X {http_method}") + + # Add auth header + if hasattr(self, "oauth") and hasattr(self.oauth, "token"): + cmd_parts.append(f'-H "Authorization: Bearer {self.oauth.token["access_token"]}"') + else: + # Fallback for tests or when not properly initialized + cmd_parts.append('-H "Authorization: Bearer TOKEN"') + + # Add data if present + if json: + cmd_parts.append(f"-d '{dumps(json)}'") + cmd_parts.append('-H "Content-Type: application/json"') + elif data: + cmd_parts.append(f"-d '{urlencode(data)}'") + cmd_parts.append('-H "Content-Type: application/x-www-form-urlencoded"') + + # Add URL with parameters if present + if params: + url = f"{url}?{urlencode(params)}" + cmd_parts.append(f"'{url}'") + + return " \\\n ".join(cmd_parts) diff --git a/fitbit_client/utils/types.py b/fitbit_client/utils/types.py index 19efd44..bbce1f7 100644 --- a/fitbit_client/utils/types.py +++ b/fitbit_client/utils/types.py @@ -18,6 +18,10 @@ ParamValue = Union[str, int, float, bool, None] ParamDict = Dict[str, ParamValue] +# Type for form data (typically used with data parameter in requests) +FormDataValue = Union[str, int, float, bool, None] +FormDataDict = Dict[str, FormDataValue] + # Type definitions for token structure class TokenDict(TypedDict, total=False): diff --git a/tests/resources/test_base.py b/tests/resources/test_base.py index 88d517f..37eeb1a 100644 --- a/tests/resources/test_base.py +++ b/tests/resources/test_base.py @@ -247,38 +247,10 @@ def test_log_response_for_error_without_content(base_resource, mock_logger): # ----------------------------------------------------------------------------- -# 6. Debug Curl Command Generation +# 6. Debug Mode Testing # ----------------------------------------------------------------------------- - -def test_build_curl_command_with_json_data(base_resource): - """Test generating curl command with JSON data""" - # This tests lines 217-218 in base.py - base_resource.oauth.token = {"access_token": "test_token"} - - json_data = {"name": "Test Activity", "type": "run", "duration": 3600} - result = base_resource._build_curl_command( - url="https://api.fitbit.com/1/user/-/activities.json", http_method="POST", json=json_data - ) - - # Assert command contains JSON data and correct header - assert '-d \'{"name": "Test Activity", "type": "run", "duration": 3600}\'' in result - assert '-H "Content-Type: application/json"' in result - - -def test_build_curl_command_with_form_data(base_resource): - """Test generating curl command with form data""" - # This tests lines 220-221 in base.py - base_resource.oauth.token = {"access_token": "test_token"} - - form_data = {"date": "2023-01-01", "foodId": "12345", "amount": "1", "mealTypeId": "1"} - result = base_resource._build_curl_command( - url="https://api.fitbit.com/1/user/-/foods/log.json", http_method="POST", data=form_data - ) - - # Assert command contains form data and correct header - assert "-d 'date=2023-01-01&foodId=12345&amount=1&mealTypeId=1'" in result - assert '-H "Content-Type: application/x-www-form-urlencoded"' in result +# Debug mode tests are now in tests/utils/test_curl_debug_mixin.py # ----------------------------------------------------------------------------- diff --git a/tests/utils/test_curl_debug_mixin.py b/tests/utils/test_curl_debug_mixin.py new file mode 100644 index 0000000..d93d5d3 --- /dev/null +++ b/tests/utils/test_curl_debug_mixin.py @@ -0,0 +1,134 @@ +# tests/utils/test_curl_debug_mixin.py + +"""Tests for CurlDebugMixin""" + +# Standard library imports +from unittest.mock import Mock +from unittest.mock import patch + +# Third party imports +from pytest import fixture + +# Local imports +from fitbit_client.utils.curl_debug_mixin import CurlDebugMixin + + +# Create a test class that uses the mixin for debug testing +class TestResource(CurlDebugMixin): + """Test class that uses CurlDebugMixin""" + + def __init__(self): + self.oauth = Mock() + self.oauth.token = {"access_token": "test_token"} + + def make_debug_request(self, debug=False): + """Test method that simulates _make_request with debug mode""" + url = "https://api.fitbit.com/1/user/-/test/endpoint" + + if debug: + curl_command = self._build_curl_command( + url=url, http_method="GET", params={"param1": "value1"} + ) + print(f"\n# Debug curl command:") + print(curl_command) + print() + return None + + return {"success": True} + + +@fixture +def curl_debug_mixin(): + """Fixture for CurlDebugMixin with mocked OAuth session""" + mixin = CurlDebugMixin() + mixin.oauth = Mock() + mixin.oauth.token = {"access_token": "test_token"} + return mixin + + +def test_build_curl_command_with_json_data(curl_debug_mixin): + """Test generating curl command with JSON data""" + json_data = {"name": "Test Activity", "type": "run", "duration": 3600} + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/activities.json", http_method="POST", json=json_data + ) + + # Assert command contains JSON data and correct header + assert '-d \'{"name": "Test Activity", "type": "run", "duration": 3600}\'' in result + assert '-H "Content-Type: application/json"' in result + assert "-X POST" in result + assert "curl -v" in result + assert '-H "Authorization: Bearer test_token"' in result + assert "'https://api.fitbit.com/1/user/-/activities.json'" in result + + +def test_build_curl_command_with_form_data(curl_debug_mixin): + """Test generating curl command with form data""" + form_data = {"date": "2023-01-01", "foodId": "12345", "amount": "1", "mealTypeId": "1"} + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/foods/log.json", http_method="POST", data=form_data + ) + + # Assert command contains form data and correct header + assert "-d 'date=2023-01-01&foodId=12345&amount=1&mealTypeId=1'" in result + assert '-H "Content-Type: application/x-www-form-urlencoded"' in result + assert "-X POST" in result + + +def test_build_curl_command_with_get_params(curl_debug_mixin): + """Test generating curl command with GET parameters""" + params = {"date": "2023-01-01", "offset": "0", "limit": "10"} + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/activities/list.json", http_method="GET", params=params + ) + + # Assert command doesn't have -X GET but has parameters in URL + assert "-X GET" not in result + assert "?date=2023-01-01&offset=0&limit=10" in result + + +def test_build_curl_command_with_delete(curl_debug_mixin): + """Test generating curl command for DELETE request""" + result = curl_debug_mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/foods/log/123456.json", http_method="DELETE" + ) + + # Assert command has DELETE method + assert "-X DELETE" in result + assert "curl -v" in result + assert '-H "Authorization: Bearer test_token"' in result + + +def test_debug_mode_integration(capsys): + """Test debug mode integration with a resource class""" + # Create test resource + resource = TestResource() + + # Call make_debug_request with debug=True + result = resource.make_debug_request(debug=True) + + # Capture stdout + captured = capsys.readouterr() + + # Verify results + assert result is None + assert "curl" in captured.out + assert "test/endpoint" in captured.out + assert "param1=value1" in captured.out + assert "test_token" in captured.out + + +def test_build_curl_command_without_oauth(): + """Test curl command generation when oauth is not available""" + # Create a bare mixin instance without oauth + mixin = CurlDebugMixin() + + # Generate a curl command + result = mixin._build_curl_command( + url="https://api.fitbit.com/1/user/-/test.json", http_method="GET" + ) + + # Verify that the fallback auth header is used + assert '-H "Authorization: Bearer TOKEN"' in result + assert "curl -v" in result + assert "'https://api.fitbit.com/1/user/-/test.json'" in result diff --git a/tests/utils/test_date_validation.py b/tests/utils/test_date_validation.py index 232b548..18c036b 100644 --- a/tests/utils/test_date_validation.py +++ b/tests/utils/test_date_validation.py @@ -15,6 +15,7 @@ from fitbit_client.utils.date_validation import validate_date_param from fitbit_client.utils.date_validation import validate_date_range from fitbit_client.utils.date_validation import validate_date_range_params +from fitbit_client.utils.types import JSONDict class TestDateValidation: @@ -106,7 +107,7 @@ def test_validate_date_range_params_decorator(self): """Test the validate_date_range_params decorator""" @validate_date_range_params(max_days=30) - def dummy_func(start_date: str, end_date: str) -> Dict[str, Any]: + def dummy_func(start_date: str, end_date: str) -> JSONDict: return {"start": start_date, "end": end_date} result = dummy_func("2024-02-01", "2024-02-13") @@ -148,7 +149,7 @@ def test_validate_date_range_params_decorator_with_optional(self): @validate_date_range_params(max_days=30) def dummy_func( start_date: Optional[str] = None, end_date: Optional[str] = None - ) -> Dict[str, Any]: + ) -> JSONDict: return {"start": start_date, "end": end_date} # Should work with no parameters @@ -169,7 +170,7 @@ def test_validate_date_range_params_custom_field_names(self): """Test that validate_date_range_params respects custom field names""" @validate_date_range_params(start_field="begin_date", end_field="finish_date", max_days=30) - def dummy_func(begin_date: str, finish_date: str) -> Dict[str, Any]: + def dummy_func(begin_date: str, finish_date: str) -> JSONDict: return {"start": begin_date, "end": finish_date} # Test valid dates