mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-01-25 08:34:51 +00:00
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""
|
|
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 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
|
|
|
|
|
|
@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('garminconnect.Garmin')
|
|
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=(None, None))
|
|
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('garminconnect.Garmin')
|
|
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('garminconnect.Garmin')
|
|
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.login = AsyncMock(return_value=(None, None)) # Login handles loading from tokenstore
|
|
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(tokenstore=garmin_service.session_dir)
|
|
mock_client.save.assert_not_called()
|
|
|
|
|
|
class TestWorkoutSyncing:
|
|
"""Test workout synchronization functionality."""
|
|
|
|
@patch.dict(os.environ, {
|
|
'GARMIN_USERNAME': 'test@example.com',
|
|
'GARMIN_PASSWORD': 'testpass123'
|
|
})
|
|
@patch('garminconnect.Garmin')
|
|
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_by_date = MagicMock(return_value=mock_activities)
|
|
mock_client.get_activity_details = 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_by_date = 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('garminconnect.Garmin')
|
|
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('garminconnect.Garmin')
|
|
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_by_date = 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('garminconnect.Garmin')
|
|
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_by_date = MagicMock(return_value=mock_activities)
|
|
# First two calls fail, third succeeds
|
|
mock_client.get_activity_details = 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_details.call_count == 3 |