Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fitbit_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
fitbit_cli Module
"""

__version__ = "1.8.0"
__version__ = "1.9.0"
56 changes: 44 additions & 12 deletions fitbit_cli/fitbit_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Fitbit API
"""

from datetime import datetime, timedelta

import requests

from .exceptions import FitbitAPIError
Expand Down Expand Up @@ -134,29 +136,59 @@ def get_azm_intraday(self, start_date, end_date=None):
response = self.make_request("GET", url)
return response.json()

def _fetch_chunked_data(self, url_template, key, start_date, end_date=None):
"""Helper to fetch and aggregate data for APIs with max date range limits."""
max_days = 30
if not end_date:
url = url_template.format(date_range=start_date)
return self.make_request("GET", url).json()

start = (
datetime.strptime(str(start_date), "%Y-%m-%d").date()
if isinstance(start_date, str)
else start_date
)
end = (
datetime.strptime(str(end_date), "%Y-%m-%d").date()
if isinstance(end_date, str)
else end_date
)

if (end - start).days < max_days:
url = url_template.format(date_range=f"{start_date}/{end_date}")
return self.make_request("GET", url).json()

combined_items = []
curr_start = start
while curr_start <= end:
curr_end = min(curr_start + timedelta(days=max_days - 1), end)
chunk_range = (
f"{curr_start.strftime('%Y-%m-%d')}/{curr_end.strftime('%Y-%m-%d')}"
)
url = url_template.format(date_range=chunk_range)
response = self.make_request("GET", url).json()
combined_items.extend(response.get(key, []))
curr_start = curr_end + timedelta(days=1)

return {key: combined_items}

def get_breathing_rate_summary(self, start_date, end_date=None):
"""Get Breathing Rate Summary by Interval and Data"""

date_range = f"{start_date}/{end_date}" if end_date else start_date
url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}.json"
response = self.make_request("GET", url)
return response.json()
url_template = "https://api.fitbit.com/1/user/-/br/date/{date_range}.json"
return self._fetch_chunked_data(url_template, "br", start_date, end_date)

def get_breathing_rate_intraday(self, start_date, end_date=None):
"""Get Breathing Rate Intraday by Interval and Data"""

date_range = f"{start_date}/{end_date}" if end_date else start_date
url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}/all.json"
response = self.make_request("GET", url)
return response.json()
url_template = "https://api.fitbit.com/1/user/-/br/date/{date_range}/all.json"
return self._fetch_chunked_data(url_template, "br", start_date, end_date)

def get_hrv_summary(self, start_date, end_date=None):
"""Get HRV Summary by Interval and Date"""

date_range = f"{start_date}/{end_date}" if end_date else start_date
url = f"https://api.fitbit.com/1/user/-/hrv/date/{date_range}.json"
response = self.make_request("GET", url)
return response.json()
url_template = "https://api.fitbit.com/1/user/-/hrv/date/{date_range}.json"
return self._fetch_chunked_data(url_template, "hrv", start_date, end_date)

def get_body_time_series(self, resource_path, start_date, end_date=None):
"""Get Body Time Series by Interval and Date"""
Expand Down
205 changes: 205 additions & 0 deletions tests/date_range_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
"""
Date Range Chunking Tests for Breathing Rate and HRV APIs
"""

import os
import sys
import unittest
from datetime import date
from unittest.mock import MagicMock

# Add the parent directory to sys.path to make imports work
sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(__file__))))

# pylint: disable=C0413
from fitbit_cli.fitbit_api import FitbitAPI


class TestDateRangeChunking(unittest.TestCase):
"""Test suite for auto-chunking date ranges > 30 days for Breathing Rate and HRV."""

def setUp(self):
self.fitbit = FitbitAPI("client", "secret", "access", "refresh")

def test_breathing_rate_summary_single_date(self):
"""Test breathing rate summary with a single date."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"br": [{"dateTime": "2026-01-01", "value": {"breathingRate": 14.0}}]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_breathing_rate_summary("2026-01-01")

self.fitbit.make_request.assert_called_once_with(
"GET", "https://api.fitbit.com/1/user/-/br/date/2026-01-01.json"
)
self.assertEqual(len(res["br"]), 1)

def test_breathing_rate_summary_short_range(self):
"""Test breathing rate summary with a range <= 30 days."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-15"}]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_breathing_rate_summary("2026-01-01", "2026-01-15")

self.fitbit.make_request.assert_called_once_with(
"GET", "https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-15.json"
)
self.assertEqual(len(res["br"]), 2)

def test_breathing_rate_summary_long_range_chunks_and_aggregates(self):
"""Test breathing rate summary with a range > 30 days automatically chunks and merges data."""
mock_resp1 = MagicMock()
mock_resp1.json.return_value = {
"br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-30"}]
}

mock_resp2 = MagicMock()
mock_resp2.json.return_value = {
"br": [{"dateTime": "2026-01-31"}, {"dateTime": "2026-03-01"}]
}

self.fitbit.make_request = MagicMock(side_effect=[mock_resp1, mock_resp2])

# Range is 59 days: 2026-01-01 to 2026-03-01
res = self.fitbit.get_breathing_rate_summary("2026-01-01", "2026-03-01")

expected_calls = [
(
(
"GET",
"https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-30.json",
),
),
(
(
"GET",
"https://api.fitbit.com/1/user/-/br/date/2026-01-31/2026-03-01.json",
),
),
]
self.assertEqual(self.fitbit.make_request.call_args_list, expected_calls)
self.assertEqual(len(res["br"]), 4)

def test_breathing_rate_intraday_single_date(self):
"""Test breathing rate intraday with a single date."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"br": [
{
"dateTime": "2026-01-01",
"value": {"fullSleepSummaryBreathingRate": 14.5},
}
]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_breathing_rate_intraday("2026-01-01")

self.fitbit.make_request.assert_called_once_with(
"GET", "https://api.fitbit.com/1/user/-/br/date/2026-01-01/all.json"
)
self.assertEqual(len(res["br"]), 1)

def test_breathing_rate_intraday_short_range(self):
"""Test breathing rate intraday with a range <= 30 days."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-20"}]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_breathing_rate_intraday("2026-01-01", "2026-01-20")

self.fitbit.make_request.assert_called_once_with(
"GET",
"https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-20/all.json",
)
self.assertEqual(len(res["br"]), 2)

def test_breathing_rate_intraday_long_range_chunks_and_aggregates(self):
"""Test breathing rate intraday with a range > 30 days automatically chunks and merges data."""
mock_resp1 = MagicMock()
mock_resp1.json.return_value = {
"br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-30"}]
}

mock_resp2 = MagicMock()
mock_resp2.json.return_value = {
"br": [{"dateTime": "2026-01-31"}, {"dateTime": "2026-02-15"}]
}

self.fitbit.make_request = MagicMock(side_effect=[mock_resp1, mock_resp2])

res = self.fitbit.get_breathing_rate_intraday("2026-01-01", "2026-02-15")

expected_calls = [
(
(
"GET",
"https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-30/all.json",
),
),
(
(
"GET",
"https://api.fitbit.com/1/user/-/br/date/2026-01-31/2026-02-15/all.json",
),
),
]
self.assertEqual(self.fitbit.make_request.call_args_list, expected_calls)
self.assertEqual(len(res["br"]), 4)

def test_hrv_summary_single_date(self):
"""Test HRV summary with a single date."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"hrv": [{"dateTime": "2026-01-01", "value": {"dailyRmssd": 42.0}}]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_hrv_summary("2026-01-01")

self.fitbit.make_request.assert_called_once_with(
"GET", "https://api.fitbit.com/1/user/-/hrv/date/2026-01-01.json"
)
self.assertEqual(len(res["hrv"]), 1)

def test_hrv_summary_short_range(self):
"""Test HRV summary with a range <= 30 days."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"hrv": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-25"}]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_hrv_summary("2026-01-01", "2026-01-25")

self.fitbit.make_request.assert_called_once_with(
"GET", "https://api.fitbit.com/1/user/-/hrv/date/2026-01-01/2026-01-25.json"
)
self.assertEqual(len(res["hrv"]), 2)

def test_hrv_long_range_chunks_and_aggregates(self):
"""Test HRV summary with a range > 30 days automatically chunks and merges data."""
mock_resp1 = MagicMock()
mock_resp1.json.return_value = {"hrv": [{"dateTime": "2026-01-01"}]}

mock_resp2 = MagicMock()
mock_resp2.json.return_value = {"hrv": [{"dateTime": "2026-01-31"}]}

self.fitbit.make_request = MagicMock(side_effect=[mock_resp1, mock_resp2])

res = self.fitbit.get_hrv_summary(date(2026, 1, 1), date(2026, 3, 1))

self.assertEqual(self.fitbit.make_request.call_count, 2)
self.assertEqual(len(res["hrv"]), 2)


if __name__ == "__main__":
unittest.main()