mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-13 19:06:41 +00:00
sync - still working on the TUI
This commit is contained in:
308
backend/tests/services/test_garmin_functional.py
Normal file
308
backend/tests/services/test_garmin_functional.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Functional tests for Garmin authentication and workout syncing.
|
||||
These tests verify the end-to-end functionality of Garmin integration.
|
||||
"""
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.services.garmin import 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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def garmin_service():
|
||||
"""Create GarminService instance for testing."""
|
||||
service = GarminService()
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def workout_sync_service(db_session: AsyncSession):
|
||||
"""Create WorkoutSyncService instance for testing."""
|
||||
service = WorkoutSyncService(db_session)
|
||||
yield service
|
||||
|
||||
|
||||
class TestGarminAuthentication:
|
||||
"""Test Garmin Connect authentication functionality."""
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'GARMIN_USERNAME': 'test@example.com',
|
||||
'GARMIN_PASSWORD': 'testpass123'
|
||||
})
|
||||
@patch('garth.Client')
|
||||
async def test_successful_authentication(self, mock_client_class, garmin_service):
|
||||
"""Test successful authentication with valid credentials."""
|
||||
# Setup mock client
|
||||
mock_client = MagicMock()
|
||||
mock_client.login = AsyncMock(return_value=True)
|
||||
mock_client.save = MagicMock()
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# 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()
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'GARMIN_USERNAME': 'invalid@example.com',
|
||||
'GARMIN_PASSWORD': 'wrongpass'
|
||||
})
|
||||
@patch('garth.Client')
|
||||
async def test_failed_authentication(self, mock_client_class, 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_class.return_value = mock_client
|
||||
|
||||
# Test authentication
|
||||
with pytest.raises(GarminAuthError, match="Authentication failed"):
|
||||
await garmin_service.authenticate()
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'GARMIN_USERNAME': 'test@example.com',
|
||||
'GARMIN_PASSWORD': 'testpass123'
|
||||
})
|
||||
@patch('garth.Client')
|
||||
async def test_session_reuse(self, mock_client_class, garmin_service):
|
||||
"""Test that existing sessions are reused."""
|
||||
# Setup mock client with load method
|
||||
mock_client = MagicMock()
|
||||
mock_client.load = MagicMock(return_value=True)
|
||||
mock_client.login = AsyncMock() # Should not be called
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Test authentication
|
||||
result = await garmin_service.authenticate()
|
||||
|
||||
assert result is True
|
||||
mock_client.load.assert_called_once()
|
||||
mock_client.login.assert_not_awaited()
|
||||
|
||||
|
||||
class TestWorkoutSyncing:
|
||||
"""Test workout synchronization functionality."""
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'GARMIN_USERNAME': 'test@example.com',
|
||||
'GARMIN_PASSWORD': 'testpass123'
|
||||
})
|
||||
@patch('garth.Client')
|
||||
async def test_successful_sync_recent_activities(self, mock_client_class, 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 activity data
|
||||
mock_activities = [
|
||||
{
|
||||
'activityId': '12345',
|
||||
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||
'activityType': {'typeKey': 'cycling'},
|
||||
'duration': 3600.0,
|
||||
'distance': 25000.0,
|
||||
'averageHR': 140.0,
|
||||
'maxHR': 170.0,
|
||||
'avgPower': 200.0,
|
||||
'maxPower': 350.0,
|
||||
'averageBikingCadenceInRevPerMinute': 85.0,
|
||||
'elevationGain': 500.0
|
||||
}
|
||||
]
|
||||
|
||||
# Mock detailed activity data
|
||||
mock_details = {
|
||||
'activityId': '12345',
|
||||
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||
'activityType': {'typeKey': 'cycling'},
|
||||
'duration': 3600.0,
|
||||
'distance': 25000.0,
|
||||
'averageHR': 140.0,
|
||||
'maxHR': 170.0,
|
||||
'avgPower': 200.0,
|
||||
'maxPower': 350.0,
|
||||
'averageBikingCadenceInRevPerMinute': 85.0,
|
||||
'elevationGain': 500.0
|
||||
}
|
||||
|
||||
mock_client.get_activities = MagicMock(return_value=mock_activities)
|
||||
mock_client.get_activity = MagicMock(return_value=mock_details)
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Test sync
|
||||
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
||||
|
||||
assert synced_count == 1
|
||||
|
||||
# Verify workout was created
|
||||
workout_result = await db_session.execute(
|
||||
select(Workout).where(Workout.garmin_activity_id == '12345')
|
||||
)
|
||||
workout = workout_result.scalar_one_or_none()
|
||||
assert workout is not None
|
||||
assert workout.activity_type == 'cycling'
|
||||
assert workout.duration_seconds == 3600.0
|
||||
assert workout.distance_m == 25000.0
|
||||
|
||||
# Verify sync log was created
|
||||
sync_log_result = await db_session.execute(
|
||||
select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc())
|
||||
)
|
||||
sync_log = sync_log_result.scalar_one_or_none()
|
||||
assert sync_log is not None
|
||||
assert sync_log.status == 'success'
|
||||
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):
|
||||
"""Test that duplicate activities are not synced again."""
|
||||
# First, create an existing workout
|
||||
existing_workout = Workout(
|
||||
garmin_activity_id='12345',
|
||||
activity_type='cycling',
|
||||
start_time=datetime.now(),
|
||||
duration_seconds=3600.0,
|
||||
distance_m=25000.0
|
||||
)
|
||||
db_session.add(existing_workout)
|
||||
await db_session.commit()
|
||||
|
||||
# Setup mock Garmin client
|
||||
mock_client = MagicMock()
|
||||
mock_client.login = AsyncMock(return_value=True)
|
||||
mock_client.save = MagicMock()
|
||||
|
||||
# Mock activity data (same as existing)
|
||||
mock_activities = [
|
||||
{
|
||||
'activityId': '12345',
|
||||
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||
'activityType': {'typeKey': 'cycling'},
|
||||
'duration': 3600.0,
|
||||
'distance': 25000.0
|
||||
}
|
||||
]
|
||||
|
||||
mock_client.get_activities = MagicMock(return_value=mock_activities)
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Test sync
|
||||
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
||||
|
||||
assert synced_count == 0 # No new activities synced
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'GARMIN_USERNAME': 'invalid@example.com',
|
||||
'GARMIN_PASSWORD': 'wrongpass'
|
||||
})
|
||||
@patch('garth.Client')
|
||||
async def test_sync_with_auth_failure(self, mock_client_class, 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_class.return_value = mock_client
|
||||
|
||||
# Test sync
|
||||
with pytest.raises(GarminAuthError):
|
||||
await workout_sync_service.sync_recent_activities(days_back=7)
|
||||
|
||||
# Verify sync log shows failure
|
||||
sync_log_result = await db_session.execute(
|
||||
select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc())
|
||||
)
|
||||
sync_log = sync_log_result.scalar_one_or_none()
|
||||
assert sync_log is not None
|
||||
assert sync_log.status == 'auth_error'
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'GARMIN_USERNAME': 'test@example.com',
|
||||
'GARMIN_PASSWORD': 'testpass123'
|
||||
})
|
||||
@patch('garth.Client')
|
||||
async def test_sync_with_api_error(self, mock_client_class, 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 = MagicMock(side_effect=Exception("API rate limit exceeded"))
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Test sync
|
||||
with pytest.raises(GarminAPIError):
|
||||
await workout_sync_service.sync_recent_activities(days_back=7)
|
||||
|
||||
# Verify sync log shows API error
|
||||
sync_log_result = await db_session.execute(
|
||||
select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc())
|
||||
)
|
||||
sync_log = sync_log_result.scalar_one_or_none()
|
||||
assert sync_log is not None
|
||||
assert sync_log.status == 'api_error'
|
||||
assert 'API rate limit' in sync_log.error_message
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling in Garmin integration."""
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'GARMIN_USERNAME': 'test@example.com',
|
||||
'GARMIN_PASSWORD': 'testpass123'
|
||||
})
|
||||
@patch('garth.Client')
|
||||
async def test_activity_detail_fetch_retry(self, mock_client_class, 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_activities = [
|
||||
{
|
||||
'activityId': '12345',
|
||||
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||
'activityType': {'typeKey': 'cycling'},
|
||||
'duration': 3600.0,
|
||||
'distance': 25000.0
|
||||
}
|
||||
]
|
||||
|
||||
mock_client.get_activities = MagicMock(return_value=mock_activities)
|
||||
# First two calls fail, third succeeds
|
||||
mock_client.get_activity = MagicMock(side_effect=[
|
||||
Exception("Temporary error"),
|
||||
Exception("Temporary error"),
|
||||
{
|
||||
'activityId': '12345',
|
||||
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||
'activityType': {'typeKey': 'cycling'},
|
||||
'duration': 3600.0,
|
||||
'distance': 25000.0,
|
||||
'averageHR': 140.0,
|
||||
'maxHR': 170.0
|
||||
}
|
||||
])
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Test sync
|
||||
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
||||
|
||||
assert synced_count == 1
|
||||
# Verify get_activity was called 3 times (initial + 2 retries)
|
||||
assert mock_client.get_activity.call_count == 3
|
||||
Reference in New Issue
Block a user