Files
AICyclingCoach/backend/tests/services/test_workout_sync.py
2025-11-17 06:26:36 -08:00

257 lines
11 KiB
Python

import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime, timedelta
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(mock_db_session, mock_garmin_service):
"""Test successful sync of new activities"""
# Configure mock_db_session.execute for activity_exists check
mock_db_session.execute.return_value.scalar_one_or_none.return_value = None # No duplicates
# 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)
# 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_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 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_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"""
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(mock_db_session, mock_garmin_service):
"""Test API error handling"""
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(mock_db_session):
"""Test retrieval of latest sync status"""
mock_log = GarminSyncLog(
status=GarminSyncStatus.COMPLETED,
activities_synced=5,
last_sync_time=datetime.now()
)
# 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 == mock_log
mock_db_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_activity_exists_check(mock_db_session):
"""Test the activity_exists helper method"""
# Mock existing activity
mock_workout = Workout(garmin_activity_id="123456")
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_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_activity_does_not_exist(mock_db_session):
"""Test activity_exists when activity doesn't exist"""
# Mock no existing activity
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(mock_db_session):
"""Test parsing of Garmin activity data"""
service = WorkoutSyncService(mock_db_session)
activity_data = {
'activityId': '987654321',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15 08:30:00', # Adjusted format
'duration': 7200,
'distance': 50000,
'averageHR': 145, # Corrected key
'maxHR': 175, # Corrected key
'avgPower': 230, # Corrected key
'maxPower': 450, # Corrected key
'averageBikingCadenceInRevPerMinute': 85,
'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
assert result['distance_m'] == 50000
assert result['avg_hr'] == 145
assert result['max_hr'] == 175
assert result['avg_power'] == 230
assert result['max_power'] == 450
assert result['avg_cadence'] == 85
assert result['elevation_gain_m'] == 800
@pytest.mark.asyncio
async def test_sync_with_network_timeout(mock_db_session, mock_garmin_service):
"""Test handling of network timeouts during sync"""
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