feat: implement Fitbit OAuth, Garmin MFA, and optimize segment discovery

- Add Fitbit authentication flow (save credentials, OAuth callback handling)
- Implement Garmin MFA support with successful session/cookie handling
- Optimize segment discovery with new sampling and activity query services
- Refactor database session management in discovery API for better testability
- Enhance activity data parsing for charts and analysis
- Update tests to use testcontainers and proper dependency injection
- Clean up repository by ignoring and removing tracked transient files (.pyc, .db)
This commit is contained in:
2026-01-16 15:35:26 -08:00
parent 45dbc32295
commit d1cfd0fd8e
217 changed files with 1795 additions and 922 deletions

View File

@@ -2,105 +2,110 @@
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from datetime import datetime, timedelta
# Import models and app
from src.models import Base, Configuration, APIToken
from main import app
from src.api.setup import get_db
from src.api.auth import get_db, FitbitCredentials
from src.models import Configuration, APIToken
# Setup in-memory DB for tests
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture
def mock_db_session():
return MagicMock()
@pytest.fixture(scope="module")
def db_engine():
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def db(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture(scope="function")
def client(db):
@pytest.fixture
def client(mock_db_session):
def override_get_db():
try:
yield db
yield mock_db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
del app.dependency_overrides[get_db]
app.dependency_overrides.clear()
def test_save_fitbit_credentials(client, db):
def test_save_fitbit_credentials(client, mock_db_session):
"""Test saving Fitbit credentials and generating auth URL."""
payload = {
"client_id": "test_client_id",
"client_secret": "test_client_secret"
"client_secret": "test_client_secret",
"redirect_uri": "http://localhost/callback"
}
# Needs to match the Pydantic model we will create
response = client.post("/api/setup/fitbit", json=payload)
assert response.status_code == 200
data = response.json()
assert "auth_url" in data
assert "https://www.fitbit.com/oauth2/authorize" in data["auth_url"]
assert "client_id=test_client_id" in data["auth_url"]
# Verify DB
config = db.query(Configuration).first()
assert config is not None
assert config.fitbit_client_id == "test_client_id"
assert config.fitbit_client_secret == "test_client_secret"
@patch("src.api.setup.FitbitClient")
def test_fitbit_callback_success(mock_fitbit_cls, client, db):
# Mock DB query for existing config
mock_db_session.query.return_value.first.return_value = None
# Mock Config creation is handled by code logic (checks if exists, else creates)
with patch("src.api.auth.FitbitClient") as MockFitbitClient:
instance = MockFitbitClient.return_value
instance.get_authorization_url.return_value = "https://www.fitbit.com/oauth2/authorize?client_id=test_client_id"
response = client.post("/api/setup/fitbit", json=payload)
assert response.status_code == 200
data = response.json()
assert "auth_url" in data
assert "test_client_id" in data["auth_url"]
# Verify DB interactions
# Should add new config
assert mock_db_session.add.called
assert mock_db_session.commit.called
def test_fitbit_callback_success(client, mock_db_session):
"""Test Fitbit OAuth callback success."""
# Setup initial config
config_entry = Configuration(fitbit_client_id="cid", fitbit_client_secret="csec")
db.add(config_entry)
db.commit()
# Setup initial config in mock DB
mock_config = MagicMock(spec=Configuration)
mock_config.fitbit_client_id = "cid"
mock_config.fitbit_client_secret = "csec"
mock_config.fitbit_redirect_uri = "uri"
mock_db_session.query.return_value.first.return_value = mock_config
# Mock Token query (return None so it creates new)
# query(Configuration).first() -> config
# query(APIToken).filter_by().first() -> None (to trigger creation)
def query_side_effect(model):
m = MagicMock()
if model == Configuration:
m.first.return_value = mock_config
elif model == APIToken:
m.filter_by.return_value.first.return_value = None
return m
mock_db_session.query.side_effect = query_side_effect
# Mock FitbitClient instance and method
mock_instance = MagicMock()
mock_fitbit_cls.return_value = mock_instance
mock_instance.exchange_code_for_token.return_value = {
"access_token": "new_at",
"refresh_token": "new_rt",
"expires_at": 3600, # seconds
"user_id": "uid",
"scope": ["weight"]
}
with patch("src.api.auth.FitbitClient") as MockFitbitClient:
instance = MockFitbitClient.return_value
instance.exchange_code_for_token.return_value = {
"access_token": "new_at",
"refresh_token": "new_rt",
"expires_in": 3600,
"user_id": "uid",
"scope": ["weight"]
}
payload = {"code": "auth_code_123"}
response = client.post("/api/setup/fitbit/callback", json=payload)
payload = {"code": "auth_code_123"}
response = client.post("/api/setup/fitbit/callback", json=payload)
assert response.status_code == 200
assert response.json()["status"] == "success"
assert response.status_code == 200
assert response.json()["status"] == "success"
# Verify Token saved
token = db.query(APIToken).filter_by(token_type="fitbit").first()
assert token is not None
assert token.access_token == "new_at"
assert token.refresh_token == "new_rt"
# Verify Token saved
assert mock_db_session.add.called # APIToken added
assert mock_db_session.commit.called
@patch("src.api.setup.FitbitClient")
def test_fitbit_callback_no_config(mock_fitbit_cls, client, db):
def test_fitbit_callback_no_config(client, mock_db_session):
"""Test callback fails if no config exists."""
# Mock DB returns None for config
def query_side_effect(model):
m = MagicMock()
if model == Configuration:
m.first.return_value = None # No config
return m
mock_db_session.query.side_effect = query_side_effect
payload = {"code": "auth_code_123"}
response = client.post("/api/setup/fitbit/callback", json=payload)
assert response.status_code == 400
assert "Configuration not found" in response.json()["detail"]
assert "Configuration missing" in response.json()["detail"]

View File

@@ -37,12 +37,22 @@ def test_login_mfa_flow_success_structure():
mock_db = MagicMock(spec=Session)
with patch('src.services.garmin.auth.garth') as mock_garth:
mock_garth.login.side_effect = GarthException("Error: needs-mfa")
# Setup expected structure
mock_client_instance = MagicMock()
mock_client_instance._session.cookies.get_dict.return_value = {"cookie": "yum"}
# Link sess property to _session to match garth.Client behavior or ensure attribute exists
mock_client_instance.sess = mock_client_instance._session
mock_client_instance.domain = "garmin.com"
mock_client_instance.last_resp.text = "success"
mock_client_instance.last_resp.url = "http://garmin.com"
# Mock tuple return for success flow
mock_garth.login.return_value = ("needs_mfa", {
"signin_params": {"csrf": "token"},
"mfa": "state",
"client": mock_client_instance
})
mock_garth.login.side_effect = None
mock_garth.client.mfa_state = {
"signin_params": {"csrf": "token"},

View File

@@ -0,0 +1,142 @@
import pytest
from datetime import datetime, timedelta
import io
import fitdecode
import struct
from src.services.parsers import extract_streams, extract_activity_data, extract_summary
from src.utils.sampling import downsample_streams
# Mock FIT file creation (simplified)
def create_mock_fit_content(points=100, has_gps=True):
# This is hard to mock binary FIT correctly without using the library to Write.
# But fitdecode is a reader.
# Alternatively, we can mock the functions wrapping fitdecode?
# Or just use a very simple known FIT structure if possible?
# Writing a valid FIT file in a test without a library is hard.
#
# Better approach for UNIT testing parsers:
# 1. Mock the fitdecode.FitReader to return frames we want.
# 2. Test `extract_streams` logic given those frames.
pass
class MockFrame:
def __init__(self, name, values):
self.frame_type = fitdecode.FIT_FRAME_DATA
self.name = name
self.values = values
def has_field(self, name):
return name in self.values
def get_value(self, name):
return self.values.get(name)
@pytest.fixture
def mock_fit_reader(monkeypatch):
def mock_reader(file_obj):
return MockFitReaderContext()
monkeypatch.setattr(fitdecode, 'FitReader', mock_reader)
class MockFitReaderContext:
def __init__(self):
self.frames = []
def __enter__(self):
return self.frames
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def add_record(self, ts, lat=None, lon=None, hr=None, pwr=None, alt=None):
vals = {'timestamp': ts}
if lat is not None: vals['position_lat'] = int(lat / (180.0 / 2**31))
if lon is not None: vals['position_long'] = int(lon / (180.0 / 2**31))
if hr is not None: vals['heart_rate'] = hr
if pwr is not None: vals['power'] = pwr
if alt is not None: vals['enhanced_altitude'] = alt
self.frames.append(MockFrame('record', vals))
def test_extract_activity_data_no_gps(monkeypatch):
# Test that we can extract data even if GPS is missing (Fix for the Bug)
frames_iter = []
class MockIter:
def __enter__(self): return self
def __exit__(self, *args): pass
def __iter__(self): return iter(frames_iter)
monkeypatch.setattr(fitdecode, 'FitReader', lambda f: MockIter())
# Add records without GPS
base_time = datetime(2023, 1, 1, 12, 0, 0)
for i in range(10):
vals = {
'timestamp': base_time + timedelta(seconds=i),
'heart_rate': 100 + i,
'power': 200 + i
}
frames_iter.append(MockFrame('record', vals))
data = extract_activity_data(b'dummy', 'fit', strict_gps=False)
assert len(data['timestamps']) == 10
assert len(data['heart_rate']) == 10
assert data['heart_rate'][0] == 100
assert data['points'][0] is None # No GPS
def test_extract_streams_logic(monkeypatch):
frames_iter = []
class MockIter:
def __enter__(self): return self
def __exit__(self, *args): pass
def __iter__(self): return iter(frames_iter)
monkeypatch.setattr(fitdecode, 'FitReader', lambda f: MockIter())
base_time = datetime(2023, 1, 1, 12, 0, 0)
# 5 points with GPS, 5 without
for i in range(5):
vals = {
'timestamp': base_time + timedelta(seconds=i),
'position_lat': int(10 * (2**31 / 180.0)),
'position_long': int(20 * (2**31 / 180.0)),
'enhanced_altitude': 100 + i,
'heart_rate': 140
}
frames_iter.append(MockFrame('record', vals))
for i in range(5, 10):
vals = {
'timestamp': base_time + timedelta(seconds=i),
'heart_rate': 150
# No GPS
}
frames_iter.append(MockFrame('record', vals))
streams = extract_streams(b'dummy', 'fit')
assert len(streams['time']) == 10
assert streams['altitude'][0] == 100
assert streams['altitude'][9] is None
assert streams['heart_rate'][9] == 150
def test_extract_summary(monkeypatch):
frames_iter = []
class MockIter:
def __enter__(self): return self
def __exit__(self, *args): pass
def __iter__(self): return iter(frames_iter)
monkeypatch.setattr(fitdecode, 'FitReader', lambda f: MockIter())
vals = {
'total_distance': 1000.0,
'total_timer_time': 3600.0,
'avg_heart_rate': 145
}
frames_iter.append(MockFrame('session', vals))
summary = extract_summary(b'dummy', 'fit')
assert summary['total_distance'] == 1000.0
assert summary['avg_heart_rate'] == 145

View File

@@ -0,0 +1,116 @@
from unittest.mock import MagicMock
import sys
# Mock fitdecode before imports since it might not be installed in local env (running in docker)
sys.modules['fitdecode'] = MagicMock()
import pytest
from src.utils.geo import ramer_douglas_peucker, haversine_distance, calculate_bounds
from src.services.segment_matcher import SegmentMatcher
from src.models.activity import Activity
from src.models.segment import Segment
from datetime import datetime, timedelta
import json
from unittest.mock import patch
def test_haversine():
# Dist between (0,0) and (0,1) deg is ~111km
d = haversine_distance(0, 0, 0, 1)
# 1 deg lat ~ 111.32 km
assert 110000 < d < 112000
def test_rdp_simple():
# Points on a line
points = [[0,0], [1,1], [2,2]]
# Should simplify to [0,0], [2,2]
simplified = ramer_douglas_peucker(points, epsilon=0.1)
assert len(simplified) == 2
assert simplified[0] == [0,0]
assert simplified[1] == [2,2]
def test_rdp_peak():
# Triangle
points = [[0,0], [1,10], [2,0]] # [lon, lat] note: RDP expects [lon, lat] usually?
# My RDP implementation uses x,y so order doesn't matter for geometric shape
simplified = ramer_douglas_peucker(points, epsilon=1.0)
assert len(simplified) == 3
def test_bounds():
points = [[0,0], [10, 10], [-5, 5]]
bounds = calculate_bounds(points)
# bounds is [min_lat, min_lon, max_lat, max_lon]
# points are [lon, lat].
# 0,0 -> lat=0, lon=0
# 10,10 -> lat=10, lon=10
# -5,5 -> lat=5, lon=-5
assert bounds[0] == 0 # min_lat (0)
assert bounds[2] == 10 # max_lat (10)
assert bounds[1] == -5 # min_lon (-5)
assert bounds[3] == 10 # max_lon (10)
def test_matcher_logic():
# Mock DB session
mock_session = MagicMock()
# Create segment [0,0] -> [0, 0.01] (approx 1.1km north)
segment_points = [[0,0], [0, 0.01]]
segment = Segment(
id=1,
name="Test Seg",
points=json.dumps(segment_points),
bounds=json.dumps(calculate_bounds(segment_points)),
distance=1110.0,
activity_type='cycling'
)
mock_session.query.return_value.filter.return_value.filter.return_value.all.return_value = [segment]
matcher = SegmentMatcher(mock_session)
# Create activity trace that covers this
# 0,0 at T=0
# 0,0.01 at T=100s
act_points = [[0,0], [0, 0.005], [0, 0.01]]
# Mock activity
activity = Activity(id=100, start_time=datetime.now())
# Matcher needs to use parsers internally? Or uses slice of points?
# Matcher logic (_match_segment) uses points list passed to match_activity
# Wait, _match_segment needs timestamps to calc elapsed time.
# We need to mock extract_timestamps_from_file or patch it
from unittest.mock import patch
# Patch the parser where it is IMPORTED/USED in the SegmentMatcher service (or source)
# _create_effort imports extract_activity_data from ..services.parsers
with patch('src.services.parsers.extract_activity_data') as mock_extract:
# 0,0@T0, 0,0.005@T50, 0,0.01@T100
start_time = datetime.now()
timestamps = [start_time, start_time + timedelta(seconds=50), start_time + timedelta(seconds=100)]
# extract_activity_data returns dict
mock_extract.return_value = {
'timestamps': timestamps,
'heart_rate': [100, 110, 120],
'power': [200, 210, 220],
'cadence': [80, 80, 80],
'speed': [10, 10, 10],
'respiration_rate': [20, 20, 20]
}
# Add dummy content
activity.file_content = b'dummy'
activity.file_type = 'fit'
# Run match
efforts = matcher.match_activity(activity, act_points)
assert len(efforts) == 1
effort = efforts[0]
assert effort.segment_id == 1
assert effort.elapsed_time == 100.0