mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2025-12-05 23:52:06 +00:00
sync
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
5
main.py
5
main.py
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user