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

@@ -0,0 +1,153 @@
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from datetime import datetime, timedelta
from src.api.activities import router
from src.models.activity import Activity
from src.models.bike_setup import BikeSetup
from src.models.activity_state import GarminActivityState
from src.api.activities import get_db
from main import app # Use main app instance
# Mock Objects matches Pydantic models structure where needed,
# but for SQLAlchemy response matching, standard objects work.
def mock_activity(garmin_id="12345", name="Test Ride"):
# Mocking a SQLAlchemy Activity object
act = MagicMock(spec=Activity)
act.id = int(garmin_id)
act.garmin_activity_id = garmin_id
act.activity_name = name
act.activity_type = "cycling"
act.start_time = datetime(2023, 1, 1, 10, 0, 0)
act.duration = 3600.0
act.file_type = "fit"
act.download_status = "downloaded"
act.downloaded_at = datetime(2023, 1, 1, 12, 0, 0)
act.avg_power = 200
act.avg_hr = 150
act.avg_cadence = 90
act.is_estimated_power = False
# Relationships
bike = MagicMock(spec=BikeSetup)
bike.id = 1
bike.name = "Road Bike"
bike.frame = "Carbon"
bike.chainring = 50
bike.rear_cog = 11
act.bike_setup = bike
act.bike_setup_id = 1
# File content for details overrides
act.file_content = b"mock_content"
# Extended stats for details
act.distance = 20000.0
act.calories = 800.0
act.max_hr = 180
act.avg_speed = 8.5
act.max_speed = 12.0
act.elevation_gain = 500.0
act.elevation_loss = 500.0
act.max_cadence = 100
act.steps = 0
act.vo2_max = 50.0
return act
def mock_activity_state(garmin_id="12345", name="Test Ride"):
state = MagicMock(spec=GarminActivityState)
state.garmin_activity_id = garmin_id
state.activity_name = name
state.activity_type = "cycling"
state.start_time = datetime(2023, 1, 1, 10, 0, 0)
state.sync_status = "synced"
return state
@pytest.fixture
def mock_db_session():
return MagicMock()
@pytest.fixture
def client(mock_db_session):
def override_get_db():
try:
yield mock_db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_list_activities(client, mock_db_session):
# Setup Mock Return
# list_activities queries (GarminActivityState, Activity)
state1 = mock_activity_state("1001", "Morning Ride")
act1 = mock_activity("1001", "Morning Ride")
state2 = mock_activity_state("1002", "Evening Ride")
act2 = mock_activity("1002", "Evening Ride")
# Mock query().outerjoin().order_by().offset().limit().all() chain
# It's a bit long chain to mock perfectly.
# db.query(...) returns Query object.
mock_query = mock_db_session.query.return_value
mock_query.outerjoin.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = [(state1, act1), (state2, act2)]
response = client.get("/api/activities/list?limit=10&offset=0")
# Check execution
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["garmin_activity_id"] == "1001"
assert data[0]["activity_name"] == "Morning Ride"
assert data[0]["download_status"] == "downloaded"
assert data[0]["bike_setup"]["name"] == "Road Bike"
def test_get_activity_details(client, mock_db_session):
# Setup
act = mock_activity("2001", "Detail Test")
# db.query(Activity).filter(...).first()
mock_query = mock_db_session.query.return_value
mock_query.filter.return_value = mock_query
mock_query.first.return_value = act
response = client.get(f"/api/activities/2001/details")
assert response.status_code == 200
data = response.json()
assert data["garmin_activity_id"] == "2001"
assert data["distance"] == 20000.0
assert data["bike_setup"]["name"] == "Road Bike"
def test_get_activity_streams_mock(client, mock_db_session):
act = mock_activity("3001", "Stream Test")
act.streams_json = None
act.file_content = None
# Mock query returning activity directly
mock_db_session.query.return_value.filter.return_value.first.return_value = act
response = client.get(f"/api/activities/3001/streams")
assert response.status_code == 200
data = response.json()
# Expect empty streams structure
expected_keys = ["time", "heart_rate", "power", "altitude", "speed", "cadence", "respiration_rate"]
for k in expected_keys:
assert k in data
assert data[k] == []

View File

@@ -0,0 +1,39 @@
import pytest
from unittest.mock import MagicMock, patch
from src.services.bike_matching import run_matching_for_all
from src.models.activity import Activity
from src.models.bike_setup import BikeSetup
def test_bike_matching_job_updates_progress():
"""
Verify that run_matching_for_all calls job_manager.update_job
when a job_id is provided.
"""
mock_db = MagicMock()
# Mock Activities
activities = [
Activity(id=1, activity_type="cycling", bike_setup_id=None, bike_match_confidence=None),
Activity(id=2, activity_type="cycling", bike_setup_id=None, bike_match_confidence=None)
]
# Setup Query chain
mock_db.query.return_value.filter.return_value.all.return_value = activities
with patch("src.services.bike_matching.process_activity_matching") as mock_process:
with patch("src.services.job_manager.job_manager") as mock_job_manager:
# Important: Ensure expecting cancellation returns False, otherwise loop breaks
mock_job_manager.should_cancel.return_value = False
run_matching_for_all(mock_db, job_id="test-job-123")
# Verify update_job called at start
mock_job_manager.update_job.assert_any_call("test-job-123", message="Found 2 candidates. Matching...", progress=0)
# Verify update_job called during loop (progress)
# 2 items, index 0 satisfies % 10 == 0
mock_job_manager.update_job.assert_any_call("test-job-123", progress=0)
# Verify process was called
assert mock_process.call_count == 2

View File

@@ -0,0 +1,55 @@
import pytest
from src.models.activity import Activity
from src.services.parsers import extract_activity_data
import os
@pytest.mark.asyncio
async def test_discovery_returns_file_type_when_db_type_missing(db_session, client, tmp_path):
"""
Verify that the discovery API returns the activity type parsed from the file
even if the database record has activity_type=None.
"""
# 1. Create dummy FIT file content (minimal valid header/data or mock)
# Using a real file is better, or mocking extract_activity_data.
# Let's mock extract_activity_data to avoid needing a complex binary file.
from unittest.mock import patch
# Mock return value mimics a running activity
mock_parsed_data = {
'type': 'running',
'points': [[-122.4, 37.8], [-122.41, 37.81]], # Dummy points
'timestamps': []
}
with patch('src.api.discovery.extract_activity_data', return_value=mock_parsed_data) as mock_extract:
# 2. Create Activity in DB with type=None
activity = Activity(
activity_name="Test Activity",
garmin_activity_id="99999",
start_time="2023-01-01T10:00:00",
activity_type=None, # <--- MISSING TYPE
file_type="fit",
file_content=b"dummy_bytes"
)
db_session.add(activity)
db_session.commit()
db_session.refresh(activity)
# 3. Call Discovery Single API
payload = {
"activity_id": activity.id,
"pause_threshold": 10,
"rdp_epsilon": 10,
"turn_threshold": 60,
"min_length": 100
}
response = client.post("/api/discovery/single", json=payload)
assert response.status_code == 200
data = response.json()
# 4. Assert analyzed_activity_type is 'running'
assert data['analyzed_activity_type'] == 'running'
print("Success: API returned inferred type 'running'")

View File

@@ -0,0 +1,45 @@
import pytest
from src.models.activity import Activity
import os
@pytest.mark.asyncio
async def test_discovery_by_garmin_id(db_session, client, tmp_path):
"""
Verify that the discovery API correctly finds an activity when passed its
Garmin Activity ID (as an int/string) instead of the internal DB ID.
"""
# 1. Create Activity with a specific Garmin ID
garmin_id = "9876543210" # Large ID
activity = Activity(
activity_name="Garmin Test Activity",
garmin_activity_id=garmin_id,
start_time="2023-05-20T10:00:00",
activity_type="hiking",
file_type="fit",
file_content=b"dummy"
)
db_session.add(activity)
db_session.commit()
db_session.refresh(activity)
# Internal ID should be small (e.g. 1)
print(f"DEBUG: Created Activity Internal ID: {activity.id}, Garmin ID: {activity.garmin_activity_id}")
# 2. Call Discovery Single API with the GARMIN ID
payload = {
"activity_id": int(garmin_id), # Passing the large ID
"pause_threshold": 10,
"rdp_epsilon": 10,
"turn_threshold": 60,
"min_length": 100
}
response = client.post("/api/discovery/single", json=payload)
# Needs to be 200 OK
assert response.status_code == 200
data = response.json()
# 3. Assert analyzed_activity_type is 'hiking' (retrieved from DB via Garmin ID lookup)
assert data['analyzed_activity_type'] == 'hiking'
print("Success: API found activity via Garmin ID and returned type 'hiking'")

View File

@@ -27,7 +27,8 @@ def test_setup_garmin_success(mock_garth_client, mock_garth_login, client: TestC
assert response.status_code == 200
assert response.json() == {"status": "success", "message": "Logged in and tokens saved."}
mock_garth_login.assert_called_once_with("testuser", "testpassword")
assert response.json() == {"status": "success", "message": "Logged in and tokens saved."}
mock_garth_login.assert_called_once_with("testuser", "testpassword", return_on_mfa=True)
# Verify token saved in DB
token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
@@ -41,13 +42,24 @@ def test_setup_garmin_success(mock_garth_client, mock_garth_login, client: TestC
@patch("garth.client")
def test_setup_garmin_mfa_required(mock_garth_client, mock_garth_login, client: TestClient, db_session: Session):
"""Test Garmin login via API when MFA is required."""
mock_garth_login.side_effect = GarthException("needs-mfa")
# Mock garth.client.mfa_state as it would be set by garth.login
# (Actually if return value is tuple, implementation uses tuple[1] as mfa_state)
mock_client_for_mfa = MagicMock()
mock_client_for_mfa._session = MagicMock()
mock_client_for_mfa._session.cookies.get_dict.return_value = {"cookie1": "val1"}
mock_client_for_mfa.sess = MagicMock()
mock_client_for_mfa.sess.cookies.get_dict.return_value = {"cookie1": "val1"}
mock_client_for_mfa.domain = "garmin.com"
mock_client_for_mfa.last_resp.text = "response_text"
mock_client_for_mfa.last_resp.url = "http://garmin.com/response"
# Mock return tuple (status, state) instead of raising exception
# Must include client object in state so initiate_mfa uses our configured mock
mock_garth_login.return_value = ("needs_mfa", {
"signin_params": {"param1": "value1"},
"mfa": "state",
"client": mock_client_for_mfa
})
mock_garth_login.side_effect = None
mock_garth_client.mfa_state = {
"signin_params": {"param1": "value1"},
@@ -60,8 +72,10 @@ def test_setup_garmin_mfa_required(mock_garth_client, mock_garth_login, client:
)
assert response.status_code == 202
assert response.json() == {"status": "mfa_required", "message": "MFA code required."}
mock_garth_login.assert_called_once_with("testmfauser", "testmfapassword")
response_json = response.json()
assert response_json["status"] == "mfa_required"
assert response_json["message"] == "MFA code required."
mock_garth_login.assert_called_once_with("testmfauser", "testmfapassword", return_on_mfa=True)
# Verify MFA state saved in DB
token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
@@ -84,7 +98,7 @@ def test_setup_garmin_login_failure(mock_garth_login, client: TestClient, db_ses
assert response.status_code == 401
assert response.json()["detail"] == "Login failed. Check username/password." # Updated message
mock_garth_login.assert_called_once_with("wronguser", "wrongpassword")
mock_garth_login.assert_called_once_with("wronguser", "wrongpassword", return_on_mfa=True)
assert db_session.query(APIToken).count() == 0 # No token saved on failure
@@ -110,8 +124,8 @@ def test_complete_garmin_mfa_success(mock_garth_client, mock_garth_client_class,
# Mock Client constructor (called by actual code)
mock_client_instance = MagicMock(spec=Client)
mock_client_instance._session = MagicMock()
mock_client_instance._session.cookies.update.return_value = None # No return needed
mock_client_instance.sess = MagicMock()
mock_client_instance.sess.cookies.update.return_value = None # No return needed
mock_garth_client_class.return_value = mock_client_instance
# Mock garth.resume_login to succeed
@@ -130,7 +144,7 @@ def test_complete_garmin_mfa_success(mock_garth_client, mock_garth_client_class,
assert response.json() == {"status": "success", "message": "MFA verification successful, tokens saved."}
mock_garth_client_class.assert_called_once_with(domain=mfa_state_data["domain"])
mock_client_instance._session.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
mock_client_instance.sess.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
mock_garth_resume_login.assert_called_once()
# Verify DB updated
@@ -159,8 +173,8 @@ def test_complete_garmin_mfa_failure(mock_garth_client_class, mock_garth_resume_
# Mock Client constructor
mock_client_instance = MagicMock(spec=Client)
mock_client_instance._session = MagicMock()
mock_client_instance._session.cookies.update.return_value = None
mock_client_instance.sess = MagicMock()
mock_client_instance.sess.cookies.update.return_value = None
mock_garth_client_class.return_value = mock_client_instance
# Mock garth.resume_login to fail
@@ -174,7 +188,7 @@ def test_complete_garmin_mfa_failure(mock_garth_client_class, mock_garth_resume_
assert response.status_code == 400
assert response.json()["detail"] == "MFA verification failed: Invalid MFA code"
mock_garth_client_class.assert_called_once_with(domain=mfa_state_data["domain"])
mock_client_instance._session.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
mock_client_instance.sess.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
mock_garth_resume_login.assert_called_once()
# Verify MFA state still exists in DB

View File

@@ -0,0 +1,72 @@
from src.models.activity import Activity
import pytest
def test_repro_segment_wrong_type(client, db_session):
"""
Reproduction: Create a segment from a 'running' activity and verify it is not saved as 'cycling'.
"""
# 1. Setup a fake running activity in DB
act_id = 999999999
# Dummy TCX
dummy_tcx = """
<TrainingCenterDatabase>
<Activities>
<Activity Sport="Running">
<Id>2018-01-01T00:00:00Z</Id>
<Lap StartTime="2018-01-01T00:00:00Z">
<Track>
<Trackpoint>
<Time>2018-01-01T00:00:00Z</Time>
<Position>
<LatitudeDegrees>45.0</LatitudeDegrees>
<LongitudeDegrees>-33.0</LongitudeDegrees>
</Position>
</Trackpoint>
<Trackpoint>
<Time>2018-01-01T00:00:10Z</Time>
<Position>
<LatitudeDegrees>45.01</LatitudeDegrees>
<LongitudeDegrees>-33.01</LongitudeDegrees>
</Position>
</Trackpoint>
</Track>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
act = Activity(
id=act_id,
garmin_activity_id=str(act_id),
activity_name="Test Run",
activity_type="running", # Correct Type in DB
file_content=dummy_tcx.encode('utf-8'),
file_type="tcx"
)
db_session.add(act)
db_session.commit()
# 2. Call Create Segment Endpoint
payload = {
"activity_id": act_id,
"name": "Test Segment",
"start_index": 0,
"end_index": 1,
"activity_type": "cycling" # Simulate Frontend forcing 'cycling'
}
response = client.post("/api/segments/create", json=payload)
assert response.status_code == 200, f"Response: {response.text}"
data = response.json()
seg_id = data['id']
from src.models.segment import Segment
segment = db_session.query(Segment).filter(Segment.id == seg_id).first()
print(f"Created Segment Type: {segment.activity_type}")
# Assert it is running
assert segment.activity_type == 'running'

View File

@@ -0,0 +1,42 @@
import pytest
from unittest.mock import MagicMock
from sqlalchemy import func
from datetime import datetime, timezone
from src.models.activity import Activity
from src.models.segment import Segment
@pytest.fixture
def mock_db_session():
return MagicMock()
def test_optimization_logic(mock_db_session):
# Setup test data object
activity = MagicMock(spec=Activity)
activity.id = 256
activity.last_segment_scan_timestamp = None
# 1. Reset
# Logic in original script: manually reset timestamp.
# Here we test the optimization logic steps.
# Scene 1: last_scan is None. Logic should PROCEED.
last_scan = activity.last_segment_scan_timestamp
assert last_scan is None
# If the logic in Job Manager checks `if last_scan >= max_seg_date`, it returns False (Don't skip).
# Scene 2: last_scan exists and new Segments exist.
max_seg_date_mock = datetime(2023, 1, 2, 12, 0, 0, tzinfo=timezone.utc)
mock_db_session.query.return_value.scalar.return_value = max_seg_date_mock
# Set activity last scan to OLDER
activity.last_segment_scan_timestamp = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
# Check Logic
should_skip = (activity.last_segment_scan_timestamp >= max_seg_date_mock)
assert should_skip is False
# Scene 3: last_scan is NEWER
activity.last_segment_scan_timestamp = datetime(2023, 1, 3, 12, 0, 0, tzinfo=timezone.utc)
should_skip = (activity.last_segment_scan_timestamp >= max_seg_date_mock)
assert should_skip is True

View File

@@ -0,0 +1,74 @@
from fastapi.testclient import TestClient
from main import app
import pytest
def test_activity_view_render(client):
"""
Test that the activity view page renders the HTML shell correctly
and contains the necessary container elements for JS to populate.
"""
# Requires an activity ID path parameter, but doesn't validate it in the route handler
response = client.get("/activity/12345")
assert response.status_code == 200
html = response.text
# Verify Title/Header placeholders
assert 'id="act-name"' in html
assert 'id="act-time"' in html
# Verify Map Container
assert 'id="map"' in html
# Verify Chart Container
assert 'id="streams-chart"' in html
# Verify Stats Grid
assert 'class="stats-grid"' in html
assert 'id="metric-dist"' in html
assert 'id="metric-dur"' in html
# Verify Metrics Section
assert 'class="metrics-section"' in html
assert 'id="m-avg-pwr"' in html
def test_segment_view_render(client):
"""
Test that the segments page renders correctly with the segments table
and hidden modals for viewing and comparing segments.
"""
response = client.get("/segments")
assert response.status_code == 200
html = response.text
# Verify Page Title
assert "Segments" in html
# Verify Segments Table
assert 'id="segments-table"' in html
# Verify Create Modal Trigger
assert 'data-bs-target="#createSegmentModal"' in html
def test_comparison_modals_render(client):
"""
Test that the Segment Comparison and Details modals are present in the DOM
of the segments page.
"""
response = client.get("/segments")
assert response.status_code == 200
html = response.text
# Verify View Modal & Attributes
assert 'id="viewSegmentModal"' in html
assert 'id="seg-map"' in html
assert 'id="elevationChart"' in html
assert 'id="leaderboard-table"' in html
# Verify Comparison Modal & Attributes
assert 'id="compareModal"' in html
assert 'Effort Comparison' in html
assert 'id="comparison-table"' in html
assert 'id="comparisonChart"' in html