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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
142
FitnessSync/backend/tests/unit/test_parsers.py
Normal file
142
FitnessSync/backend/tests/unit/test_parsers.py
Normal 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
|
||||
116
FitnessSync/backend/tests/unit/test_segment_matcher.py
Normal file
116
FitnessSync/backend/tests/unit/test_segment_matcher.py
Normal 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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user