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