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

@@ -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