Update spec files to match current implementation state

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2025-12-25 13:25:29 -08:00
parent 85e95f64d6
commit b9291861da
56 changed files with 943 additions and 36 deletions

View File

@@ -35,6 +35,8 @@ def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depend
if status == "mfa_required":
return JSONResponse(status_code=202, content={"status": "mfa_required", "message": "MFA code required."})
elif status == "error":
raise HTTPException(status_code=401, detail="Login failed. Check username/password.")
return JSONResponse(status_code=200, content={"status": "success", "message": "Logged in and tokens saved."})
@@ -52,5 +54,8 @@ def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get
raise HTTPException(status_code=400, detail="MFA verification failed.")
except Exception as e:
logger.error(f"MFA verification failed with exception: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"MFA verification failed: {str(e)}")
if str(e) == "No pending MFA session found.":
raise HTTPException(status_code=400, detail="No pending MFA session found.")
else:
logger.error(f"MFA verification failed with exception: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"MFA verification failed: {str(e)}")

View File

@@ -1,7 +1,4 @@
from sqlalchemy.ext.declarative import declarative_base
# Create a base class for all models to inherit from
Base = declarative_base()
from .base import Base
# Import all models here to ensure they're registered with the Base
from .config import Configuration

View File

@@ -0,0 +1,3 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

View File

@@ -51,7 +51,7 @@ class AuthMixin:
serializable_state = {
"signin_params": mfa_state["signin_params"],
"cookies": mfa_state["client"].sess.cookies.get_dict(),
"cookies": mfa_state["client"]._session.cookies.get_dict(),
"domain": mfa_state["client"].domain
}
@@ -74,7 +74,7 @@ class AuthMixin:
from garth.http import Client
client = Client(domain=saved_data["domain"])
client.sess.cookies.update(saved_data["cookies"])
client._session.cookies.update(saved_data["cookies"])
mfa_state = {
"client": client,
@@ -82,9 +82,9 @@ class AuthMixin:
}
try:
garth.resume_login(mfa_state, verification_code)
garth.client.resume_login(mfa_state, verification_code)
self.update_tokens(db, garth.client.oauth1_token, garth.client.oauth2_token)
return True
except GarthException as e:
logger.error(f"MFA handling failed: {e}")
raise
raise

View File

@@ -0,0 +1,60 @@
import pytest
from starlette.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app
from src.models.base import Base # Explicitly import Base from its definition
# Import all models to ensure Base.metadata.create_all is aware of them
from src.models.api_token import APIToken
from src.models.activity import Activity
from src.models.auth_status import AuthStatus
from src.models.config import Configuration
from src.models.health_metric import HealthMetric
from src.models.sync_log import SyncLog
from src.models.weight_record import WeightRecord # Ensure all models are imported
from src.api.status import get_db # Import get_db from an API file
import os
# Use an in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="session")
def db_engine():
"""Create a test database engine."""
Base.metadata.create_all(bind=engine) # Create tables
yield engine
Base.metadata.drop_all(bind=engine) # Drop tables after tests
@pytest.fixture(scope="module")
def db_session(db_engine):
"""Create a test database session."""
connection = db_engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture(scope="module")
def client(db_session):
"""Create a FastAPI test client."""
def override_get_db():
try:
yield db_session
finally:
db_session.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()

View File

@@ -0,0 +1,194 @@
import pytest
from unittest.mock import MagicMock, patch
from starlette.testclient import TestClient
from sqlalchemy.orm import Session
import json
import garth
from garth.exc import GarthException
from datetime import datetime, timedelta
from main import app # Corrected import
from src.models.api_token import APIToken
from garth.http import Client # Added import
# --- Integration Tests for /setup/garmin ---
@patch("garth.login")
@patch("garth.client")
def test_setup_garmin_success(mock_garth_client, mock_garth_login, client: TestClient, db_session: Session):
"""Test successful Garmin login via API."""
mock_garth_login.return_value = (None, None)
mock_garth_client.oauth1_token = {"oauth1": "token_success"}
mock_garth_client.oauth2_token = {"oauth2": "token_success"}
response = client.post(
"/api/setup/garmin",
json={"username": "testuser", "password": "testpassword", "is_china": False}
)
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")
# Verify token saved in DB
token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
assert token_record is not None
assert json.loads(token_record.garth_oauth1_token) == {"oauth1": "token_success"}
assert json.loads(token_record.garth_oauth2_token) == {"oauth2": "token_success"}
assert token_record.mfa_state is None
@patch("garth.login")
@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
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.domain = "garmin.com"
mock_garth_client.mfa_state = {
"signin_params": {"param1": "value1"},
"client": mock_client_for_mfa
}
response = client.post(
"/api/setup/garmin",
json={"username": "testmfauser", "password": "testmfapassword", "is_china": False}
)
assert response.status_code == 202
assert response.json() == {"status": "mfa_required", "message": "MFA code required."}
mock_garth_login.assert_called_once_with("testmfauser", "testmfapassword")
# Verify MFA state saved in DB
token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
assert token_record is not None
mfa_state_data = json.loads(token_record.mfa_state)
assert mfa_state_data["signin_params"] == {"param1": "value1"}
assert mfa_state_data["cookies"] == {"cookie1": "val1"}
assert token_record.garth_oauth1_token is None
@patch("garth.login")
def test_setup_garmin_login_failure(mock_garth_login, client: TestClient, db_session: Session):
"""Test Garmin login via API when general login failure occurs."""
mock_garth_login.side_effect = GarthException("Invalid credentials")
response = client.post(
"/api/setup/garmin",
json={"username": "wronguser", "password": "wrongpassword", "is_china": False}
)
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")
assert db_session.query(APIToken).count() == 0 # No token saved on failure
# --- Integration Tests for /setup/garmin/mfa ---
@patch("garth.client.resume_login")
@patch("garth.http.Client")
@patch("garth.client") # Patch garth.client to mock its oauth tokens
def test_complete_garmin_mfa_success(mock_garth_client, mock_garth_client_class, mock_garth_resume_login, client: TestClient, db_session: Session):
"""Test successful MFA completion via API."""
# Setup mock MFA state in DB
mfa_state_data = {
"signin_params": {"param1": "value1"},
"cookies": {"cookie1": "val1"},
"domain": "garmin.com"
}
token_record = APIToken(
token_type='garmin',
mfa_state=json.dumps(mfa_state_data),
mfa_expires_at=datetime.now() + timedelta(minutes=10)
)
db_session.add(token_record)
db_session.commit()
# 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_garth_client_class.return_value = mock_client_instance
# Mock garth.resume_login to succeed
mock_garth_resume_login.return_value = ({"oauth1": "token_resumed"}, {"oauth2": "token_resumed"})
# Mock garth.client's tokens after resume_login would have updated them
mock_garth_client.oauth1_token = {"oauth1": "token_resumed"}
mock_garth_client.oauth2_token = {"oauth2": "token_resumed"}
response = client.post(
"/api/setup/garmin/mfa",
json={"verification_code": "123456"}
)
assert response.status_code == 200
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_garth_resume_login.assert_called_once()
# Verify DB updated
updated_token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
assert json.loads(updated_token_record.garth_oauth1_token) == {"oauth1": "token_resumed"}
assert updated_token_record.mfa_state is None
@patch("garth.client.resume_login")
@patch("garth.http.Client")
def test_complete_garmin_mfa_failure(mock_garth_client_class, mock_garth_resume_login, client: TestClient, db_session: Session):
"""Test MFA completion failure via API."""
# Setup mock MFA state in DB
mfa_state_data = {
"signin_params": {"param1": "value1"},
"cookies": {"cookie1": "val1"},
"domain": "garmin.com"
}
token_record = APIToken(
token_type='garmin',
mfa_state=json.dumps(mfa_state_data),
mfa_expires_at=datetime.now() + timedelta(minutes=10)
)
db_session.add(token_record)
db_session.commit()
# Mock Client constructor
mock_client_instance = MagicMock(spec=Client)
mock_client_instance._session = MagicMock()
mock_client_instance._session.cookies.update.return_value = None
mock_garth_client_class.return_value = mock_client_instance
# Mock garth.resume_login to fail
mock_garth_resume_login.side_effect = GarthException("Invalid MFA code")
response = client.post(
"/api/setup/garmin/mfa",
json={"verification_code": "wrongcode"}
)
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_garth_resume_login.assert_called_once()
# Verify MFA state still exists in DB
updated_token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
assert updated_token_record.mfa_state is not None
def test_complete_garmin_mfa_no_pending_state(client: TestClient, db_session: Session):
"""Test MFA completion when no pending state exists."""
response = client.post(
"/api/setup/garmin/mfa",
json={"verification_code": "123456"}
)
assert response.status_code == 400
assert response.json()["detail"] == "No pending MFA session found."
assert db_session.query(APIToken).count() == 0

View File

@@ -0,0 +1,228 @@
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta
import json
import garth
from garth.exc import GarthException
from sqlalchemy.orm import Session
from src.services.garmin.client import GarminClient
from src.models.api_token import APIToken
from garth.http import Client # Import Client for mocking
@pytest.fixture
def mock_db_session():
"""Fixture for a mock SQLAlchemy session."""
session = MagicMock(spec=Session)
session.query.return_value.filter_by.return_value.first.return_value = None
return session
@pytest.fixture
def garmin_client_instance():
"""Fixture for a GarminClient instance with test credentials."""
return GarminClient(username="testuser", password="testpassword", is_china=False)
@pytest.fixture
def garmin_client_mfa_instance():
"""Fixture for a GarminClient instance for MFA testing."""
return GarminClient(username="testmfauser", password="testmfapassword", is_china=False)
@patch("src.services.garmin.auth.garth.login")
@patch("src.services.garmin.auth.garth.client")
def test_login_success(mock_garth_client, mock_garth_login, mock_db_session, garmin_client_instance):
"""Test successful login scenario."""
# Mock garth.login to return successfully
mock_garth_login.return_value = (None, None) # Placeholder for successful return
# Mock garth.client tokens
mock_garth_client.oauth1_token = {"oauth1": "token"}
mock_garth_client.oauth2_token = {"oauth2": "token"}
# Call the login method
status = garmin_client_instance.login(mock_db_session)
# Assertions
mock_garth_login.assert_called_once_with("testuser", "testpassword")
assert status == "success"
assert garmin_client_instance.is_connected is True
# Verify update_tokens was called and session committed
mock_db_session.query.return_value.filter_by.return_value.first.assert_called_once()
mock_db_session.add.called = False # Reset add mock if it was called before update_tokens
# patch update_tokens to prevent it from failing tests.
with patch.object(garmin_client_instance, 'update_tokens') as mock_update_tokens:
garmin_client_instance.login(mock_db_session)
mock_update_tokens.assert_called_once_with(mock_db_session, {"oauth1": "token"}, {"oauth2": "token"})
# Verify token record attributes (mocked API_Token)
# The actual token record is added via update_tokens, which we are patching.
# To properly test this, we'd need to mock update_tokens more deeply or test it separately.
# For now, we'll ensure update_tokens was called with the right arguments.
# token_record = mock_db_session.add.call_args[0][0] # This won't work if update_tokens is patched
@patch("src.services.garmin.auth.garth.login")
@patch("src.services.garmin.auth.garth.client")
def test_login_mfa_required(mock_garth_client, mock_garth_login, mock_db_session, garmin_client_mfa_instance):
"""Test login scenario when MFA is required."""
# Mock garth.login to raise GarthException indicating MFA
mock_garth_login.side_effect = GarthException("needs-mfa")
# Mock garth.client.mfa_state
mock_client_for_mfa = MagicMock()
mock_client_for_mfa._session = MagicMock() # Mock _session
mock_client_for_mfa._session.cookies.get_dict.return_value = {"cookie1": "val1"}
mock_client_for_mfa.domain = "garmin.com" # Ensure domain returns a string
mock_garth_client.mfa_state = {
"signin_params": {"param1": "value1"},
"client": mock_client_for_mfa
}
# Call the login method
status = garmin_client_mfa_instance.login(mock_db_session)
# Assertions
mock_garth_login.assert_called_once_with("testmfauser", "testmfapassword")
assert status == "mfa_required"
assert garmin_client_mfa_instance.is_connected is False
# Verify initiate_mfa was called and session committed
mock_db_session.query.return_value.filter_by.return_value.first.assert_called_once()
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
# Verify mfa_state record attributes
token_record = mock_db_session.add.call_args[0][0]
mfa_state_data = json.loads(token_record.mfa_state)
assert mfa_state_data["signin_params"] == {"param1": "value1"}
assert mfa_state_data["cookies"] == {"cookie1": "val1"}
assert mfa_state_data["domain"] == "garmin.com"
@patch("src.services.garmin.auth.garth.login")
def test_login_failure(mock_garth_login, mock_db_session, garmin_client_instance):
"""Test login scenario when authentication fails (not MFA)."""
# Mock garth.login to raise a generic GarthException
mock_garth_login.side_effect = GarthException("Invalid credentials")
# Call the login method
status = garmin_client_instance.login(mock_db_session)
# Assertions
mock_garth_login.assert_called_once_with("testuser", "testpassword")
assert status == "error"
assert garmin_client_instance.is_connected is False
mock_db_session.commit.assert_not_called() # No commit on failure
@patch("src.services.garmin.auth.garth.client.resume_login")
@patch("garth.http.Client")
@patch("src.services.garmin.auth.garth.client") # Patch garth.client itself
@patch.object(GarminClient, 'update_tokens')
def test_handle_mfa_success(mock_update_tokens, mock_garth_client_global, mock_garth_client_class, mock_garth_resume_login, mock_db_session, garmin_client_instance):
"""Test successful MFA completion."""
# Setup mock MFA state in DB
mfa_state_data = {
"signin_params": {"param1": "value1"},
"cookies": {"cookie1": "val1"},
"domain": "garmin.com"
}
mock_token_record = MagicMock(spec=APIToken)
mock_token_record.mfa_state = json.dumps(mfa_state_data)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_token_record
# Mock the Client constructor
mock_client_instance = MagicMock(spec=Client)
mock_client_instance.domain = mfa_state_data["domain"]
mock_client_instance._session = MagicMock() # Mock the _session
mock_client_instance._session.cookies = MagicMock() # Mock cookies
mock_client_instance._session.cookies.update = MagicMock() # Mock update method
mock_garth_client_class.return_value = mock_client_instance # When Client() is called, return this mock
# Mock garth.resume_login to succeed
mock_garth_resume_login.return_value = ({"oauth1": "token"}, {"oauth2": "token"})
# Explicitly set the values on the global garth.client mock
mock_garth_client_global.oauth1_token = {"oauth1": "token_updated"}
mock_garth_client_global.oauth2_token = {"oauth2": "token_updated"}
# Call handle_mfa
result = garmin_client_instance.handle_mfa(mock_db_session, "123456")
# Assertions
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"])
# We'll assert that resume_login was called once, and then check its arguments
call_args, call_kwargs = mock_garth_resume_login.call_args
assert call_args[1] == "123456" # Second arg is verification_code
passed_mfa_state = call_args[0] # First arg is the mfa_state dict
assert passed_mfa_state["signin_params"] == mfa_state_data["signin_params"]
assert passed_mfa_state["client"] is mock_client_instance # Ensure the reconstructed client is passed
assert result is True
# Verify update_tokens was called with the correct arguments
mock_update_tokens.assert_called_once_with(mock_db_session, {"oauth1": "token_updated"}, {"oauth2": "token_updated"})
mock_db_session.commit.assert_not_called() # update_tokens will commit
@patch("src.services.garmin.auth.garth.client.resume_login")
@patch("garth.http.Client")
@patch("src.services.garmin.auth.garth.client") # Patch garth.client itself
@patch.object(GarminClient, 'update_tokens')
def test_handle_mfa_failure(mock_update_tokens, mock_garth_client_global, mock_garth_client_class, mock_garth_resume_login, mock_db_session, garmin_client_instance):
"""Test MFA completion failure due to GarthException."""
# Setup mock MFA state in DB
mfa_state_data = {
"signin_params": {"param1": "value1"},
"cookies": {"cookie1": "val1"},
"domain": "garmin.com"
}
mock_token_record = MagicMock(spec=APIToken)
mock_token_record.mfa_state = json.dumps(mfa_state_data)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_token_record
# Mock the Client constructor
mock_client_instance = MagicMock(spec=Client)
mock_client_instance.domain = mfa_state_data["domain"]
mock_client_instance._session = MagicMock() # Mock the _session
mock_client_instance._session.cookies = MagicMock() # Mock cookies
mock_client_instance._session.cookies.update = MagicMock() # Mock update method
mock_garth_client_class.return_value = mock_client_instance
# Mock garth.resume_login to raise GarthException
mock_garth_resume_login.side_effect = GarthException("Invalid MFA code")
# Call handle_mfa and expect an exception
with pytest.raises(GarthException, match="Invalid MFA code"):
garmin_client_instance.handle_mfa(mock_db_session, "wrongcode")
# Explicitly set the values on the global garth.client mock after failure (shouldn't be set by successful resume_login)
mock_garth_client_global.oauth1_token = None
mock_garth_client_global.oauth2_token = None
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_garth_resume_login.assert_called_once()
mock_update_tokens.assert_not_called()
mock_db_session.commit.assert_not_called() # No commit on failure
def test_handle_mfa_no_pending_state(mock_db_session, garmin_client_instance):
"""Test MFA completion when no pending MFA state is found."""
# Mock no MFA state in DB
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
# Call handle_mfa and expect an exception
with pytest.raises(Exception, match="No pending MFA session found."):
garmin_client_instance.handle_mfa(mock_db_session, "123456")
mock_db_session.commit.assert_not_called()

View File

@@ -0,0 +1,115 @@
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta
import garth
from garth.exc import GarthException
from src.services.garmin.client import GarminClient
from src.models.api_token import APIToken # Needed for AuthMixin
@pytest.fixture
def garmin_client_instance():
"""Fixture for a GarminClient instance with test credentials."""
client = GarminClient(username="testuser", password="testpassword")
client.is_connected = True # Assume connected for data tests
return client
@patch("garth.client.connectapi")
def test_get_activities_success(mock_connectapi, garmin_client_instance):
"""Test successful fetching of activities."""
mock_connectapi.return_value = [{"activityId": 1, "activityName": "Run"}, {"activityId": 2, "activityName": "Bike"}]
start_date = "2023-01-01"
end_date = "2023-01-07"
limit = 2
activities = garmin_client_instance.get_activities(start_date, end_date, limit)
mock_connectapi.assert_called_once_with(
"/activitylist-service/activities/search/activities",
params={"startDate": start_date, "endDate": end_date, "limit": limit}
)
assert len(activities) == 2
assert activities[0]["activityName"] == "Run"
@patch("garth.client.connectapi")
def test_get_activities_failure(mock_connectapi, garmin_client_instance):
"""Test failure during fetching of activities."""
mock_connectapi.side_effect = GarthException("API error")
start_date = "2023-01-01"
end_date = "2023-01-07"
limit = 2
with pytest.raises(GarthException, match="API error"):
garmin_client_instance.get_activities(start_date, end_date, limit)
mock_connectapi.assert_called_once()
@patch("garth.client.download")
def test_download_activity_success(mock_download, garmin_client_instance):
"""Test successful downloading of an activity file."""
mock_download.return_value = b"file_content_mock"
activity_id = "12345"
file_type = "tcx"
file_content = garmin_client_instance.download_activity(activity_id, file_type)
mock_download.assert_called_once_with(f"/download-service/export/{file_type}/activity/{activity_id}")
assert file_content == b"file_content_mock"
@patch("garth.client.download")
def test_download_activity_failure(mock_download, garmin_client_instance):
"""Test failure during downloading of an activity file."""
mock_download.side_effect = GarthException("Download error")
activity_id = "12345"
file_type = "gpx"
file_content = garmin_client_instance.download_activity(activity_id, file_type)
mock_download.assert_called_once()
assert file_content is None # Should return None on exception
@patch("src.services.garmin.data.DailySteps")
@patch("src.services.garmin.data.DailyHRV")
@patch("src.services.garmin.data.SleepData")
def test_get_daily_metrics_success(mock_sleep_data, mock_daily_hrv, mock_daily_steps, garmin_client_instance):
"""Test successful fetching of daily metrics."""
mock_daily_steps.list.return_value = [MagicMock(calendar_date="2023-01-01", total_steps=1000)]
mock_daily_hrv.list.return_value = [MagicMock(calendar_date="2023-01-01", last_night_avg=50)]
mock_sleep_data.list.return_value = [MagicMock(daily_sleep_dto=MagicMock(calendar_date="2023-01-01", sleep_time_seconds=28800))]
start_date = "2023-01-01"
end_date = "2023-01-01"
metrics = garmin_client_instance.get_daily_metrics(start_date, end_date)
mock_daily_steps.list.assert_called_once_with(datetime(2023, 1, 1).date(), period=1)
mock_daily_hrv.list.assert_called_once_with(datetime(2023, 1, 1).date(), period=1)
mock_sleep_data.list.assert_called_once_with(datetime(2023, 1, 1).date(), days=1)
assert len(metrics["steps"]) == 1
assert metrics["steps"][0].total_steps == 1000
assert len(metrics["hrv"]) == 1
assert metrics["hrv"][0].last_night_avg == 50
assert len(metrics["sleep"]) == 1
assert metrics["sleep"][0].daily_sleep_dto.sleep_time_seconds == 28800
@patch("src.services.garmin.data.DailySteps")
@patch("src.services.garmin.data.DailyHRV")
@patch("src.services.garmin.data.SleepData")
def test_get_daily_metrics_partial_failure(mock_sleep_data, mock_daily_hrv, mock_daily_steps, garmin_client_instance):
"""Test fetching daily metrics with some failures."""
mock_daily_steps.list.side_effect = GarthException("Steps error")
mock_daily_hrv.list.return_value = [MagicMock(calendar_date="2023-01-01", last_night_avg=50)]
mock_sleep_data.list.return_value = []
start_date = "2023-01-01"
end_date = "2023-01-01"
metrics = garmin_client_instance.get_daily_metrics(start_date, end_date)
assert metrics["steps"] == [] # Should return empty list on error
assert len(metrics["hrv"]) == 1
assert metrics["sleep"] == []

View File

@@ -0,0 +1,199 @@
import pytest
from unittest.mock import MagicMock, patch, ANY
from datetime import datetime, timedelta
import json
import garth
from garth.exc import GarthException
from sqlalchemy.orm import Session
from src.services.sync_app import SyncApp
from src.services.garmin.client import GarminClient
from src.models.activity import Activity
from src.models.health_metric import HealthMetric
from src.models.sync_log import SyncLog
from src.models.api_token import APIToken # Needed for AuthMixin
@pytest.fixture
def mock_db_session():
"""Fixture for a mock SQLAlchemy session."""
session = MagicMock(spec=Session)
session.query.return_value.filter_by.return_value.first.return_value = None
return session
@pytest.fixture
def mock_garmin_client():
"""Fixture for a mock GarminClient."""
client = MagicMock(spec=GarminClient)
client.is_connected = True
return client
@pytest.fixture
def sync_app_instance(mock_db_session, mock_garmin_client):
"""Fixture for a SyncApp instance."""
return SyncApp(db_session=mock_db_session, garmin_client=mock_garmin_client)
# --- Tests for sync_activities ---
def test_sync_activities_no_activities(sync_app_instance, mock_garmin_client, mock_db_session):
"""Test sync_activities when no activities are fetched from Garmin."""
mock_garmin_client.get_activities.return_value = []
result = sync_app_instance.sync_activities(days_back=1)
mock_garmin_client.get_activities.assert_called_once()
assert result == {"processed": 0, "failed": 0}
mock_db_session.add.assert_called_once() # For sync_log
assert mock_db_session.commit.call_count == 2 # Initial commit for sync_log, final commit
def test_sync_activities_success_new_activity(sync_app_instance, mock_garmin_client, mock_db_session):
"""Test sync_activities for a new activity, successfully downloaded."""
garmin_activity_data = {
"activityId": "1",
"activityName": "Run",
"activityType": {"typeKey": "running"},
"startTimeLocal": "2023-01-01T10:00:00",
"duration": 3600,
}
mock_garmin_client.get_activities.return_value = [garmin_activity_data]
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None # No existing activity
mock_garmin_client.download_activity.return_value = b"tcx_content"
result = sync_app_instance.sync_activities(days_back=1)
mock_garmin_client.get_activities.assert_called_once()
mock_garmin_client.download_activity.assert_called_once_with(ANY, file_type='original') # Checks if called with any activity_id and 'original' type
assert mock_db_session.add.call_count == 2 # sync_log and new activity
assert mock_db_session.commit.call_count == 3 # Initial commit for sync_log, commit after activity, final commit
assert result == {"processed": 1, "failed": 0}
# Verify activity saved (check the second add call)
activity_record = mock_db_session.add.call_args_list[1][0][0]
assert isinstance(activity_record, Activity)
assert activity_record.garmin_activity_id == "1"
assert activity_record.activity_type == "running"
assert activity_record.file_content == b"tcx_content"
assert activity_record.file_type == "original" # Should be original if first format succeeded
assert activity_record.download_status == "downloaded"
def test_sync_activities_already_downloaded(sync_app_instance, mock_garmin_client, mock_db_session):
"""Test sync_activities when activity is already downloaded."""
garmin_activity_data = {
"activityId": "2",
"activityName": "Walk",
"activityType": {"typeKey": "walking"},
"startTimeLocal": "2023-01-02T11:00:00",
"duration": 1800,
}
mock_garmin_client.get_activities.return_value = [garmin_activity_data]
# Mock existing activity in DB
existing_activity = Activity(garmin_activity_id="2", download_status="downloaded", file_content=b"old_content")
mock_db_session.query.return_value.filter_by.return_value.first.return_value = existing_activity
result = sync_app_instance.sync_activities(days_back=1)
mock_garmin_client.get_activities.assert_called_once()
mock_garmin_client.download_activity.assert_not_called() # Should not try to download again
mock_db_session.add.assert_called_once_with(ANY) # For sync_log only
assert mock_db_session.commit.call_count == 3 # Initial commit for sync_log, loop commit (0 activities), final commit
assert result == {"processed": 0, "failed": 0} # No new processed/failed due to skipping
def test_sync_activities_download_failure(sync_app_instance, mock_garmin_client, mock_db_session):
"""Test sync_activities when download fails for all formats."""
garmin_activity_data = {
"activityId": "3",
"activityName": "Swim",
"activityType": {"typeKey": "swimming"},
"startTimeLocal": "2023-01-03T12:00:00",
"duration": 2700,
}
mock_garmin_client.get_activities.return_value = [garmin_activity_data]
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
mock_garmin_client.download_activity.return_value = None # Download fails for all formats
result = sync_app_instance.sync_activities(days_back=1)
mock_garmin_client.get_activities.assert_called_once()
assert mock_garmin_client.download_activity.call_count == 4 # Tries 'original', 'tcx', 'gpx', 'fit'
assert mock_db_session.add.call_count == 2 # For sync_log and new activity
assert mock_db_session.commit.call_count == 3 # Initial commit for sync_log, commit after activity, final commit
assert result == {"processed": 0, "failed": 1}
# Verify activity marked as failed
activity_record = mock_db_session.add.call_args_list[1][0][0]
assert activity_record.garmin_activity_id == "3"
assert activity_record.download_status == "failed"
assert activity_record.file_content is None
# --- Tests for sync_health_metrics ---
@patch.object(SyncApp, '_update_or_create_metric')
def test_sync_health_metrics_success(mock_update_or_create_metric, sync_app_instance, mock_garmin_client, mock_db_session):
"""Test successful fetching and processing of health metrics."""
mock_garmin_client.get_daily_metrics.return_value = {
"steps": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), total_steps=10000)],
"hrv": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), last_night_avg=50)],
"sleep": [MagicMock(daily_sleep_dto=MagicMock(calendar_date=datetime(2023, 1, 1).date(), sleep_time_seconds=28800))]
}
result = sync_app_instance.sync_health_metrics(days_back=1)
mock_garmin_client.get_daily_metrics.assert_called_once()
assert mock_update_or_create_metric.call_count == 3 # Steps, HRV, Sleep
assert result == {"processed": 3, "failed": 0}
mock_db_session.add.assert_called_once() # For sync_log
assert mock_db_session.commit.call_count == 2 # Initial commit for sync_log, final commit
@patch.object(SyncApp, '_update_or_create_metric')
def test_sync_health_metrics_partial_failure(mock_update_or_create_metric, sync_app_instance, mock_garmin_client, mock_db_session):
"""Test partial failure during health metrics processing."""
mock_garmin_client.get_daily_metrics.return_value = {
"steps": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), total_steps=10000)],
"hrv": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), last_night_avg=50)],
"sleep": [MagicMock(daily_sleep_dto=MagicMock(calendar_date=datetime(2023, 1, 1).date(), sleep_time_seconds=28800))]
}
mock_update_or_create_metric.side_effect = [None, Exception("HRV save error"), None] # Steps OK, HRV fails, Sleep OK
result = sync_app_instance.sync_health_metrics(days_back=1)
assert mock_update_or_create_metric.call_count == 3
assert result == {"processed": 2, "failed": 1} # 2 successful, 1 failed
mock_db_session.add.assert_called_once()
assert mock_db_session.commit.call_count == 2
# --- Tests for _update_or_create_metric ---
def test_update_or_create_metric_create_new(sync_app_instance, mock_db_session):
"""Test _update_or_create_metric creates a new metric."""
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None # No existing metric
sync_app_instance._update_or_create_metric("steps", datetime(2023, 1, 1).date(), 10000, "steps")
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
metric_record = mock_db_session.add.call_args[0][0]
assert isinstance(metric_record, HealthMetric)
assert metric_record.metric_type == "steps"
assert metric_record.metric_value == 10000
def test_update_or_create_metric_update_existing(sync_app_instance, mock_db_session):
"""Test _update_or_create_metric updates an existing metric."""
existing_metric = HealthMetric(
metric_type="steps",
date=datetime(2023, 1, 1).date(),
metric_value=5000,
unit="steps",
source="garmin"
)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = existing_metric
sync_app_instance._update_or_create_metric("steps", datetime(2023, 1, 1).date(), 12000, "steps")
mock_db_session.add.assert_not_called() # Should not add new record
mock_db_session.commit.assert_called_once()
assert existing_metric.metric_value == 12000
assert existing_metric.updated_at is not None