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) start_date = datetime.now() - timedelta(days=days_back)
logger.debug(f"Fetching activities from Garmin starting from: {start_date}") 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( activities = await self.garmin_service.get_activities(
limit=50, start_date=start_date, end_date=datetime.now() limit=50, start_date=start_date, end_date=datetime.now()
) )
@@ -109,7 +110,8 @@ class WorkoutSyncService:
sync_log.status = GarminSyncStatus.FAILED sync_log.status = GarminSyncStatus.FAILED
sync_log.error_message = str(e) sync_log.error_message = str(e)
await self.db.commit() 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): async def get_latest_sync_status(self):
"""Get the most recent sync log entry.""" """Get the most recent sync log entry."""
@@ -119,7 +121,7 @@ class WorkoutSyncService:
.order_by(desc(GarminSyncLog.created_at)) .order_by(desc(GarminSyncLog.created_at))
.limit(1) .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'}") logger.debug(f"Latest sync status: {status.status if status else 'None'}")
return status return status
@@ -129,7 +131,7 @@ class WorkoutSyncService:
result = await self.db.execute( result = await self.db.execute(
select(Workout).where(Workout.garmin_activity_id == garmin_activity_id) 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}") logger.debug(f"Activity {garmin_activity_id} exists: {exists}")
return exists return exists

View File

@@ -1,130 +1,237 @@
import os import os
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import MagicMock, patch, call
import pytest 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 backend.app.models.garmin_sync_log import GarminSyncStatus
from datetime import datetime, timedelta from datetime import datetime, timedelta
import garth # Import garth for type hinting from garminconnect import Garmin, GarminConnectAuthenticationError, GarminConnectTooManyRequestsError, GarminConnectConnectionError
@pytest.fixture @pytest.fixture
def mock_env_vars(): def mock_env_vars():
with patch.dict(os.environ, {"GARMIN_USERNAME": "test_user", "GARMIN_PASSWORD": "test_password"}): with patch.dict(os.environ, {"GARMIN_USERNAME": "test_user", "GARMIN_PASSWORD": "test_password"}):
yield yield
def create_garmin_client_mock(): def _create_garmin_client_mock():
mock_client_instance = MagicMock(spec=GarminService) # Use GarminService (which is GarminConnectService) mock_client_instance = MagicMock(spec=Garmin)
mock_client_instance.authenticate = AsyncMock(return_value=True) mock_client_instance.get_activities_by_date = MagicMock(return_value=[])
mock_client_instance.get_activities = AsyncMock(return_value=[]) mock_client_instance.get_activity_details = MagicMock(return_value={})
mock_client_instance.get_activity_details = AsyncMock(return_value={})
mock_client_instance.is_authenticated = MagicMock(return_value=True) # Mock garth.dump for saving session
mock_garth = MagicMock()
mock_garth.dump = MagicMock()
mock_client_instance.garth = mock_garth
return mock_client_instance return mock_client_instance
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_authentication_success(db_session, mock_env_vars): async def test_garmin_authentication_success(db_session, mock_env_vars):
"""Test successful Garmin Connect authentication""" """Test successful Garmin Connect authentication"""
with patch('backend.app.services.garmin.garth.Client') as mock_client_class: mock_client = _create_garmin_client_mock()
mock_instance = mock_client_class.return_value mock_client.login.side_effect = [FileNotFoundError(), None] # Session load fails, fresh login succeeds
mock_instance.load.side_effect = FileNotFoundError mock_client.garth.dump.return_value = None # Ensure dump also returns None
service = GarminService(db_session)
# 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() result = await service.authenticate()
assert result is True 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 @pytest.mark.asyncio
async def test_garmin_authentication_failure(db_session, mock_env_vars): async def test_garmin_authentication_failure(db_session, mock_env_vars):
"""Test authentication failure handling""" """Test authentication failure handling"""
with patch('backend.app.services.garmin.garth.Client') as mock_client_class: mock_client = _create_garmin_client_mock()
mock_instance = mock_client_class.return_value mock_client.login.side_effect = [FileNotFoundError(), GarminConnectAuthenticationError("Invalid credentials")] # Session load fails, fresh login fails
mock_instance.load.side_effect = FileNotFoundError
mock_instance.login.side_effect = Exception("Invalid credentials") mock_path_instance = MagicMock()
service = GarminService(db_session) 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): with pytest.raises(GarminAuthError):
await service.authenticate() 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 @pytest.mark.asyncio
async def test_garmin_authentication_load_session_success(db_session, mock_env_vars): async def test_garmin_authentication_load_session_success(db_session, mock_env_vars):
"""Test successful loading of existing Garmin session""" """Test successful loading of existing Garmin session"""
with patch('backend.app.services.garmin.garth.Client') as mock_client_class: mock_client = _create_garmin_client_mock()
mock_instance = mock_client_class.return_value mock_client.login.return_value = None # Session load succeeds (only one call expected)
mock_instance.load.side_effect = None
service = GarminService(db_session) 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() result = await service.authenticate()
assert result is True assert result is True
mock_instance.load.assert_called_once_with(service.session_dir) mock_garmin_class.assert_called_once_with() # Ensure Garmin() is called once without args
mock_instance.login.assert_not_called() mock_path_constructor.assert_called_once_with("data/sessions")
mock_instance.save.assert_not_called() 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 @pytest.mark.asyncio
async def test_garmin_authentication_missing_credentials(db_session): async def test_garmin_authentication_missing_credentials(db_session):
"""Test authentication failure when credentials are missing""" """Test authentication failure when credentials are missing"""
with patch.dict(os.environ, {"GARMIN_USERNAME": "", "GARMIN_PASSWORD": ""}): mock_path_instance = MagicMock()
with patch('backend.app.services.garmin.garth.Client') as mock_client_class: mock_path_instance.exists.return_value = True
mock_instance = mock_client_class.return_value mock_path_instance.__str__.return_value = "data/sessions"
mock_instance.load.side_effect = FileNotFoundError mock_path_instance.mkdir.return_value = None
service = GarminService(db_session)
with pytest.raises(GarminAuthError, match="Garmin username or password not configured."): with patch.dict(os.environ, {"GARMIN_USERNAME": "", "GARMIN_PASSWORD": ""}), \
await service.authenticate() patch('backend.app.services.garmin.Path', new_callable=MagicMock) as mock_path_constructor:
mock_instance.login.assert_not_called() mock_path_constructor.return_value = mock_path_instance
mock_instance.save.assert_not_called() 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 @pytest.mark.asyncio
async def test_activity_sync(db_session, mock_env_vars): async def test_activity_sync(db_session, mock_env_vars):
"""Test successful activity synchronization""" """Test successful activity synchronization"""
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class: mock_client = _create_garmin_client_mock()
mock_instance = mock_client_class.return_value mock_client.get_activities_by_date.return_value = [
mock_instance.get_activities.return_value = [ {"activityId": 123, "startTimeLocal": "2024-01-01 08:00:00"}
{"activityId": 123, "startTime": "2024-01-01T08:00:00"} ]
] mock_path_instance = MagicMock()
service = GarminService(db_session) mock_path_instance.exists.return_value = True
service.client = mock_instance mock_path_instance.__str__.return_value = "data/sessions"
activities = await service.get_activities() 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 len(activities) == 1
assert activities[0]["activityId"] == 123 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 @pytest.mark.asyncio
async def test_rate_limiting_handling(db_session, mock_env_vars): async def test_rate_limiting_handling(db_session, mock_env_vars):
"""Test API rate limit error handling""" """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_client = _create_garmin_client_mock()
mock_instance = mock_client_class.return_value mock_client.get_activities_by_date.side_effect = GarminConnectTooManyRequestsError("Rate limit exceeded")
mock_instance.get_activities.side_effect = Exception("Rate limit exceeded") mock_path_instance = MagicMock()
service = GarminService(db_session) mock_path_instance.exists.return_value = True
service.client = mock_instance 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): with pytest.raises(GarminAPIError):
await service.get_activities() await service.get_activities(datetime(2024, 1, 1), datetime(2024, 1, 1))
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 @pytest.mark.asyncio
async def test_get_activity_details_success(db_session, mock_env_vars): async def test_get_activity_details_success(db_session, mock_env_vars):
"""Test successful retrieval of activity details.""" """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_client = _create_garmin_client_mock()
mock_instance = mock_client_class.return_value mock_client.get_activity_details.return_value = {"activityId": 123, "details": "data"}
mock_instance.get_activity.return_value = {"activityId": 123, "details": "data"} mock_path_instance = MagicMock()
service = GarminService(db_session) mock_path_instance.exists.return_value = True
service.client = mock_instance 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") details = await service.get_activity_details("123")
assert details["activityId"] == 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 @pytest.mark.asyncio
async def test_get_activity_details_failure(db_session, mock_env_vars): async def test_get_activity_details_failure(db_session, mock_env_vars):
"""Test failure in retrieving activity details.""" """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_client = _create_garmin_client_mock()
mock_instance = mock_client_class.return_value mock_client.get_activity_details.side_effect = GarminConnectConnectionError("Activity not found")
mock_instance.get_activity.side_effect = Exception("Activity not found") mock_path_instance = MagicMock()
service = GarminService(db_session) mock_path_instance.exists.return_value = True
service.client = mock_instance 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"): with pytest.raises(GarminAPIError, match="Failed to fetch activity details"):
await service.get_activity_details("123") 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 @pytest.mark.asyncio
async def test_is_authenticated(db_session): async def test_is_authenticated(db_session):
"""Test is_authenticated method""" """Test is_authenticated method"""
service = GarminService(db_session) service = GarminConnectService(db_session)
assert service.is_authenticated() is False assert service.client is None
service.client = MagicMock() assert service.username is None
assert service.is_authenticated() is True 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 pytest
import os import os
from unittest.mock import AsyncMock, patch, MagicMock from unittest.mock import patch, MagicMock, AsyncMock
import garminconnect
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select 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.garmin import GarminConnectService as GarminService, GarminAuthError, GarminAPIError
from backend.app.services.workout_sync import WorkoutSyncService from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.models.workout import Workout 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 @pytest.fixture
@@ -36,33 +38,40 @@ class TestGarminAuthentication:
'GARMIN_USERNAME': 'test@example.com', 'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123' 'GARMIN_PASSWORD': 'testpass123'
}) })
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin') @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.""" """Test successful authentication with valid credentials."""
# Setup mock client # Setup mock client
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(return_value=(None, None)) mock_client.login = MagicMock(return_value=True) # login is not async in python-garminconnect
mock_client.save = MagicMock()
mock_client_class.return_value = mock_client 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 # Test authentication
result = await garmin_service.authenticate() result = await garmin_service.authenticate()
assert result is True assert result is True
mock_client.login.assert_awaited_once_with('test@example.com', 'testpass123') mock_client.login.assert_called_once()
mock_client.save.assert_called_once()
@patch.dict(os.environ, { @patch.dict(os.environ, {
'GARMIN_USERNAME': 'invalid@example.com', 'GARMIN_USERNAME': 'invalid@example.com',
'GARMIN_PASSWORD': 'wrongpass' 'GARMIN_PASSWORD': 'wrongpass'
}) })
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin') @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.""" """Test authentication failure with invalid credentials."""
# Setup mock client to raise exception # Setup mock client to raise exception
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(side_effect=Exception("Invalid credentials")) mock_client.login = MagicMock(side_effect=garminconnect.GarminConnectAuthenticationError("Invalid credentials"))
mock_client_class.return_value = mock_client 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 # Test authentication
with pytest.raises(GarminAuthError, match="Authentication failed"): with pytest.raises(GarminAuthError, match="Authentication failed"):
@@ -72,20 +81,24 @@ class TestGarminAuthentication:
'GARMIN_USERNAME': 'test@example.com', 'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123' 'GARMIN_PASSWORD': 'testpass123'
}) })
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin') @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.""" """Test that existing sessions are reused."""
# Setup mock client with load method # Setup mock client. python-garminconnect handles session loading internally
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(return_value=(None, None)) # Login handles loading from tokenstore mock_client.login = MagicMock(return_value=True) # login is not async
mock_client_class.return_value = mock_client 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 # Test authentication
result = await garmin_service.authenticate() result = await garmin_service.authenticate()
assert result is True assert result is True
mock_client.login.assert_awaited_once_with(tokenstore=garmin_service.session_dir) # python-garminconnect will attempt to load a session before logging in
mock_client.save.assert_not_called() mock_client.login.assert_called_once()
class TestWorkoutSyncing: class TestWorkoutSyncing:
@@ -95,13 +108,16 @@ class TestWorkoutSyncing:
'GARMIN_USERNAME': 'test@example.com', 'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123' 'GARMIN_PASSWORD': 'testpass123'
}) })
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin') @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.""" """Test successful synchronization of recent activities."""
# Setup mock Garmin client # Setup mock Garmin client
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(return_value=True) mock_client.login = MagicMock(return_value=True)
mock_client.save = MagicMock() 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 activity data
mock_activities = [ mock_activities = [
@@ -160,15 +176,16 @@ class TestWorkoutSyncing:
) )
sync_log = sync_log_result.scalar_one_or_none() sync_log = sync_log_result.scalar_one_or_none()
assert sync_log is not None assert sync_log is not None
assert sync_log.status == 'success' assert sync_log.status == GarminSyncStatus.COMPLETED
assert sync_log.activities_synced == 1 assert sync_log.activities_synced == 1
@patch.dict(os.environ, { @patch.dict(os.environ, {
'GARMIN_USERNAME': 'test@example.com', 'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123' 'GARMIN_PASSWORD': 'testpass123'
}) })
@patch('garth.Client') @patch('garth.Client.connectapi')
async def test_sync_with_duplicate_activities(self, mock_client_class, workout_sync_service, db_session): @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.""" """Test that duplicate activities are not synced again."""
# First, create an existing workout # First, create an existing workout
existing_workout = Workout( existing_workout = Workout(
@@ -182,9 +199,11 @@ class TestWorkoutSyncing:
await db_session.commit() await db_session.commit()
# Setup mock Garmin client # Setup mock Garmin client
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(return_value=True) mock_client.login = MagicMock(return_value=True)
mock_client.save = MagicMock() 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 activity data (same as existing)
mock_activities = [ mock_activities = [
@@ -209,12 +228,16 @@ class TestWorkoutSyncing:
'GARMIN_USERNAME': 'invalid@example.com', 'GARMIN_USERNAME': 'invalid@example.com',
'GARMIN_PASSWORD': 'wrongpass' 'GARMIN_PASSWORD': 'wrongpass'
}) })
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin') @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.""" """Test sync failure due to authentication error."""
# Setup mock client to fail authentication # Setup mock client to fail authentication
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(side_effect=Exception("Invalid credentials")) 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 mock_client_class.return_value = mock_client
# Test sync # Test sync
@@ -227,20 +250,23 @@ class TestWorkoutSyncing:
) )
sync_log = sync_log_result.scalar_one_or_none() sync_log = sync_log_result.scalar_one_or_none()
assert sync_log is not None assert sync_log is not None
assert sync_log.status == 'auth_error' assert sync_log.status == GarminSyncStatus.AUTH_FAILED
@patch.dict(os.environ, { @patch.dict(os.environ, {
'GARMIN_USERNAME': 'test@example.com', 'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123' 'GARMIN_PASSWORD': 'testpass123'
}) })
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin') @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.""" """Test sync failure due to API error."""
# Setup mock client # Setup mock client
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(return_value=True) mock_client.login = MagicMock(return_value=True)
mock_client.save = MagicMock() mock_connect_api.return_value = {"displayName": "test_user"}
mock_client.get_activities_by_date = MagicMock(side_effect=Exception("API rate limit exceeded")) 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 mock_client_class.return_value = mock_client
# Test sync # Test sync
@@ -253,7 +279,7 @@ class TestWorkoutSyncing:
) )
sync_log = sync_log_result.scalar_one_or_none() sync_log = sync_log_result.scalar_one_or_none()
assert sync_log is not 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 assert 'API rate limit' in sync_log.error_message
@@ -264,13 +290,14 @@ class TestErrorHandling:
'GARMIN_USERNAME': 'test@example.com', 'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123' 'GARMIN_PASSWORD': 'testpass123'
}) })
@patch('garth.Client.connectapi')
@patch('garminconnect.Garmin') @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.""" """Test retry logic when fetching activity details fails."""
# Setup mock client # Setup mock client
mock_client = MagicMock() mock_client = MagicMock(spec=Garmin)
mock_client.login = AsyncMock(return_value=True) mock_client.login = MagicMock(return_value=True)
mock_client.save = MagicMock() mock_connect_api.return_value = {"displayName": "test_user"}
mock_activities = [ mock_activities = [
{ {

View File

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

View File

@@ -1,229 +1,228 @@
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import MagicMock, patch, AsyncMock
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 datetime import datetime, timedelta 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 @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""" """Test successful sync of new activities"""
# Create proper async mock for database session # Configure mock_db_session.execute for activity_exists check
mock_db = AsyncMock() mock_db_session.execute.return_value.scalar_one_or_none.return_value = None # No duplicates
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) # Patch GarminService to return our mock_garmin_service
mock_db.execute = AsyncMock() with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
mock_db.execute.return_value.scalar_one_or_none = AsyncMock(return_value=None) service = WorkoutSyncService(mock_db_session)
service = WorkoutSyncService(mock_db) # Mock the garmin service methods
mock_garmin_service.get_activities.return_value = [
# Mock the garmin service methods {
service.garmin_service.get_activities = AsyncMock(return_value=[ 'activityId': '123456',
{ 'activityType': {'typeKey': 'cycling'},
'activityId': '123456', 'startTimeLocal': '2024-01-15 08:00:00', # Adjusted format
'activityType': {'typeKey': 'cycling'}, 'duration': 3600,
'startTimeLocal': '2024-01-15T08:00:00Z', 'distance': 25000
'duration': 3600, }
'distance': 25000 ]
}
]) mock_garmin_service.get_activity_details.return_value = {
'averageHeartRateInBeatsPerMinute': 150,
service.garmin_service.get_activity_details = AsyncMock(return_value={ 'maxHeartRateInBeatsPerMinute': 180,
'averageHR': 150, 'averagePower': 250,
'maxHR': 180, 'totalElevationGain': 500
'avgPower': 250, }
'elevationGain': 500
}) result = await service.sync_recent_activities(days_back=7)
result = await service.sync_recent_activities(days_back=7) assert result == 1
mock_db_session.add.assert_called()
assert result == 1 mock_db_session.commit.assert_called()
assert mock_db.add.call_count >= 2 # sync_log and workout mock_db_session.refresh.assert_called()
mock_db.commit.assert_awaited() mock_garmin_service.authenticate.assert_called_once()
mock_garmin_service.get_activities.assert_called_once()
@pytest.mark.asyncio mock_garmin_service.get_activity_details.assert_called_once()
async def test_duplicate_activity_handling():
"""Test skipping duplicate activities""" @pytest.mark.asyncio
mock_db = AsyncMock() async def test_duplicate_activity_handling(mock_db_session, mock_garmin_service):
mock_db.add = MagicMock() """Test skipping duplicate activities"""
mock_db.commit = AsyncMock() # Configure mock_db_session.execute for activity_exists check
mock_db.refresh = AsyncMock() mock_db_session.execute.return_value.scalar_one_or_none.return_value = MagicMock(spec=Workout) # Simulate activity exists
service = WorkoutSyncService(mock_db) with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
# Mock activity_exists to return True (activity exists)
service.activity_exists = AsyncMock(return_value=True) mock_garmin_service.get_activities.return_value = [
{'activityId': '123456', 'startTimeLocal': '2024-01-15 08:00:00'}
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()
@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 asyncio.sleep to avoid actual delays in tests
with patch('asyncio.sleep', new_callable=AsyncMock):
result = await service.sync_recent_activities() result = await service.sync_recent_activities()
assert service.garmin_service.get_activity_details.call_count == 2 assert result == 0
assert result == 1 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 @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""" """Test authentication error handling"""
mock_db = AsyncMock() with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
mock_db.add = MagicMock() service = WorkoutSyncService(mock_db_session)
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db) # Mock authentication failure. The authenticate method is called before get_activities
mock_garmin_service.authenticate.side_effect = GarminAuthError("Authentication failed")
# Mock authentication failure with pytest.raises(GarminAuthError):
service.garmin_service.get_activities = AsyncMock( await service.sync_recent_activities()
side_effect=GarminAuthError("Authentication failed")
)
with pytest.raises(GarminAuthError): mock_db_session.add.assert_called_once() # Should add the sync log
await service.sync_recent_activities() assert mock_db_session.commit.call_count == 2 # Initial commit and commit after updating status
mock_db_session.refresh.assert_called_once() # Initial refresh
# 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"
@pytest.mark.asyncio @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""" """Test API error handling"""
mock_db = AsyncMock() with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
mock_db.add = MagicMock() service = WorkoutSyncService(mock_db_session)
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db) mock_garmin_service.get_activities.side_effect = GarminAPIError("API rate limit exceeded")
service.garmin_service.get_activities = AsyncMock( with pytest.raises(GarminAPIError):
side_effect=GarminAPIError("API rate limit exceeded") await service.sync_recent_activities()
)
with pytest.raises(GarminAPIError): mock_db_session.add.assert_called_once() # Should add the sync log
await service.sync_recent_activities() assert mock_db_session.commit.call_count == 2 # Initial commit and commit after updating status
mock_db_session.refresh.assert_called_once() # Initial refresh
# 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()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_sync_status(): async def test_get_sync_status(mock_db_session):
"""Test retrieval of latest sync status""" """Test retrieval of latest sync status"""
mock_db = AsyncMock()
mock_log = GarminSyncLog( mock_log = GarminSyncLog(
status="success", status=GarminSyncStatus.COMPLETED,
activities_synced=5, activities_synced=5,
last_sync_time=datetime.now() last_sync_time=datetime.now()
) )
# Mock the database query # Mock the database query for a single result
mock_result = AsyncMock() mock_result = MagicMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_log) mock_result.scalar_one_or_none = AsyncMock(return_value=mock_log) # scalar_one_or_none is awaited
mock_db.execute = AsyncMock(return_value=mock_result) mock_db_session.execute.return_value = mock_result # mock_db_session.execute is awaited
service = WorkoutSyncService(mock_db) service = WorkoutSyncService(mock_db_session)
result = await service.get_latest_sync_status() result = await service.get_latest_sync_status()
assert result.status == "success" assert result == mock_log
assert result.activities_synced == 5 mock_db_session.execute.assert_called_once()
mock_db.execute.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_activity_exists_check(): async def test_activity_exists_check(mock_db_session):
"""Test the activity_exists helper method""" """Test the activity_exists helper method"""
mock_db = AsyncMock()
# Mock existing activity # Mock existing activity
mock_workout = Workout(garmin_activity_id="123456") mock_workout = Workout(garmin_activity_id="123456")
mock_result = AsyncMock() mock_result = MagicMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_workout) mock_result.scalar_one_or_none = AsyncMock(return_value=mock_workout) # scalar_one_or_none is awaited
mock_db.execute = AsyncMock(return_value=mock_result) mock_db_session.execute.return_value = mock_result # mock_db_session.execute is awaited
service = WorkoutSyncService(mock_db) service = WorkoutSyncService(mock_db_session)
exists = await service.activity_exists("123456") exists = await service.activity_exists("123456")
assert exists is True assert exists is True
mock_db.execute.assert_awaited_once() mock_db_session.execute.assert_called_once()
@pytest.mark.asyncio @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""" """Test activity_exists when activity doesn't exist"""
mock_db = AsyncMock()
# Mock no existing activity # Mock no existing activity
mock_result = AsyncMock() mock_result = MagicMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=None) mock_result.scalar_one_or_none = AsyncMock(return_value=None) # scalar_one_or_none is awaited
mock_db.execute = AsyncMock(return_value=mock_result) mock_db_session.execute.return_value = mock_result # mock_db_session.execute is awaited
service = WorkoutSyncService(mock_db) service = WorkoutSyncService(mock_db_session)
exists = await service.activity_exists("nonexistent") exists = await service.activity_exists("nonexistent")
assert exists is False assert exists is False
mock_db_session.execute.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_parse_activity_data(): async def test_parse_activity_data(mock_db_session):
"""Test parsing of Garmin activity data""" """Test parsing of Garmin activity data"""
mock_db = AsyncMock() service = WorkoutSyncService(mock_db_session)
service = WorkoutSyncService(mock_db)
activity_data = { activity_data = {
'activityId': '987654321', 'activityId': '987654321',
'activityType': {'typeKey': 'cycling'}, 'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15T08:30:00Z', 'startTimeLocal': '2024-01-15 08:30:00', # Adjusted format
'duration': 7200, 'duration': 7200,
'distance': 50000, 'distance': 50000,
'averageHR': 145, 'averageHR': 145, # Corrected key
'maxHR': 175, 'maxHR': 175, # Corrected key
'avgPower': 230, 'avgPower': 230, # Corrected key
'maxPower': 450, 'maxPower': 450, # Corrected key
'averageBikingCadenceInRevPerMinute': 85, 'averageBikingCadenceInRevPerMinute': 85,
'elevationGain': 800 'elevationGain': 800 # Corrected key
} }
result = await service.parse_activity_data(activity_data) result = await service.parse_activity_data(activity_data)
@@ -238,29 +237,21 @@ async def test_parse_activity_data():
assert result['max_power'] == 450 assert result['max_power'] == 450
assert result['avg_cadence'] == 85 assert result['avg_cadence'] == 85
assert result['elevation_gain_m'] == 800 assert result['elevation_gain_m'] == 800
assert result['metrics'] == activity_data # Full data stored as JSONB
@pytest.mark.asyncio @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""" """Test handling of network timeouts during sync"""
mock_db = AsyncMock() mock_db_session.execute.return_value.scalar_one_or_none.return_value = None # No duplicates
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db) with patch('backend.app.services.workout_sync.GarminService', return_value=mock_garmin_service):
service = WorkoutSyncService(mock_db_session)
# Simulate timeout error # Simulate timeout error
import asyncio mock_garmin_service.get_activities.side_effect = garminconnect.GarminConnectConnectionError("Request timed out")
service.garmin_service.get_activities = AsyncMock(
side_effect=asyncio.TimeoutError("Request timed out")
)
with pytest.raises(Exception): # Should raise the timeout error with pytest.raises(GarminAPIError): # Should raise the GarminAPIError
await service.sync_recent_activities() await service.sync_recent_activities()
# Verify error was logged mock_db_session.add.assert_called_once() # Should add the sync log
sync_log_calls = [call for call in mock_db.add.call_args_list assert mock_db_session.commit.call_count == 2 # Initial commit and commit after updating status
if isinstance(call[0][0], GarminSyncLog)] mock_db_session.refresh.assert_called_once() # Initial refresh
sync_log = sync_log_calls[0][0][0]
assert sync_log.status == "error"

View File

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