This commit is contained in:
2025-11-17 06:26:36 -08:00
parent 7d4ffcd902
commit 06419077b4
6 changed files with 485 additions and 353 deletions

View File

@@ -35,7 +35,8 @@ class WorkoutSyncService:
start_date = datetime.now() - timedelta(days=days_back)
logger.debug(f"Fetching activities from Garmin starting from: {start_date}")
# Fetch activities from Garmin
# Authenticate and fetch activities from Garmin
await self.garmin_service.authenticate()
activities = await self.garmin_service.get_activities(
limit=50, start_date=start_date, end_date=datetime.now()
)
@@ -109,7 +110,8 @@ class WorkoutSyncService:
sync_log.status = GarminSyncStatus.FAILED
sync_log.error_message = str(e)
await self.db.commit()
raise
# Wrap unexpected errors in GarminAPIError for consistent error handling
raise GarminAPIError(f"An unexpected error occurred: {e}") from e
async def get_latest_sync_status(self):
"""Get the most recent sync log entry."""
@@ -119,7 +121,7 @@ class WorkoutSyncService:
.order_by(desc(GarminSyncLog.created_at))
.limit(1)
)
status = result.scalar_one_or_none()
status = await result.scalar_one_or_none()
logger.debug(f"Latest sync status: {status.status if status else 'None'}")
return status
@@ -129,7 +131,7 @@ class WorkoutSyncService:
result = await self.db.execute(
select(Workout).where(Workout.garmin_activity_id == garmin_activity_id)
)
exists = result.scalar_one_or_none() is not None
exists = await result.scalar_one_or_none() is not None
logger.debug(f"Activity {garmin_activity_id} exists: {exists}")
return exists

View File

@@ -1,130 +1,237 @@
import os
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch, call
import pytest
from backend.app.services.garmin import GarminConnectService as GarminService, GarminAuthError, GarminAPIError
from backend.app.services.garmin import GarminConnectService, GarminAuthError, GarminAPIError
from backend.app.models.garmin_sync_log import GarminSyncStatus
from datetime import datetime, timedelta
import garth # Import garth for type hinting
from garminconnect import Garmin, GarminConnectAuthenticationError, GarminConnectTooManyRequestsError, GarminConnectConnectionError
@pytest.fixture
def mock_env_vars():
with patch.dict(os.environ, {"GARMIN_USERNAME": "test_user", "GARMIN_PASSWORD": "test_password"}):
yield
def create_garmin_client_mock():
mock_client_instance = MagicMock(spec=GarminService) # Use GarminService (which is GarminConnectService)
mock_client_instance.authenticate = AsyncMock(return_value=True)
mock_client_instance.get_activities = AsyncMock(return_value=[])
mock_client_instance.get_activity_details = AsyncMock(return_value={})
mock_client_instance.is_authenticated = MagicMock(return_value=True)
def _create_garmin_client_mock():
mock_client_instance = MagicMock(spec=Garmin)
mock_client_instance.get_activities_by_date = MagicMock(return_value=[])
mock_client_instance.get_activity_details = MagicMock(return_value={})
# Mock garth.dump for saving session
mock_garth = MagicMock()
mock_garth.dump = MagicMock()
mock_client_instance.garth = mock_garth
return mock_client_instance
@pytest.mark.asyncio
async def test_garmin_authentication_success(db_session, mock_env_vars):
"""Test successful Garmin Connect authentication"""
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = FileNotFoundError
service = GarminService(db_session)
mock_client = _create_garmin_client_mock()
mock_client.login.side_effect = [FileNotFoundError(), None] # Session load fails, fresh login succeeds
mock_client.garth.dump.return_value = None # Ensure dump also returns None
# Mock Path constructor and its instance methods
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch('backend.app.services.garmin.Garmin', return_value=mock_client) as mock_garmin_class, \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor:
mock_path_constructor.return_value = mock_path_instance # Configure what Path(...) returns
service = GarminConnectService(db_session)
result = await service.authenticate()
assert result is True
mock_instance.login.assert_called_once_with(os.getenv("GARMIN_USERNAME"), os.getenv("GARMIN_PASSWORD"))
mock_instance.save.assert_called_once_with(service.session_dir)
mock_garmin_class.assert_called_once() # Garmin() constructor called once
mock_path_constructor.assert_called_once_with("data/sessions") # Path constructor called
mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True) # mkdir called
# Verify calls to login on the mock_client instance
assert mock_client.login.call_count == 2
mock_client.login.assert_has_calls([
call(str(service.session_dir)), # First call: session load
call(os.getenv("GARMIN_USERNAME"), os.getenv("GARMIN_PASSWORD")) # Second call: fresh login
])
mock_client.garth.dump.assert_called_once_with(str(service.session_dir))
@pytest.mark.asyncio
async def test_garmin_authentication_failure(db_session, mock_env_vars):
"""Test authentication failure handling"""
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = FileNotFoundError
mock_instance.login.side_effect = Exception("Invalid credentials")
service = GarminService(db_session)
mock_client = _create_garmin_client_mock()
mock_client.login.side_effect = [FileNotFoundError(), GarminConnectAuthenticationError("Invalid credentials")] # Session load fails, fresh login fails
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch('backend.app.services.garmin.Garmin', return_value=mock_client) as mock_garmin_class, \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor:
mock_path_constructor.return_value = mock_path_instance
service = GarminConnectService(db_session)
with pytest.raises(GarminAuthError):
await service.authenticate()
mock_instance.login.assert_called_once_with(os.getenv("GARMIN_USERNAME"), os.getenv("GARMIN_PASSWORD"))
mock_instance.save.assert_not_called()
mock_garmin_class.assert_called_once() # Garmin() constructor called once
mock_path_constructor.assert_called_once_with("data/sessions")
mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True)
# Verify calls to login on the mock_client instance
assert mock_client.login.call_count == 2
mock_client.login.assert_has_calls([
call(str(service.session_dir)), # First call: session load
call(os.getenv("GARMIN_USERNAME"), os.getenv("GARMIN_PASSWORD")) # Second call: fresh login
])
@pytest.mark.asyncio
async def test_garmin_authentication_load_session_success(db_session, mock_env_vars):
"""Test successful loading of existing Garmin session"""
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = None
service = GarminService(db_session)
mock_client = _create_garmin_client_mock()
mock_client.login.return_value = None # Session load succeeds (only one call expected)
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch('backend.app.services.garmin.Garmin', return_value=mock_client) as mock_garmin_class, \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor:
mock_path_constructor.return_value = mock_path_instance
service = GarminConnectService(db_session)
result = await service.authenticate()
assert result is True
mock_instance.load.assert_called_once_with(service.session_dir)
mock_instance.login.assert_not_called()
mock_instance.save.assert_not_called()
mock_garmin_class.assert_called_once_with() # Ensure Garmin() is called once without args
mock_path_constructor.assert_called_once_with("data/sessions")
mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True)
mock_client.login.assert_called_once_with(str(service.session_dir)) # Only session login should be called
@pytest.mark.asyncio
async def test_garmin_authentication_missing_credentials(db_session):
"""Test authentication failure when credentials are missing"""
with patch.dict(os.environ, {"GARMIN_USERNAME": "", "GARMIN_PASSWORD": ""}):
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = FileNotFoundError
service = GarminService(db_session)
with pytest.raises(GarminAuthError, match="Garmin username or password not configured."):
await service.authenticate()
mock_instance.login.assert_not_called()
mock_instance.save.assert_not_called()
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch.dict(os.environ, {"GARMIN_USERNAME": "", "GARMIN_PASSWORD": ""}), \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor:
mock_path_constructor.return_value = mock_path_instance
with pytest.raises(GarminAuthError, match="Garmin username or password not configured."):
service = GarminConnectService(db_session)
mock_path_constructor.assert_called_once_with("data/sessions")
mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True)
await service.authenticate()
@pytest.mark.asyncio
async def test_activity_sync(db_session, mock_env_vars):
"""Test successful activity synchronization"""
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activities.return_value = [
{"activityId": 123, "startTime": "2024-01-01T08:00:00"}
]
service = GarminService(db_session)
service.client = mock_instance
activities = await service.get_activities()
mock_client = _create_garmin_client_mock()
mock_client.get_activities_by_date.return_value = [
{"activityId": 123, "startTimeLocal": "2024-01-01 08:00:00"}
]
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch('backend.app.services.garmin.Garmin', return_value=mock_client) as mock_garmin_class, \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor, \
patch('backend.app.services.garmin.GarminConnectService.authenticate', return_value=True) as mock_authenticate:
mock_path_constructor.return_value = mock_path_instance
service = GarminConnectService(db_session)
activities = await service.get_activities(datetime(2024, 1, 1), datetime(2024, 1, 1))
assert len(activities) == 1
assert activities[0]["activityId"] == 123
mock_instance.get_activities.assert_called_once()
mock_authenticate.assert_called_once() # Ensure authenticate was called
mock_client.get_activities_by_date.assert_called_once()
@pytest.mark.asyncio
async def test_rate_limiting_handling(db_session, mock_env_vars):
"""Test API rate limit error handling"""
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activities.side_effect = Exception("Rate limit exceeded")
service = GarminService(db_session)
service.client = mock_instance
mock_client = _create_garmin_client_mock()
mock_client.get_activities_by_date.side_effect = GarminConnectTooManyRequestsError("Rate limit exceeded")
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch('backend.app.services.garmin.Garmin', return_value=mock_client) as mock_garmin_class, \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor, \
patch('backend.app.services.garmin.GarminConnectService.authenticate', return_value=True) as mock_authenticate:
mock_path_constructor.return_value = mock_path_instance
service = GarminConnectService(db_session)
with pytest.raises(GarminAPIError):
await service.get_activities()
mock_instance.get_activities.assert_called_once()
await service.get_activities(datetime(2024, 1, 1), datetime(2024, 1, 1))
mock_authenticate.assert_called_once() # Ensure authenticate was called
mock_client.get_activities_by_date.assert_called_once()
@pytest.mark.asyncio
async def test_get_activity_details_success(db_session, mock_env_vars):
"""Test successful retrieval of activity details."""
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activity.return_value = {"activityId": 123, "details": "data"}
service = GarminService(db_session)
service.client = mock_instance
mock_client = _create_garmin_client_mock()
mock_client.get_activity_details.return_value = {"activityId": 123, "details": "data"}
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch('backend.app.services.garmin.Garmin', return_value=mock_client) as mock_garmin_class, \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor, \
patch('backend.app.services.garmin.GarminConnectService.authenticate', return_value=True) as mock_authenticate:
mock_path_constructor.return_value = mock_path_instance
service = GarminConnectService(db_session)
details = await service.get_activity_details("123")
assert details["activityId"] == 123
mock_instance.get_activity.assert_called_once_with("123")
mock_authenticate.assert_called_once() # Ensure authenticate was called
mock_client.get_activity_details.assert_called_once_with("123")
@pytest.mark.asyncio
async def test_get_activity_details_failure(db_session, mock_env_vars):
"""Test failure in retrieving activity details."""
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activity.side_effect = Exception("Activity not found")
service = GarminService(db_session)
service.client = mock_instance
mock_client = _create_garmin_client_mock()
mock_client.get_activity_details.side_effect = GarminConnectConnectionError("Activity not found")
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.__str__.return_value = "data/sessions"
mock_path_instance.mkdir.return_value = None
with patch('backend.app.services.garmin.Garmin', return_value=mock_client) as mock_garmin_class, \
patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor, \
patch('backend.app.services.garmin.GarminConnectService.authenticate', return_value=True) as mock_authenticate:
mock_path_constructor.return_value = mock_path_instance
service = GarminConnectService(db_session)
with pytest.raises(GarminAPIError, match="Failed to fetch activity details"):
await service.get_activity_details("123")
mock_instance.get_activity.assert_called_once_with("123")
mock_authenticate.assert_called_once() # Ensure authenticate was called
mock_client.get_activity_details.assert_called_once_with("123")
@pytest.mark.asyncio
async def test_is_authenticated(db_session):
"""Test is_authenticated method"""
service = GarminService(db_session)
assert service.is_authenticated() is False
service.client = MagicMock()
assert service.is_authenticated() is True
service = GarminConnectService(db_session)
assert service.client is None
assert service.username is None
assert service.password is None
# After authentication, client should be set and username/password should be present
service.client = MagicMock(spec=Garmin)
service.username = "testuser"
service.password = "testpass"
assert service.client is not None
assert service.username is not None
assert service.password is not None

View File

@@ -4,7 +4,8 @@ These tests verify the end-to-end functionality of Garmin integration.
"""
import pytest
import os
from unittest.mock import AsyncMock, patch, MagicMock
from unittest.mock import patch, MagicMock, AsyncMock
import garminconnect
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -12,7 +13,8 @@ from sqlalchemy import select
from backend.app.services.garmin import GarminConnectService as GarminService, GarminAuthError, GarminAPIError
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog
from backend.app.models.garmin_sync_log import GarminSyncLog, GarminSyncStatus
from garminconnect import Garmin
@pytest.fixture
@@ -36,33 +38,40 @@ class TestGarminAuthentication:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_successful_authentication(self, mock_client_class, garmin_service):
async def test_successful_authentication(self, mock_client_class, mock_connect_api, garmin_service):
"""Test successful authentication with valid credentials."""
# Setup mock client
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=(None, None))
mock_client.save = MagicMock()
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(return_value=True) # login is not async in python-garminconnect
mock_client_class.return_value = mock_client
mock_connect_api.return_value = {"displayName": "test_user"}
os.environ['GARMIN_USERNAME'] = 'test@example.com'
os.environ['GARMIN_PASSWORD'] = 'testpass123'
# Test authentication
result = await garmin_service.authenticate()
assert result is True
mock_client.login.assert_awaited_once_with('test@example.com', 'testpass123')
mock_client.save.assert_called_once()
mock_client.login.assert_called_once()
@patch.dict(os.environ, {
'GARMIN_USERNAME': 'invalid@example.com',
'GARMIN_PASSWORD': 'wrongpass'
})
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_failed_authentication(self, mock_client_class, garmin_service):
async def test_failed_authentication(self, mock_client_class, mock_connect_api, garmin_service):
"""Test authentication failure with invalid credentials."""
# Setup mock client to raise exception
mock_client = MagicMock()
mock_client.login = AsyncMock(side_effect=Exception("Invalid credentials"))
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(side_effect=garminconnect.GarminConnectAuthenticationError("Invalid credentials"))
mock_client_class.return_value = mock_client
mock_connect_api.return_value = {"displayName": "test_user"}
os.environ['GARMIN_USERNAME'] = 'invalid@example.com'
os.environ['GARMIN_PASSWORD'] = 'wrongpass'
# Test authentication
with pytest.raises(GarminAuthError, match="Authentication failed"):
@@ -72,20 +81,24 @@ class TestGarminAuthentication:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_session_reuse(self, mock_client_class, garmin_service):
async def test_session_reuse(self, mock_client_class, mock_connect_api, garmin_service):
"""Test that existing sessions are reused."""
# Setup mock client with load method
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=(None, None)) # Login handles loading from tokenstore
# Setup mock client. python-garminconnect handles session loading internally
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(return_value=True) # login is not async
mock_client_class.return_value = mock_client
mock_connect_api.return_value = {"displayName": "test_user"}
os.environ['GARMIN_USERNAME'] = 'test@example.com'
os.environ['GARMIN_PASSWORD'] = 'testpass123'
# Test authentication
result = await garmin_service.authenticate()
assert result is True
mock_client.login.assert_awaited_once_with(tokenstore=garmin_service.session_dir)
mock_client.save.assert_not_called()
# python-garminconnect will attempt to load a session before logging in
mock_client.login.assert_called_once()
class TestWorkoutSyncing:
@@ -95,13 +108,16 @@ class TestWorkoutSyncing:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_successful_sync_recent_activities(self, mock_client_class, workout_sync_service, db_session):
async def test_successful_sync_recent_activities(self, mock_client_class, mock_connect_api, workout_sync_service, db_session):
"""Test successful synchronization of recent activities."""
# Setup mock Garmin client
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=True)
mock_client.save = MagicMock()
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(return_value=True)
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
# Mock activity data
mock_activities = [
@@ -160,15 +176,16 @@ class TestWorkoutSyncing:
)
sync_log = sync_log_result.scalar_one_or_none()
assert sync_log is not None
assert sync_log.status == 'success'
assert sync_log.status == GarminSyncStatus.COMPLETED
assert sync_log.activities_synced == 1
@patch.dict(os.environ, {
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client')
async def test_sync_with_duplicate_activities(self, mock_client_class, workout_sync_service, db_session):
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_sync_with_duplicate_activities(self, mock_client_class, mock_connect_api, workout_sync_service, db_session):
"""Test that duplicate activities are not synced again."""
# First, create an existing workout
existing_workout = Workout(
@@ -182,9 +199,11 @@ class TestWorkoutSyncing:
await db_session.commit()
# Setup mock Garmin client
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=True)
mock_client.save = MagicMock()
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(return_value=True)
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
# Mock activity data (same as existing)
mock_activities = [
@@ -209,12 +228,16 @@ class TestWorkoutSyncing:
'GARMIN_USERNAME': 'invalid@example.com',
'GARMIN_PASSWORD': 'wrongpass'
})
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_sync_with_auth_failure(self, mock_client_class, workout_sync_service, db_session):
async def test_sync_with_auth_failure(self, mock_client_class, mock_connect_api, workout_sync_service, db_session):
"""Test sync failure due to authentication error."""
# Setup mock client to fail authentication
mock_client = MagicMock()
mock_client.login = AsyncMock(side_effect=Exception("Invalid credentials"))
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(side_effect=garminconnect.GarminConnectAuthenticationError("Invalid credentials"))
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
mock_client_class.return_value = mock_client
# Test sync
@@ -227,20 +250,23 @@ class TestWorkoutSyncing:
)
sync_log = sync_log_result.scalar_one_or_none()
assert sync_log is not None
assert sync_log.status == 'auth_error'
assert sync_log.status == GarminSyncStatus.AUTH_FAILED
@patch.dict(os.environ, {
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_sync_with_api_error(self, mock_client_class, workout_sync_service, db_session):
async def test_sync_with_api_error(self, mock_client_class, mock_connect_api, workout_sync_service, db_session):
"""Test sync failure due to API error."""
# Setup mock client
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=True)
mock_client.save = MagicMock()
mock_client.get_activities_by_date = MagicMock(side_effect=Exception("API rate limit exceeded"))
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(return_value=True)
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
mock_connect_api.return_value = {"displayName": "test_user"}
mock_client.get_activities_by_date = MagicMock(side_effect=garminconnect.GarminConnectTooManyRequestsError("API rate limit exceeded"))
mock_client_class.return_value = mock_client
# Test sync
@@ -253,7 +279,7 @@ class TestWorkoutSyncing:
)
sync_log = sync_log_result.scalar_one_or_none()
assert sync_log is not None
assert sync_log.status == 'api_error'
assert sync_log.status == GarminSyncStatus.FAILED
assert 'API rate limit' in sync_log.error_message
@@ -264,13 +290,14 @@ class TestErrorHandling:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin')
async def test_activity_detail_fetch_retry(self, mock_client_class, workout_sync_service, db_session):
async def test_activity_detail_fetch_retry(self, mock_client_class, mock_connect_api, workout_sync_service, db_session):
"""Test retry logic when fetching activity details fails."""
# Setup mock client
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=True)
mock_client.save = MagicMock()
mock_client = MagicMock(spec=Garmin)
mock_client.login = MagicMock(return_value=True)
mock_connect_api.return_value = {"displayName": "test_user"}
mock_activities = [
{

View File

@@ -1,5 +1,5 @@
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from unittest.mock import patch, MagicMock
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import StaticPool
from sqlalchemy import select
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta
import os
from dotenv import load_dotenv
from backend.app.config import Settings
import garminconnect
# --- Completely Rewritten Fixtures ---
@@ -51,11 +52,12 @@ async def db_session(test_engine, setup_database):
@pytest.fixture
def mock_garmin_service():
"""Mock the GarminService for testing."""
"""Mock the GarminConnectService for testing."""
mock_service = MagicMock(spec=GarminService)
mock_service.authenticate = AsyncMock(return_value=True)
mock_service.get_activities = AsyncMock(return_value=[])
mock_service.get_activity_details = AsyncMock(return_value={})
# python-garminconnect.Garmin.login is not async
mock_service.authenticate = MagicMock(return_value=True)
mock_service.get_activities = MagicMock(return_value=[])
mock_service.get_activity_details = MagicMock(return_value={})
return mock_service
@pytest.fixture
@@ -86,18 +88,18 @@ async def test_successful_sync_functional(db_session: AsyncSession, mock_garmin_
{
'activityId': '1001',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': (datetime.now() - timedelta(days=1)).isoformat(),
'startTimeLocal': (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
'duration': 3600,
'distance': 50000,
'averageHR': 150,
'maxHR': 180,
'avgPower': 200,
'elevationGain': 500
'averageHeartRateInBeatsPerMinute': 150, # Adjusted for python-garminconnect
'maxHeartRateInBeatsPerMinute': 180, # Adjusted for python-garminconnect
'averagePower': 200, # Adjusted for python-garminconnect
'totalElevationGain': 500 # Adjusted for python-garminconnect
}
]
mock_garmin_service.get_activity_details.return_value = {
'avgPower': 200,
'elevationGain': 500,
'averagePower': 200,
'totalElevationGain': 500,
'temperature': 25
}
@@ -159,7 +161,7 @@ async def test_sync_with_authentication_error(db_session: AsyncSession, mock_gar
service.garmin_service = mock_garmin_service
# Arrange
mock_garmin_service.get_activities.side_effect = GarminAuthError("Invalid credentials")
mock_garmin_service.authenticate.side_effect = GarminAuthError("Invalid credentials")
# Act & Assert
with pytest.raises(GarminAuthError):
@@ -181,7 +183,7 @@ async def test_sync_with_api_error(db_session: AsyncSession, mock_garmin_service
service.garmin_service = mock_garmin_service
# Arrange
mock_garmin_service.get_activities.side_effect = GarminAPIError("Garmin service unavailable")
mock_garmin_service.get_activities.side_effect = garminconnect.GarminConnectException("Garmin service unavailable")
# Act & Assert
with pytest.raises(GarminAPIError):
@@ -207,22 +209,22 @@ async def test_sync_with_activity_details_retry_success(db_session: AsyncSession
{
'activityId': '1002',
'activityType': {'typeKey': 'running'},
'startTimeLocal': (datetime.now() - timedelta(days=2)).isoformat(),
'startTimeLocal': (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S"),
'duration': 3000,
'distance': 10000
}
]
# First call to get_activity_details fails, second succeeds
mock_garmin_service.get_activity_details.side_effect = [
GarminAPIError("Temporary network issue"),
{'averageHR': 160, 'maxHR': 190}
garminconnect.GarminConnectException("Temporary network issue"),
{'averageHeartRateInBeatsPerMinute': 160, 'maxHeartRateInBeatsPerMinute': 190}
]
# Act
# Mock asyncio.sleep to avoid actual delays during tests
with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
with patch('asyncio.sleep', new_callable=MagicMock) as mock_sleep:
synced_count = await service.sync_recent_activities(days_back=7)
mock_sleep.assert_awaited_with(1) # First retry delay
mock_sleep.assert_called_with(1) # First retry delay
# Assert
assert synced_count == 1
@@ -251,24 +253,24 @@ async def test_sync_with_activity_details_retry_failure(db_session: AsyncSession
{
'activityId': '1003',
'activityType': {'typeKey': 'swimming'},
'startTimeLocal': (datetime.now() - timedelta(days=3)).isoformat(),
'startTimeLocal': (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S"),
'duration': 2000,
'distance': 2000
}
]
# All calls to get_activity_details fail
mock_garmin_service.get_activity_details.side_effect = [
GarminAPIError("Service unavailable"),
GarminAPIError("Service unavailable"),
GarminAPIError("Service unavailable")
garminconnect.GarminConnectException("Service unavailable"),
garminconnect.GarminConnectException("Service unavailable"),
garminconnect.GarminConnectException("Service unavailable")
]
# Act & Assert
with pytest.raises(GarminAPIError), \
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
patch('asyncio.sleep', new_callable=MagicMock) as mock_sleep:
await service.sync_recent_activities(days_back=7)
assert mock_garmin_service.get_activity_details.call_count == 3
mock_sleep.assert_awaited_with(4) # Last retry delay (2**(3-1))
mock_sleep.assert_called_with(4) # Last retry delay (2**(3-1))
# Verify sync log in DB
result = await db_session.execute(select(GarminSyncLog))
@@ -296,12 +298,12 @@ async def test_sync_with_duplicate_activities_in_garmin_feed(db_session: AsyncSe
{
'activityId': '1004',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': (datetime.now() - timedelta(days=4)).isoformat(),
'startTimeLocal': (datetime.now() - timedelta(days=4)).strftime("%Y-%m-%d %H:%M:%S"),
'duration': 4000,
'distance': 60000
}
]
mock_garmin_service.get_activity_details.return_value = {'averageHR': 140}
mock_garmin_service.get_activity_details.return_value = {'averageHeartRateInBeatsPerMinute': 140}
await service.sync_recent_activities(days_back=7)
# Second sync: activity 1004 is present again, plus a new activity 1005
@@ -309,19 +311,19 @@ async def test_sync_with_duplicate_activities_in_garmin_feed(db_session: AsyncSe
{
'activityId': '1004',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': (datetime.now() - timedelta(days=4)).isoformat(),
'startTimeLocal': (datetime.now() - timedelta(days=4)).strftime("%Y-%m-%d %H:%M:%S"),
'duration': 4000,
'distance': 60000
},
{
'activityId': '1005',
'activityType': {'typeKey': 'running'},
'startTimeLocal': (datetime.now() - timedelta(days=5)).isoformat(),
'startTimeLocal': (datetime.now() - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S"),
'duration': 2500,
'distance': 5000
}
]
mock_garmin_service.get_activity_details.return_value = {'averageHR': 130} # for activity 1005
mock_garmin_service.get_activity_details.return_value = {'averageHeartRateInBeatsPerMinute': 130} # for activity 1005
# Act
synced_count = await service.sync_recent_activities(days_back=7)
@@ -349,6 +351,8 @@ async def test_garmin_sync_with_real_creds(db_session: AsyncSession, real_garmin
# Arrange
service = WorkoutSyncService(db=db_session)
service.garmin_service = real_garmin_service
# Ensure garmin_service is authenticated for real tests
await real_garmin_service.authenticate()
# Act
# We sync the last 1 day to keep the test fast

View File

@@ -1,233 +1,232 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.services.garmin import GarminAPIError, GarminAuthError
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime, timedelta
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
import garminconnect # Import garminconnect to resolve NameError
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.services.garmin import GarminConnectService, GarminAuthError, GarminAPIError
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog, GarminSyncStatus
@pytest.fixture
def mock_garmin_service():
"""Fixture to provide a mock GarminConnectService."""
service = MagicMock(spec=GarminConnectService)
service.authenticate = AsyncMock(return_value=True)
service.get_activities = AsyncMock(return_value=[])
service.get_activity_details = AsyncMock(return_value={})
return service
@pytest.fixture
def mock_db_session():
"""Fixture to provide an AsyncMock for the database session."""
mock_db = MagicMock(spec=AsyncSession)
mock_db.add = MagicMock()
mock_db.commit = AsyncMock() # Must be AsyncMock as it's awaited
mock_db.refresh = AsyncMock() # Must be AsyncMock as it's awaited
# Configure execute to return a mock result object that can be awaited
mock_result_proxy = MagicMock()
mock_result_proxy.scalar_one_or_none = AsyncMock(return_value=None) # scalar_one_or_none is awaited
mock_db.execute = AsyncMock(return_value=mock_result_proxy) # Must be AsyncMock as it's awaited
return mock_db
@pytest.mark.asyncio
async def test_successful_sync():
async def test_successful_sync(mock_db_session, mock_garmin_service):
"""Test successful sync of new activities"""
# Create proper async mock for database session
mock_db = AsyncMock()
mock_db.add = MagicMock() # add is synchronous
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
# Mock the activity_exists check to return False (no duplicates)
mock_db.execute = AsyncMock()
mock_db.execute.return_value.scalar_one_or_none = AsyncMock(return_value=None)
service = WorkoutSyncService(mock_db)
# Mock the garmin service methods
service.garmin_service.get_activities = AsyncMock(return_value=[
{
'activityId': '123456',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15T08:00:00Z',
'duration': 3600,
'distance': 25000
}
])
service.garmin_service.get_activity_details = AsyncMock(return_value={
'averageHR': 150,
'maxHR': 180,
'avgPower': 250,
'elevationGain': 500
})
result = await service.sync_recent_activities(days_back=7)
assert result == 1
assert mock_db.add.call_count >= 2 # sync_log and workout
mock_db.commit.assert_awaited()
# Configure mock_db_session.execute for activity_exists check
mock_db_session.execute.return_value.scalar_one_or_none.return_value = None # No duplicates
@pytest.mark.asyncio
async def test_duplicate_activity_handling():
"""Test skipping duplicate activities"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
# Mock activity_exists to return True (activity exists)
service.activity_exists = AsyncMock(return_value=True)
service.garmin_service.get_activities = AsyncMock(return_value=[
{'activityId': '123456', 'startTimeLocal': '2024-01-15T08:00:00Z'}
])
result = await service.sync_recent_activities()
assert result == 0 # No new activities synced
mock_db.commit.assert_awaited()
# Patch GarminService to return our mock_garmin_service
with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
@pytest.mark.asyncio
async def test_activity_detail_retry_logic():
"""Test retry logic for activity details"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
service.activity_exists = AsyncMock(return_value=False)
service.garmin_service.get_activities = AsyncMock(return_value=[
{
'activityId': '123456',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15T08:00:00Z',
'duration': 3600
}
])
# First call fails, second succeeds
service.garmin_service.get_activity_details = AsyncMock(
side_effect=[
GarminAPIError("Temporary failure"),
{'averageHR': 150, 'maxHR': 180}
# Mock the garmin service methods
mock_garmin_service.get_activities.return_value = [
{
'activityId': '123456',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15 08:00:00', # Adjusted format
'duration': 3600,
'distance': 25000
}
]
)
# Mock asyncio.sleep to avoid actual delays in tests
with patch('asyncio.sleep', new_callable=AsyncMock):
mock_garmin_service.get_activity_details.return_value = {
'averageHeartRateInBeatsPerMinute': 150,
'maxHeartRateInBeatsPerMinute': 180,
'averagePower': 250,
'totalElevationGain': 500
}
result = await service.sync_recent_activities(days_back=7)
assert result == 1
mock_db_session.add.assert_called()
mock_db_session.commit.assert_called()
mock_db_session.refresh.assert_called()
mock_garmin_service.authenticate.assert_called_once()
mock_garmin_service.get_activities.assert_called_once()
mock_garmin_service.get_activity_details.assert_called_once()
@pytest.mark.asyncio
async def test_duplicate_activity_handling(mock_db_session, mock_garmin_service):
"""Test skipping duplicate activities"""
# Configure mock_db_session.execute for activity_exists check
mock_db_session.execute.return_value.scalar_one_or_none.return_value = MagicMock(spec=Workout) # Simulate activity exists
with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
mock_garmin_service.get_activities.return_value = [
{'activityId': '123456', 'startTimeLocal': '2024-01-15 08:00:00'}
]
result = await service.sync_recent_activities()
assert service.garmin_service.get_activity_details.call_count == 2
assert result == 1
assert result == 0
mock_db_session.add.assert_called_once() # Should add the sync log
mock_db_session.commit.assert_called() # Commit for sync log update
mock_garmin_service.authenticate.assert_called_once()
mock_garmin_service.get_activities.assert_called_once()
mock_garmin_service.get_activity_details.assert_not_called()
@pytest.mark.asyncio
async def test_auth_error_handling():
async def test_activity_detail_retry_logic(mock_db_session, mock_garmin_service):
"""Test retry logic for activity details"""
mock_db_session.execute.return_value.scalar_one_or_none.return_value = None # No duplicates
with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
service.garmin_service.get_activities.return_value = [
{
'activityId': '123456',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15 08:00:00',
'duration': 3600
}
]
# First call fails, second succeeds
mock_garmin_service.get_activity_details.side_effect = [
GarminAPIError("Temporary failure"),
{'averageHeartRateInBeatsPerMinute': 150, 'maxHeartRateInBeatsPerMinute': 180}
]
# Mock asyncio.sleep to avoid actual delays in tests
with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
result = await service.sync_recent_activities()
assert result == 1
assert mock_garmin_service.get_activity_details.call_count == 2
mock_sleep.assert_called_once_with(1) # First retry delay
@pytest.mark.asyncio
async def test_auth_error_handling(mock_db_session, mock_garmin_service):
"""Test authentication error handling"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
# Mock authentication failure
service.garmin_service.get_activities = AsyncMock(
side_effect=GarminAuthError("Authentication failed")
)
with pytest.raises(GarminAuthError):
await service.sync_recent_activities()
# Check that sync log was created with auth error status
sync_log_calls = [call for call in mock_db.add.call_args_list
if isinstance(call[0][0], GarminSyncLog)]
assert len(sync_log_calls) >= 1
sync_log = sync_log_calls[0][0][0]
assert sync_log.status == "auth_error"
with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
# Mock authentication failure. The authenticate method is called before get_activities
mock_garmin_service.authenticate.side_effect = GarminAuthError("Authentication failed")
with pytest.raises(GarminAuthError):
await service.sync_recent_activities()
mock_db_session.add.assert_called_once() # Should add the sync log
assert mock_db_session.commit.call_count == 2 # Initial commit and commit after updating status
mock_db_session.refresh.assert_called_once() # Initial refresh
@pytest.mark.asyncio
async def test_api_error_handling():
async def test_api_error_handling(mock_db_session, mock_garmin_service):
"""Test API error handling"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
service.garmin_service.get_activities = AsyncMock(
side_effect=GarminAPIError("API rate limit exceeded")
)
with pytest.raises(GarminAPIError):
await service.sync_recent_activities()
# Check sync log status
sync_log_calls = [call for call in mock_db.add.call_args_list
if isinstance(call[0][0], GarminSyncLog)]
sync_log = sync_log_calls[0][0][0]
assert sync_log.status == "api_error"
assert "rate limit" in sync_log.error_message.lower()
with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
mock_garmin_service.get_activities.side_effect = GarminAPIError("API rate limit exceeded")
with pytest.raises(GarminAPIError):
await service.sync_recent_activities()
mock_db_session.add.assert_called_once() # Should add the sync log
assert mock_db_session.commit.call_count == 2 # Initial commit and commit after updating status
mock_db_session.refresh.assert_called_once() # Initial refresh
@pytest.mark.asyncio
async def test_get_sync_status():
async def test_get_sync_status(mock_db_session):
"""Test retrieval of latest sync status"""
mock_db = AsyncMock()
mock_log = GarminSyncLog(
status="success",
status=GarminSyncStatus.COMPLETED,
activities_synced=5,
last_sync_time=datetime.now()
)
# Mock the database query
mock_result = AsyncMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_log)
mock_db.execute = AsyncMock(return_value=mock_result)
service = WorkoutSyncService(mock_db)
# Mock the database query for a single result
mock_result = MagicMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_log) # scalar_one_or_none is awaited
mock_db_session.execute.return_value = mock_result # mock_db_session.execute is awaited
service = WorkoutSyncService(mock_db_session)
result = await service.get_latest_sync_status()
assert result.status == "success"
assert result.activities_synced == 5
mock_db.execute.assert_awaited_once()
assert result == mock_log
mock_db_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_activity_exists_check():
async def test_activity_exists_check(mock_db_session):
"""Test the activity_exists helper method"""
mock_db = AsyncMock()
# Mock existing activity
mock_workout = Workout(garmin_activity_id="123456")
mock_result = AsyncMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_workout)
mock_db.execute = AsyncMock(return_value=mock_result)
service = WorkoutSyncService(mock_db)
mock_result = MagicMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_workout) # scalar_one_or_none is awaited
mock_db_session.execute.return_value = mock_result # mock_db_session.execute is awaited
service = WorkoutSyncService(mock_db_session)
exists = await service.activity_exists("123456")
assert exists is True
mock_db.execute.assert_awaited_once()
mock_db_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_activity_does_not_exist():
async def test_activity_does_not_exist(mock_db_session):
"""Test activity_exists when activity doesn't exist"""
mock_db = AsyncMock()
# Mock no existing activity
mock_result = AsyncMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=None)
mock_db.execute = AsyncMock(return_value=mock_result)
service = WorkoutSyncService(mock_db)
mock_result = MagicMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=None) # scalar_one_or_none is awaited
mock_db_session.execute.return_value = mock_result # mock_db_session.execute is awaited
service = WorkoutSyncService(mock_db_session)
exists = await service.activity_exists("nonexistent")
assert exists is False
mock_db_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_parse_activity_data():
async def test_parse_activity_data(mock_db_session):
"""Test parsing of Garmin activity data"""
mock_db = AsyncMock()
service = WorkoutSyncService(mock_db)
service = WorkoutSyncService(mock_db_session)
activity_data = {
'activityId': '987654321',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15T08:30:00Z',
'startTimeLocal': '2024-01-15 08:30:00', # Adjusted format
'duration': 7200,
'distance': 50000,
'averageHR': 145,
'maxHR': 175,
'avgPower': 230,
'maxPower': 450,
'averageHR': 145, # Corrected key
'maxHR': 175, # Corrected key
'avgPower': 230, # Corrected key
'maxPower': 450, # Corrected key
'averageBikingCadenceInRevPerMinute': 85,
'elevationGain': 800
'elevationGain': 800 # Corrected key
}
result = await service.parse_activity_data(activity_data)
assert result['garmin_activity_id'] == '987654321'
assert result['activity_type'] == 'cycling'
assert result['duration_seconds'] == 7200
@@ -238,29 +237,21 @@ async def test_parse_activity_data():
assert result['max_power'] == 450
assert result['avg_cadence'] == 85
assert result['elevation_gain_m'] == 800
assert result['metrics'] == activity_data # Full data stored as JSONB
@pytest.mark.asyncio
async def test_sync_with_network_timeout():
async def test_sync_with_network_timeout(mock_db_session, mock_garmin_service):
"""Test handling of network timeouts during sync"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
# Simulate timeout error
import asyncio
service.garmin_service.get_activities = AsyncMock(
side_effect=asyncio.TimeoutError("Request timed out")
)
with pytest.raises(Exception): # Should raise the timeout error
await service.sync_recent_activities()
# Verify error was logged
sync_log_calls = [call for call in mock_db.add.call_args_list
if isinstance(call[0][0], GarminSyncLog)]
sync_log = sync_log_calls[0][0][0]
assert sync_log.status == "error"
mock_db_session.execute.return_value.scalar_one_or_none.return_value = None # No duplicates
with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
# Simulate timeout error
mock_garmin_service.get_activities.side_effect = garminconnect.GarminConnectConnectionError("Request timed out")
with pytest.raises(GarminAPIError): # Should raise the GarminAPIError
await service.sync_recent_activities()
mock_db_session.add.assert_called_once() # Should add the sync log
assert mock_db_session.commit.call_count == 2 # Initial commit and commit after updating status
mock_db_session.refresh.assert_called_once() # Initial refresh

View File

@@ -32,6 +32,7 @@ from tui.views.rules import RuleView
from tui.views.routes import RouteView
from backend.app.database import AsyncSessionLocal
from tui.services.workout_service import WorkoutService
from backend.app.services.workout_sync import WorkoutSyncService
class CyclingCoachApp(App):
@@ -208,8 +209,8 @@ async def sync_garmin_activities_cli():
logger.info("Starting Garmin activity sync...")
async with AsyncSessionLocal() as db:
workout_service = WorkoutService(db)
await workout_service.sync_garmin_activities()
workout_sync_service = WorkoutSyncService(db)
await workout_sync_service.sync_recent_activities()
logger.info("Garmin activity sync completed successfully.")
except Exception as e:
logger.error(f"Error during Garmin activity sync: {e}")