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:
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user