diff --git a/oura_api_client/api/vo2_max.py b/oura_api_client/api/vo2_max.py index 9a3df3d..3e054d1 100644 --- a/oura_api_client/api/vo2_max.py +++ b/oura_api_client/api/vo2_max.py @@ -31,7 +31,7 @@ def get_vo2_max_documents( "next_token": next_token if next_token else None, } params = {k: v for k, v in params.items() if v is not None} - response = self.client._make_request("/v2/usercollection/vo2_max", params=params) + response = self.client._make_request("/v2/usercollection/vO2_max", params=params) return Vo2MaxResponse(**response) def get_vo2_max_document(self, document_id: str) -> Vo2MaxModel: @@ -44,5 +44,5 @@ def get_vo2_max_document(self, document_id: str) -> Vo2MaxModel: Returns: Vo2MaxModel: Response containing VO2 max data. """ - response = self.client._make_request(f"/v2/usercollection/vo2_max/{document_id}") + response = self.client._make_request(f"/v2/usercollection/vO2_max/{document_id}") return Vo2MaxModel(**response) diff --git a/oura_api_client/api/webhook.py b/oura_api_client/api/webhook.py index 807ec97..3fa1c13 100644 --- a/oura_api_client/api/webhook.py +++ b/oura_api_client/api/webhook.py @@ -1,111 +1,153 @@ -from typing import Optional, List # Added List +from typing import Optional, List from oura_api_client.api.base import BaseRouter from oura_api_client.models.webhook import ( WebhookSubscriptionModel, - WebhookListResponse, + # WebhookListResponse, # Removed as API returns List directly WebhookSubscriptionCreateRequest, - WebhookSubscriptionUpdateRequest + WebhookSubscriptionUpdateRequest, + WebhookOperation, + ExtApiV2DataType ) class Webhook(BaseRouter): - def list_webhook_subscriptions(self) -> WebhookListResponse: + def _get_webhook_headers(self) -> dict: + """Helper to construct headers for webhook requests.""" + if not hasattr(self.client, 'client_id') or not hasattr(self.client, 'client_secret'): + # This is a fallback or error case. Ideally, the OuraClient should be initialized + # with client_id and client_secret if webhook management is to be used. + # For now, we'll raise an error or return base headers, + # but a production client would need proper handling. + raise ValueError("client_id and client_secret must be set in OuraClient for webhook operations.") + + # Merge with existing base headers (like Content-Type if needed, or other default headers) + # For this specific API, it seems only x-client-id and x-client-secret are custom. + # The base _make_request should handle common headers like Authorization if that was the case, + # but webhook auth is different. + headers = { + "x-client-id": self.client.client_id, + "x-client-secret": self.client.client_secret, + } + # Add other necessary headers like Content-Type for POST/PUT if not handled by _make_request + # when json_data is present. Typically, requests library does this automatically. + return headers + + def list_webhook_subscriptions(self) -> List[WebhookSubscriptionModel]: """ List all existing webhook subscriptions. - Note: Oura API v2 for webhooks does not use pagination (next_token). - - Returns: - WebhookListResponse: Response containing a list of webhook subscriptions. + API Path: GET /v2/webhook/subscription """ - response = self.client._make_request("/v2/usercollection/webhook") - return WebhookListResponse(**response) + headers = self._get_webhook_headers() + response_data = self.client._make_request( + "/v2/webhook/subscription", + headers=headers + ) + # API returns a list of subscriptions directly + return [WebhookSubscriptionModel(**item) for item in response_data] def create_webhook_subscription( self, callback_url: str, - event_types: List[str], - verification_token: Optional[str] = None, + event_type: WebhookOperation, + data_type: ExtApiV2DataType, + verification_token: str, # Made non-optional as per updated model reflecting spec ) -> WebhookSubscriptionModel: """ Create a new webhook subscription. - - Args: - callback_url: The URL where webhook notifications will be sent. - event_types: A list of event types to subscribe to. - verification_token: An optional token to verify the callback URL. - - Returns: - WebhookSubscriptionModel: The created webhook subscription details. + API Path: POST /v2/webhook/subscription """ + headers = self._get_webhook_headers() + # Ensure Content-Type is set for POST with JSON body, if not handled by _make_request + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/json' + request_body = WebhookSubscriptionCreateRequest( callback_url=callback_url, - event_types=event_types, + event_type=event_type, + data_type=data_type, verification_token=verification_token, ) - # Pydantic's model_dump(by_alias=True) ensures correct field names are used in the JSON - response = self.client._make_request( - "/v2/usercollection/webhook", + response_data = self.client._make_request( + "/v2/webhook/subscription", method="POST", - json_data=request_body.model_dump(by_alias=True, exclude_none=True) + json_data=request_body.model_dump(by_alias=True), # exclude_none=True is default for model_dump + headers=headers ) - return WebhookSubscriptionModel(**response) + return WebhookSubscriptionModel(**response_data) def get_webhook_subscription(self, subscription_id: str) -> WebhookSubscriptionModel: """ Get details for a specific webhook subscription. - - Args: - subscription_id: The ID of the webhook subscription. - - Returns: - WebhookSubscriptionModel: Details of the webhook subscription. + API Path: GET /v2/webhook/subscription/{subscription_id} """ - response = self.client._make_request(f"/v2/usercollection/webhook/{subscription_id}") - return WebhookSubscriptionModel(**response) + headers = self._get_webhook_headers() + response_data = self.client._make_request( + f"/v2/webhook/subscription/{subscription_id}", + headers=headers + ) + return WebhookSubscriptionModel(**response_data) def update_webhook_subscription( self, subscription_id: str, + verification_token: str, # Required callback_url: Optional[str] = None, - event_types: Optional[List[str]] = None, - verification_token: Optional[str] = None, + event_type: Optional[WebhookOperation] = None, + data_type: Optional[ExtApiV2DataType] = None, ) -> WebhookSubscriptionModel: """ Update an existing webhook subscription. - - Args: - subscription_id: The ID of the webhook subscription to update. - callback_url: The new callback URL. - event_types: The new list of event types. - verification_token: The new verification token. - - Returns: - WebhookSubscriptionModel: The updated webhook subscription details. + API Path: PUT /v2/webhook/subscription/{subscription_id} """ + headers = self._get_webhook_headers() + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/json' + request_body = WebhookSubscriptionUpdateRequest( + verification_token=verification_token, # Now required callback_url=callback_url, - event_types=event_types, - verification_token=verification_token, + event_type=event_type, + data_type=data_type, ) - # Pydantic's model_dump(by_alias=True, exclude_none=True) ensures correct field names and omits unset optionals - response = self.client._make_request( - f"/v2/usercollection/webhook/{subscription_id}", + response_data = self.client._make_request( + f"/v2/webhook/subscription/{subscription_id}", method="PUT", - json_data=request_body.model_dump(by_alias=True, exclude_none=True) + json_data=request_body.model_dump(by_alias=True, exclude_none=True), + headers=headers ) - return WebhookSubscriptionModel(**response) + return WebhookSubscriptionModel(**response_data) def delete_webhook_subscription(self, subscription_id: str) -> None: """ Delete a webhook subscription. - - Args: - subscription_id: The ID of the webhook subscription to delete. - - Returns: - None. The API returns a 204 No Content on success. + API Path: DELETE /v2/webhook/subscription/{subscription_id} """ + headers = self._get_webhook_headers() self.client._make_request( - f"/v2/usercollection/webhook/{subscription_id}", - method="DELETE" + f"/v2/webhook/subscription/{subscription_id}", + method="DELETE", + headers=headers ) return None + + def renew_webhook_subscription(self, subscription_id: str) -> WebhookSubscriptionModel: + """ + Renew an existing webhook subscription. + API Path: PUT /v2/webhook/subscription/renew/{subscription_id} + """ + headers = self._get_webhook_headers() + # Some APIs might require Content-Type even for PUTs without a body, + # but typically not. If it's needed, _make_request or requests lib handles it, + # or it can be added here. + # if 'Content-Type' not in headers: + # headers['Content-Type'] = 'application/json' + + response_data = self.client._make_request( + f"/v2/webhook/subscription/renew/{subscription_id}", + method="PUT", + headers=headers + # No json_data for this specific renew endpoint as per typical renew patterns, + # unless the spec implies a body, which it does not for this path. + ) + return WebhookSubscriptionModel(**response_data) + +[end of oura_api_client/api/webhook.py] diff --git a/oura_api_client/models/webhook.py b/oura_api_client/models/webhook.py index 6d834cf..f13e80c 100644 --- a/oura_api_client/models/webhook.py +++ b/oura_api_client/models/webhook.py @@ -1,41 +1,60 @@ from pydantic import BaseModel, Field from typing import List, Optional from datetime import datetime +from enum import Enum -class WebhookEventModel(BaseModel): # New model for events within a subscription - event_type: str = Field(alias="event_type") # e.g., "oura_webhook_test.test_event" or specific data types - # Additional fields for an event could be 'timestamp', 'user_id', 'data_id' if provided by API - # For now, keeping it simple as per typical webhook event structures. +class WebhookOperation(str, Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" -class WebhookSubscriptionModel(BaseModel): - id: str # Webhook subscription ID - created_at: datetime = Field(alias="created_at") - updated_at: datetime = Field(None, alias="updated_at") # Optional, as it might not be updated - verification_token: Optional[str] = Field(None, alias="verification_token") # Only present on creation/update - callback_url: str = Field(alias="callback_url") - subscribed_events: Optional[List[WebhookEventModel]] = Field(None, alias="subscribed_events") - # The OpenAPI spec indicates 'event_types' as a list of strings for creation, - # but a successful response for GET might detail them as objects or just list strings. - # Using WebhookEventModel for subscribed_events if the API returns more detail than just strings. - # If it's just strings, this would be: - # subscribed_events: Optional[List[str]] = Field(None, alias="subscribed_events") - # For now, assuming a list of simple event type strings as per common webhook patterns for listing subscriptions. - # Re-adjusting based on typical GET response: usually lists event type strings. - event_types: Optional[List[str]] = Field(None, alias="event_types") +class ExtApiV2DataType(str, Enum): + TAG = "tag" + ENHANCED_TAG = "enhanced_tag" + WORKOUT = "workout" + SESSION = "session" + SLEEP = "sleep" + DAILY_SLEEP = "daily_sleep" + DAILY_READINESS = "daily_readiness" + DAILY_ACTIVITY = "daily_activity" + DAILY_SPO2 = "daily_spo2" + SLEEP_TIME = "sleep_time" + REST_MODE_PERIOD = "rest_mode_period" + RING_CONFIGURATION = "ring_configuration" + DAILY_STRESS = "daily_stress" + DAILY_CARDIOVASCULAR_AGE = "daily_cardiovascular_age" + DAILY_RESILIENCE = "daily_resilience" + VO2_MAX = "vo2_max" + # Note: The OpenAPI spec does not list "heartrate" under ExtApiV2DataType for webhooks, + # but it is a general data type. If webhooks support it, it should be added. + # For now, sticking to the types explicitly listed under Webhook components. +class WebhookSubscriptionModel(BaseModel): + id: str = Field(..., description="Webhook subscription ID") + callback_url: str = Field(..., alias="callback_url") + event_type: WebhookOperation = Field(..., alias="event_type") + data_type: ExtApiV2DataType = Field(..., alias="data_type") + # Assuming created_at and updated_at are not part of the GET response based on spec example for WebhookSubscriptionModel + # If they are, they should be added back. The spec for WebhookSubscriptionModel shows: + # id, callback_url, event_type, data_type, expiration_time + expiration_time: datetime = Field(..., alias="expiration_time") + # verification_token is not part of the response for GET /subscription or GET /subscription/{id} class WebhookSubscriptionCreateRequest(BaseModel): # For POST request body - callback_url: str = Field(alias="callback_url") - verification_token: Optional[str] = Field(None, alias="verification_token") - event_types: List[str] = Field(alias="event_types") + callback_url: str = Field(..., alias="callback_url") + verification_token: str = Field(..., alias="verification_token") # Made required as per spec + event_type: WebhookOperation = Field(..., alias="event_type") + data_type: ExtApiV2DataType = Field(..., alias="data_type") class WebhookSubscriptionUpdateRequest(BaseModel): # For PUT request body + verification_token: str = Field(..., alias="verification_token") # Required callback_url: Optional[str] = Field(None, alias="callback_url") - verification_token: Optional[str] = Field(None, alias="verification_token") - event_types: Optional[List[str]] = Field(None, alias="event_types") + event_type: Optional[WebhookOperation] = Field(None, alias="event_type") + data_type: Optional[ExtApiV2DataType] = Field(None, alias="data_type") + +# No longer using WebhookListResponse as the API returns a direct list. +# class WebhookListResponse(BaseModel): +# data: List[WebhookSubscriptionModel] -# Response for listing multiple webhooks -class WebhookListResponse(BaseModel): - data: List[WebhookSubscriptionModel] - # Oura's list webhooks endpoint does not use next_token based on v2 spec - # next_token: Optional[str] = None +# Model for the renew response (same as WebhookSubscriptionModel) +WebhookRenewResponse = WebhookSubscriptionModel diff --git a/parse_openapi.py b/parse_openapi.py index 831b361..69c074e 100644 --- a/parse_openapi.py +++ b/parse_openapi.py @@ -1,4 +1,5 @@ import json +import logging def parse_openapi_spec(spec_content): """ diff --git a/tests/test_client.py b/tests/test_client.py index 56748f6..49f8023 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -17,8 +17,13 @@ from oura_api_client.models.daily_spo2 import DailySpO2Response, DailySpO2Model, DailySpO2AggregatedValuesModel from oura_api_client.models.sleep_time import SleepTimeResponse, SleepTimeModel, SleepTimeWindow, SleepTimeRecommendation, SleepTimeStatus from oura_api_client.models.rest_mode_period import RestModePeriodResponse, RestModePeriodModel # Added RestModePeriod models +from oura_api_client.models.daily_stress import DailyStressResponse, DailyStressModel, DailyStressSummary # Added DailyStress models +from oura_api_client.models.daily_resilience import DailyResilienceResponse, DailyResilienceModel, ResilienceContributors, LongTermResilienceLevel # Added DailyResilience models +from oura_api_client.models.daily_cardiovascular_age import DailyCardiovascularAgeResponse, DailyCardiovascularAgeModel # Added DailyCardiovascularAge models +from oura_api_client.models.vo2_max import Vo2MaxResponse, Vo2MaxModel # Added Vo2Max models import requests from requests.exceptions import RequestException +import httpretty # Import httpretty for more robust mocking if needed, or stick to unittest.mock class TestOuraClient(unittest.TestCase): @@ -45,6 +50,10 @@ def test_initialization(self): self.assertIsNotNone(self.client.daily_spo2) self.assertIsNotNone(self.client.sleep_time) self.assertIsNotNone(self.client.rest_mode_period) # Added rest_mode_period + self.assertIsNotNone(self.client.daily_stress) # Added daily_stress + self.assertIsNotNone(self.client.daily_resilience) # Added daily_resilience + self.assertIsNotNone(self.client.daily_cardiovascular_age) # Added daily_cardiovascular_age + self.assertIsNotNone(self.client.vo2_max) # Added vo2_max @patch("requests.get") def test_get_heart_rate(self, mock_get): @@ -1633,3 +1642,710 @@ def test_get_rest_mode_period_document_error(self, mock_get): document_id = "test_rmp_single_error" with self.assertRaises(RequestException): self.client.rest_mode_period.get_rest_mode_period_document(document_id=document_id) + + +class TestDailyStress(unittest.TestCase): + def setUp(self): + self.client = OuraClient(access_token="test_token") + self.base_url = self.client.BASE_URL + + @patch("requests.get") + def test_get_daily_stress_documents_no_params(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + response = self.client.daily_stress.get_daily_stress_documents() + self.assertIsInstance(response, DailyStressResponse) + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_stress", + headers=self.client.headers, + params={}, + ) + + @patch("requests.get") + def test_get_daily_stress_documents_start_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_stress.get_daily_stress_documents(start_date="2024-01-01") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_stress", + headers=self.client.headers, + params={"start_date": "2024-01-01"}, + ) + + @patch("requests.get") + def test_get_daily_stress_documents_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_stress.get_daily_stress_documents(end_date="2024-01-31") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_stress", + headers=self.client.headers, + params={"end_date": "2024-01-31"}, + ) + + @patch("requests.get") + def test_get_daily_stress_documents_start_and_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_stress.get_daily_stress_documents(start_date="2024-01-01", end_date="2024-01-31") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_stress", + headers=self.client.headers, + params={"start_date": "2024-01-01", "end_date": "2024-01-31"}, + ) + + @patch("requests.get") + def test_get_daily_stress_documents_next_token(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_stress.get_daily_stress_documents(next_token="some_token") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_stress", + headers=self.client.headers, + params={"next_token": "some_token"}, + ) + + @patch("requests.get") + def test_get_daily_stress_documents_success(self, mock_get): + mock_data = [ + { + "id": "stress_doc_1", + "day": "2024-03-15", + "stress_high": 1200, + "recovery_high": 3600, + "day_summary": "restored", + } + ] + mock_response_json = {"data": mock_data, "next_token": "stress_next_token"} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.daily_stress.get_daily_stress_documents(start_date="2024-03-15") + self.assertIsInstance(response, DailyStressResponse) + self.assertEqual(len(response.data), 1) + self.assertIsInstance(response.data[0], DailyStressModel) + self.assertEqual(response.data[0].id, "stress_doc_1") + self.assertEqual(response.data[0].stress_high, 1200) + self.assertEqual(response.data[0].day_summary, DailyStressSummary.RESTORED) # Assuming DailyStressSummary is an Enum + self.assertEqual(response.next_token, "stress_next_token") + mock_get.assert_called_with( + f"{self.base_url}/v2/usercollection/daily_stress", + headers=self.client.headers, + params={"start_date": "2024-03-15"}, + ) + + @patch("requests.get") + def test_get_daily_stress_documents_api_error_400(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("400 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_stress.get_daily_stress_documents() + + @patch("requests.get") + def test_get_daily_stress_documents_api_error_401(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("401 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_stress.get_daily_stress_documents() + + @patch("requests.get") + def test_get_daily_stress_documents_api_error_429(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("429 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_stress.get_daily_stress_documents() + + @patch("requests.get") + def test_get_daily_stress_document_success(self, mock_get): + document_id = "sample_stress_id" + mock_response_json = { + "id": document_id, + "day": "2024-03-16", + "stress_high": 1500, + "recovery_high": 3000, + "day_summary": "stressful", + } + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.daily_stress.get_daily_stress_document(document_id) + self.assertIsInstance(response, DailyStressModel) + self.assertEqual(response.id, document_id) + self.assertEqual(response.day, date(2024, 3, 16)) + self.assertEqual(response.stress_high, 1500) + self.assertEqual(response.day_summary, DailyStressSummary.STRESSFUL) # Assuming DailyStressSummary is an Enum + + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_stress/{document_id}", + headers=self.client.headers, + params=None, # No params for single document GET + ) + + @patch("requests.get") + def test_get_daily_stress_document_not_found_404(self, mock_get): + document_id = "non_existent_id" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error: Not Found") + mock_get.return_value = mock_response + + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_stress.get_daily_stress_document(document_id) + + +class TestDailyResilience(unittest.TestCase): + def setUp(self): + self.client = OuraClient(access_token="test_token") + self.base_url = self.client.BASE_URL + + @patch("requests.get") + def test_get_daily_resilience_documents_no_params(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + response = self.client.daily_resilience.get_daily_resilience_documents() + self.assertIsInstance(response, DailyResilienceResponse) + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_resilience", + headers=self.client.headers, + params={}, + ) + + @patch("requests.get") + def test_get_daily_resilience_documents_start_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_resilience.get_daily_resilience_documents(start_date="2024-02-01") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_resilience", + headers=self.client.headers, + params={"start_date": "2024-02-01"}, + ) + + @patch("requests.get") + def test_get_daily_resilience_documents_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_resilience.get_daily_resilience_documents(end_date="2024-02-28") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_resilience", + headers=self.client.headers, + params={"end_date": "2024-02-28"}, + ) + + @patch("requests.get") + def test_get_daily_resilience_documents_start_and_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_resilience.get_daily_resilience_documents(start_date="2024-02-01", end_date="2024-02-28") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_resilience", + headers=self.client.headers, + params={"start_date": "2024-02-01", "end_date": "2024-02-28"}, + ) + + @patch("requests.get") + def test_get_daily_resilience_documents_next_token(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_resilience.get_daily_resilience_documents(next_token="res_token") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_resilience", + headers=self.client.headers, + params={"next_token": "res_token"}, + ) + + @patch("requests.get") + def test_get_daily_resilience_documents_success(self, mock_get): + mock_contributors_data = { + "sleep_recovery": 75.0, + "daytime_recovery": 60.0, + "stress": 80.0, + } + mock_data = [ + { + "id": "res_doc_1", + "day": "2024-03-18", + "contributors": mock_contributors_data, + "level": "solid", + } + ] + mock_response_json = {"data": mock_data, "next_token": "res_next_token"} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.daily_resilience.get_daily_resilience_documents(start_date="2024-03-18") + self.assertIsInstance(response, DailyResilienceResponse) + self.assertEqual(len(response.data), 1) + model_item = response.data[0] + self.assertIsInstance(model_item, DailyResilienceModel) + self.assertEqual(model_item.id, "res_doc_1") + self.assertIsInstance(model_item.contributors, ResilienceContributors) + self.assertEqual(model_item.contributors.sleep_recovery, 75.0) + self.assertEqual(model_item.level, LongTermResilienceLevel.SOLID) + self.assertEqual(response.next_token, "res_next_token") + mock_get.assert_called_with( + f"{self.base_url}/v2/usercollection/daily_resilience", + headers=self.client.headers, + params={"start_date": "2024-03-18"}, + ) + + @patch("requests.get") + def test_get_daily_resilience_documents_api_error_400(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("400 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_resilience.get_daily_resilience_documents() + + @patch("requests.get") + def test_get_daily_resilience_documents_api_error_401(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("401 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_resilience.get_daily_resilience_documents() + + @patch("requests.get") + def test_get_daily_resilience_documents_api_error_429(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("429 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_resilience.get_daily_resilience_documents() + + @patch("requests.get") + def test_get_daily_resilience_document_success(self, mock_get): + document_id = "sample_res_id" + mock_contributors_data = { + "sleep_recovery": 80.5, + "daytime_recovery": 65.2, + "stress": 70.1, + } + mock_response_json = { + "id": document_id, + "day": "2024-03-19", + "contributors": mock_contributors_data, + "level": "exceptional", + } + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.daily_resilience.get_daily_resilience_document(document_id) + self.assertIsInstance(response, DailyResilienceModel) + self.assertEqual(response.id, document_id) + self.assertEqual(response.day, date(2024, 3, 19)) + self.assertIsInstance(response.contributors, ResilienceContributors) + self.assertEqual(response.contributors.daytime_recovery, 65.2) + self.assertEqual(response.level, LongTermResilienceLevel.EXCEPTIONAL) + + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_resilience/{document_id}", + headers=self.client.headers, + params=None, + ) + + @patch("requests.get") + def test_get_daily_resilience_document_not_found_404(self, mock_get): + document_id = "non_existent_res_id" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error: Not Found") + mock_get.return_value = mock_response + + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_resilience.get_daily_resilience_document(document_id) + + +class TestDailyCardiovascularAge(unittest.TestCase): + def setUp(self): + self.client = OuraClient(access_token="test_token") + self.base_url = self.client.BASE_URL + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_no_params(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + response = self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents() + self.assertIsInstance(response, DailyCardiovascularAgeResponse) + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_cardiovascular_age", + headers=self.client.headers, + params={}, + ) + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_start_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents(start_date="2024-03-01") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_cardiovascular_age", + headers=self.client.headers, + params={"start_date": "2024-03-01"}, + ) + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents(end_date="2024-03-31") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_cardiovascular_age", + headers=self.client.headers, + params={"end_date": "2024-03-31"}, + ) + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_start_and_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents(start_date="2024-03-01", end_date="2024-03-31") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_cardiovascular_age", + headers=self.client.headers, + params={"start_date": "2024-03-01", "end_date": "2024-03-31"}, + ) + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_next_token(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents(next_token="cva_token") + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_cardiovascular_age", + headers=self.client.headers, + params={"next_token": "cva_token"}, + ) + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_success(self, mock_get): + mock_data = [ + { + # The API might return an 'id' but DailyCardiovascularAgeModel doesn't have it. + # This is fine, Pydantic will ignore extra fields. + "id": "cva_doc_api_id_1", + "day": "2024-03-20", + "vascular_age": 30.5, # Changed to float to match spec + } + ] + mock_response_json = {"data": mock_data, "next_token": "cva_next_token"} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents(start_date="2024-03-20") + self.assertIsInstance(response, DailyCardiovascularAgeResponse) + self.assertEqual(len(response.data), 1) + model_item = response.data[0] + self.assertIsInstance(model_item, DailyCardiovascularAgeModel) + self.assertEqual(model_item.day, date(2024, 3, 20)) + self.assertEqual(model_item.vascular_age, 30.5) + self.assertEqual(response.next_token, "cva_next_token") + mock_get.assert_called_with( + f"{self.base_url}/v2/usercollection/daily_cardiovascular_age", + headers=self.client.headers, + params={"start_date": "2024-03-20"}, + ) + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_api_error_400(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("400 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents() + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_api_error_401(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("401 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents() + + @patch("requests.get") + def test_get_daily_cardiovascular_age_documents_api_error_429(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("429 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_documents() + + @patch("requests.get") + def test_get_daily_cardiovascular_age_document_success(self, mock_get): + document_id = "sample_cva_id" + mock_response_json = { + "id": document_id, + "day": "2024-03-21", + "vascular_age": 32.0, # Changed to float + } + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_document(document_id) + self.assertIsInstance(response, DailyCardiovascularAgeModel) + self.assertEqual(response.day, date(2024, 3, 21)) + self.assertEqual(response.vascular_age, 32.0) + + mock_get.assert_called_once_with( + f"{self.base_url}/v2/usercollection/daily_cardiovascular_age/{document_id}", + headers=self.client.headers, + params=None, + ) + + @patch("requests.get") + def test_get_daily_cardiovascular_age_document_not_found_404(self, mock_get): + document_id = "non_existent_cva_id" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error: Not Found") + mock_get.return_value = mock_response + + with self.assertRaises(requests.exceptions.HTTPError): + self.client.daily_cardiovascular_age.get_daily_cardiovascular_age_document(document_id) + + +class TestVo2Max(unittest.TestCase): + def setUp(self): + self.client = OuraClient(access_token="test_token") + self.base_url = self.client.BASE_URL + self.correct_path_segment = "/v2/usercollection/vO2_max" # Note the casing + + @patch("requests.get") + def test_get_vo2_max_documents_no_params(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + response = self.client.vo2_max.get_vo2_max_documents() + self.assertIsInstance(response, Vo2MaxResponse) + mock_get.assert_called_once_with( + f"{self.base_url}{self.correct_path_segment}", + headers=self.client.headers, + params={}, + ) + + @patch("requests.get") + def test_get_vo2_max_documents_start_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.vo2_max.get_vo2_max_documents(start_date="2024-04-01") + mock_get.assert_called_once_with( + f"{self.base_url}{self.correct_path_segment}", + headers=self.client.headers, + params={"start_date": "2024-04-01"}, + ) + + @patch("requests.get") + def test_get_vo2_max_documents_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.vo2_max.get_vo2_max_documents(end_date="2024-04-30") + mock_get.assert_called_once_with( + f"{self.base_url}{self.correct_path_segment}", + headers=self.client.headers, + params={"end_date": "2024-04-30"}, + ) + + @patch("requests.get") + def test_get_vo2_max_documents_start_and_end_date(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.vo2_max.get_vo2_max_documents(start_date="2024-04-01", end_date="2024-04-30") + mock_get.assert_called_once_with( + f"{self.base_url}{self.correct_path_segment}", + headers=self.client.headers, + params={"start_date": "2024-04-01", "end_date": "2024-04-30"}, + ) + + @patch("requests.get") + def test_get_vo2_max_documents_next_token(self, mock_get): + mock_response_data = {"data": [], "next_token": None} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_data + mock_get.return_value = mock_response + + self.client.vo2_max.get_vo2_max_documents(next_token="vo2_token") + mock_get.assert_called_once_with( + f"{self.base_url}{self.correct_path_segment}", + headers=self.client.headers, + params={"next_token": "vo2_token"}, + ) + + @patch("requests.get") + def test_get_vo2_max_documents_success(self, mock_get): + mock_data = [ + { + "id": "vo2_doc_1", + "day": "2024-04-10", + "timestamp": "2024-04-10T10:00:00+00:00", + "vo2_max": 35.5, + } + ] + mock_response_json = {"data": mock_data, "next_token": "vo2_next_token"} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.vo2_max.get_vo2_max_documents(start_date="2024-04-10") + self.assertIsInstance(response, Vo2MaxResponse) + self.assertEqual(len(response.data), 1) + model_item = response.data[0] + self.assertIsInstance(model_item, Vo2MaxModel) + self.assertEqual(model_item.id, "vo2_doc_1") + self.assertEqual(model_item.day, date(2024, 4, 10)) + self.assertEqual(model_item.vo2_max, 35.5) + self.assertEqual(response.next_token, "vo2_next_token") + mock_get.assert_called_with( + f"{self.base_url}{self.correct_path_segment}", + headers=self.client.headers, + params={"start_date": "2024-04-10"}, + ) + + @patch("requests.get") + def test_get_vo2_max_documents_api_error_400(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("400 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.vo2_max.get_vo2_max_documents() + + @patch("requests.get") + def test_get_vo2_max_documents_api_error_401(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("401 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.vo2_max.get_vo2_max_documents() + + @patch("requests.get") + def test_get_vo2_max_documents_api_error_429(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("429 Client Error") + mock_get.return_value = mock_response + with self.assertRaises(requests.exceptions.HTTPError): + self.client.vo2_max.get_vo2_max_documents() + + @patch("requests.get") + def test_get_vo2_max_document_success(self, mock_get): + document_id = "sample_vo2_id" + mock_response_json = { + "id": document_id, + "day": "2024-04-11", + "timestamp": "2024-04-11T11:00:00+00:00", + "vo2_max": 36.2, + } + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = mock_response_json + mock_get.return_value = mock_response + + response = self.client.vo2_max.get_vo2_max_document(document_id) + self.assertIsInstance(response, Vo2MaxModel) + self.assertEqual(response.id, document_id) + self.assertEqual(response.day, date(2024, 4, 11)) + self.assertEqual(response.timestamp, datetime.fromisoformat("2024-04-11T11:00:00+00:00")) + self.assertEqual(response.vo2_max, 36.2) + + mock_get.assert_called_once_with( + f"{self.base_url}{self.correct_path_segment}/{document_id}", + headers=self.client.headers, + params=None, + ) + + @patch("requests.get") + def test_get_vo2_max_document_not_found_404(self, mock_get): + document_id = "non_existent_vo2_id" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error: Not Found") + mock_get.return_value = mock_response + + with self.assertRaises(requests.exceptions.HTTPError): + self.client.vo2_max.get_vo2_max_document(document_id)