sync - still working on the TUI

This commit is contained in:
2025-09-27 13:24:20 -07:00
parent 72b5cc3aaa
commit ec02b923af
25 changed files with 1091 additions and 367 deletions

View File

@@ -20,10 +20,10 @@ async def test_analyze_workout_success():
})
with patch('httpx.AsyncClient.post') as mock_post:
mock_post.return_value = AsyncMock(
status_code=200,
json=lambda: {"choices": [{"message": {"content": test_response}}]}
)
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json.return_value = {"choices": [{"message": {"content": test_response}}]}
mock_post.return_value = mock_response
workout = Workout(activity_type="cycling", duration_seconds=3600)
result = await ai_service.analyze_workout(workout)
@@ -34,20 +34,20 @@ async def test_analyze_workout_success():
@pytest.mark.asyncio
async def test_generate_plan_success():
"""Test plan generation with structured response"""
mock_db = MagicMock()
mock_db = AsyncMock()
ai_service = AIService(mock_db)
ai_service.prompt_manager.get_active_prompt = AsyncMock(return_value="Plan prompt: {rules} {goals}")
ai_service.prompt_manager.get_active_prompt = AsyncMock(return_value="Plan prompt: {rules_text} {goals}")
test_plan = {
"weeks": [{"workouts": ["ride"]}],
"focus": "endurance"
}
with patch('httpx.AsyncClient.post') as mock_post:
mock_post.return_value = AsyncMock(
status_code=200,
json=lambda: {"choices": [{"message": {"content": json.dumps(test_plan)}}]}
)
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json.return_value = {"choices": [{"message": {"content": json.dumps(test_plan)}}]}
mock_post.return_value = mock_response
result = await ai_service.generate_plan([], {})
assert "weeks" in result
@@ -70,14 +70,14 @@ async def test_api_retry_logic():
@pytest.mark.asyncio
async def test_invalid_json_handling():
"""Test graceful handling of invalid JSON responses"""
mock_db = MagicMock()
mock_db = AsyncMock()
ai_service = AIService(mock_db)
with patch('httpx.AsyncClient.post') as mock_post:
mock_post.return_value = AsyncMock(
status_code=200,
json=lambda: {"choices": [{"message": {"content": "invalid{json"}}]}
)
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json.return_value = {"choices": [{"message": {"content": "invalid{json"}}]}
mock_post.return_value = mock_response
result = await ai_service.parse_rules_from_natural_language("test")
assert "raw_rules" in result
@@ -86,16 +86,16 @@ async def test_invalid_json_handling():
@pytest.mark.asyncio
async def test_code_block_parsing():
"""Test extraction of JSON from code blocks"""
mock_db = MagicMock()
mock_db = AsyncMock()
ai_service = AIService(mock_db)
test_response = "```json\n" + json.dumps({"max_rides": 4}) + "\n```"
with patch('httpx.AsyncClient.post') as mock_post:
mock_post.return_value = AsyncMock(
status_code=200,
json=lambda: {"choices": [{"message": {"content": test_response}}]}
)
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json.return_value = {"choices": [{"message": {"content": test_response}}]}
mock_post.return_value = mock_response
result = await ai_service.evolve_plan({})
assert "max_rides" in result

View File

@@ -1,78 +1,131 @@
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, patch
from backend.app.services.garmin import GarminService
from backend.app.services.garmin import GarminService, GarminAuthError, GarminAPIError
from backend.app.models.garmin_sync_log import GarminSyncStatus
from datetime import datetime, timedelta
import garth # Import garth for type hinting
@pytest.fixture
def mock_env_vars():
with patch.dict(os.environ, {"GARMIN_USERNAME": "test_user", "GARMIN_PASSWORD": "test_password"}):
yield
def create_garth_client_mock():
mock_client_instance = MagicMock(spec=garth.Client)
mock_client_instance.login = AsyncMock(return_value=True)
mock_client_instance.get_activities = AsyncMock(return_value=[])
mock_client_instance.get_activity = AsyncMock(return_value={})
mock_client_instance.load = AsyncMock(side_effect=FileNotFoundError)
mock_client_instance.save = AsyncMock()
return mock_client_instance
@pytest.mark.asyncio
async def test_garmin_authentication_success(db_session):
async def test_garmin_authentication_success(db_session, mock_env_vars):
"""Test successful Garmin Connect authentication"""
with patch('garth.Client') as mock_client:
mock_instance = mock_client.return_value
mock_instance.login = AsyncMock(return_value=True)
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = FileNotFoundError
service = GarminService(db_session)
result = await service.authenticate("test_user", "test_pass")
result = await service.authenticate()
assert result is True
mock_instance.login.assert_awaited_once_with("test_user", "test_pass")
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)
@pytest.mark.asyncio
async def test_garmin_authentication_failure(db_session):
async def test_garmin_authentication_failure(db_session, mock_env_vars):
"""Test authentication failure handling"""
with patch('garth.Client') as mock_client:
mock_instance = mock_client.return_value
mock_instance.login = AsyncMock(side_effect=Exception("Invalid credentials"))
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = FileNotFoundError
mock_instance.login.side_effect = Exception("Invalid credentials")
service = GarminService(db_session)
result = await service.authenticate("bad_user", "wrong_pass")
assert result is False
log_entry = db_session.query(GarminSyncLog).first()
assert log_entry.status == GarminSyncStatus.AUTH_FAILED
with pytest.raises(GarminAuthError):
await service.authenticate()
mock_instance.login.assert_called_once_with(os.getenv("GARMIN_USERNAME"), os.getenv("GARMIN_PASSWORD"))
mock_instance.save.assert_not_called()
@pytest.mark.asyncio
async def test_activity_sync(db_session):
async def test_garmin_authentication_load_session_success(db_session, mock_env_vars):
"""Test successful loading of existing Garmin session"""
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = None
service = GarminService(db_session)
result = await service.authenticate()
assert result is True
mock_instance.load.assert_called_once_with(service.session_dir)
mock_instance.login.assert_not_called()
mock_instance.save.assert_not_called()
@pytest.mark.asyncio
async def test_garmin_authentication_missing_credentials(db_session):
"""Test authentication failure when credentials are missing"""
with patch.dict(os.environ, {"GARMIN_USERNAME": "", "GARMIN_PASSWORD": ""}):
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.load.side_effect = FileNotFoundError
service = GarminService(db_session)
with pytest.raises(GarminAuthError, match="Garmin username or password not configured."):
await service.authenticate()
mock_instance.login.assert_not_called()
mock_instance.save.assert_not_called()
@pytest.mark.asyncio
async def test_activity_sync(db_session, mock_env_vars):
"""Test successful activity synchronization"""
with patch('garth.Client') as mock_client:
mock_instance = mock_client.return_value
mock_instance.connectapi = AsyncMock(return_value=[
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activities.return_value = [
{"activityId": 123, "startTime": "2024-01-01T08:00:00"}
])
]
service = GarminService(db_session)
await service.sync_activities()
# Verify workout created
workout = db_session.query(Workout).first()
assert workout.garmin_activity_id == 123
# Verify sync log updated
log_entry = db_session.query(GarminSyncLog).first()
assert log_entry.status == GarminSyncStatus.COMPLETED
service.client = mock_instance
activities = await service.get_activities()
assert len(activities) == 1
assert activities[0]["activityId"] == 123
mock_instance.get_activities.assert_called_once()
@pytest.mark.asyncio
async def test_rate_limiting_handling(db_session):
async def test_rate_limiting_handling(db_session, mock_env_vars):
"""Test API rate limit error handling"""
with patch('garth.Client') as mock_client:
mock_instance = mock_client.return_value
mock_instance.connectapi = AsyncMock(side_effect=Exception("Rate limit exceeded"))
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activities.side_effect = Exception("Rate limit exceeded")
service = GarminService(db_session)
result = await service.sync_activities()
assert result is False
log_entry = db_session.query(GarminSyncLog).first()
assert log_entry.status == GarminSyncStatus.FAILED
assert "Rate limit" in log_entry.error_message
service.client = mock_instance
with pytest.raises(GarminAPIError):
await service.get_activities()
mock_instance.get_activities.assert_called_once()
@pytest.mark.asyncio
async def test_session_persistence(db_session):
"""Test session cookie persistence"""
async def test_get_activity_details_success(db_session, mock_env_vars):
"""Test successful retrieval of activity details."""
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activity.return_value = {"activityId": 123, "details": "data"}
service = GarminService(db_session)
service.client = mock_instance
details = await service.get_activity_details("123")
assert details["activityId"] == 123
mock_instance.get_activity.assert_called_once_with("123")
@pytest.mark.asyncio
async def test_get_activity_details_failure(db_session, mock_env_vars):
"""Test failure in retrieving activity details."""
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
mock_instance = mock_client_class.return_value
mock_instance.get_activity.side_effect = Exception("Activity not found")
service = GarminService(db_session)
service.client = mock_instance
with pytest.raises(GarminAPIError, match="Failed to fetch activity details"):
await service.get_activity_details("123")
mock_instance.get_activity.assert_called_once_with("123")
@pytest.mark.asyncio
async def test_is_authenticated(db_session):
"""Test is_authenticated method"""
service = GarminService(db_session)
# Store session
await service.store_session({"token": "test123"})
session = await service.load_session()
assert session == {"token": "test123"}
assert Path("/app/data/sessions/garmin_session.pickle").exists()
assert service.is_authenticated() is False
service.client = MagicMock()
assert service.is_authenticated() is True

View 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

View File

@@ -17,6 +17,7 @@ async def test_evolve_plan_with_valid_analysis():
)
mock_analysis = Analysis(
approved=True,
suggestions=["More recovery"],
jsonb_feedback={"suggestions": ["More recovery"]}
)
@@ -28,7 +29,7 @@ async def test_evolve_plan_with_valid_analysis():
assert result.version == 2
assert result.parent_plan_id == 1
mock_db.add.assert_called_once()
mock_db.commit.assert_awaited_once()
mock_db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_evolution_skipped_for_unapproved_analysis():
@@ -45,12 +46,14 @@ async def test_evolution_skipped_for_unapproved_analysis():
async def test_evolution_history_retrieval():
"""Test getting plan evolution history"""
mock_db = AsyncMock()
mock_db.execute.return_value.scalars.return_value = [
mock_result = AsyncMock()
mock_result.scalars.return_value.all.return_value = [
Plan(version=1), Plan(version=2)
]
mock_db.execute.return_value = mock_result
service = PlanEvolutionService(mock_db)
history = await service.get_plan_evolution_history(1)
assert len(history) == 2
assert history[0].version == 1
history_result = await history
assert len(history_result) == 2
assert history_result[0].version == 1

View File

@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog
from backend.app.services.garmin import GarminAuthError, GarminAPIError
from datetime import datetime, timedelta
import asyncio
@@ -10,59 +11,70 @@ import asyncio
async def test_successful_sync():
"""Test successful sync of new activities"""
mock_db = AsyncMock()
mock_garmin = MagicMock()
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
mock_garmin = AsyncMock()
mock_garmin.get_activities.return_value = [{'activityId': '123', 'startTimeLocal': '2024-01-01T08:00:00', 'duration': 3600, 'distance': 10000, 'activityType': {'typeKey': 'running'}}]
mock_garmin.get_activity_details.return_value = {'metrics': 'data'}
service = WorkoutSyncService(mock_db)
service.garmin_service = mock_garmin
result = await service.sync_recent_activities()
assert result == 1
mock_db.add.assert_called()
mock_db.commit.assert_awaited()
with patch('backend.app.services.workout_sync.WorkoutSyncService.activity_exists', new_callable=AsyncMock) as mock_activity_exists:
mock_activity_exists.return_value = False
service = WorkoutSyncService(mock_db)
service.garmin_service = mock_garmin
result = await service.sync_recent_activities()
assert result == 1
mock_db.add.assert_called()
mock_db.commit.assert_called()
mock_activity_exists.assert_awaited_once_with('123')
@pytest.mark.asyncio
async def test_duplicate_activity_handling():
"""Test skipping duplicate activities"""
mock_db = AsyncMock()
mock_db.execute.return_value.scalar_one_or_none.return_value = True
mock_garmin = MagicMock()
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
service = WorkoutSyncService(mock_db)
service.garmin_service = mock_garmin
result = await service.sync_recent_activities()
assert result == 0
with patch('backend.app.services.workout_sync.WorkoutSyncService.activity_exists', new_callable=AsyncMock) as mock_activity_exists:
mock_activity_exists.return_value = True
mock_garmin = AsyncMock()
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
service = WorkoutSyncService(mock_db)
service.garmin_service = mock_garmin
result = await service.sync_recent_activities()
assert result == 0
mock_activity_exists.assert_awaited_once_with('123')
@pytest.mark.asyncio
async def test_activity_detail_retry_logic():
"""Test retry logic for activity details"""
mock_db = AsyncMock()
mock_garmin = MagicMock()
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
mock_garmin.get_activity_details.side_effect = [Exception(), {'metrics': 'data'}]
mock_garmin = AsyncMock()
mock_garmin.get_activities.return_value = [{'activityId': '123', 'startTimeLocal': '2024-01-01T08:00:00', 'duration': 3600, 'distance': 10000, 'activityType': {'typeKey': 'running'}}]
mock_garmin.get_activity_details.side_effect = [GarminAPIError("Error"), {'metrics': 'data'}]
service = WorkoutSyncService(mock_db)
service.garmin_service = mock_garmin
result = await service.sync_recent_activities()
assert mock_garmin.get_activity_details.call_count == 2
assert result == 1
with patch('backend.app.services.workout_sync.WorkoutSyncService.activity_exists', new_callable=AsyncMock) as mock_activity_exists:
mock_activity_exists.return_value = False
service = WorkoutSyncService(mock_db)
service.garmin_service = mock_garmin
result = await service.sync_recent_activities()
assert mock_garmin.get_activity_details.call_count == 2
assert result == 1
mock_activity_exists.assert_awaited_once_with('123')
@pytest.mark.asyncio
async def test_auth_error_handling():
"""Test authentication error handling"""
mock_db = AsyncMock()
mock_garmin = MagicMock()
mock_garmin.get_activities.side_effect = Exception("Auth failed")
mock_garmin = AsyncMock()
mock_garmin.get_activities.side_effect = GarminAuthError("Auth failed")
service = WorkoutSyncService(mock_db)
service.garmin_service = mock_garmin
with pytest.raises(Exception):
with pytest.raises(GarminAuthError):
await service.sync_recent_activities()
sync_log = mock_db.add.call_args[0][0]

View File

@@ -0,0 +1,266 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.services.garmin import GarminAPIError, GarminAuthError
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog
from datetime import datetime, timedelta
import asyncio
@pytest.mark.asyncio
async def test_successful_sync():
"""Test successful sync of new activities"""
# Create proper async mock for database session
mock_db = AsyncMock()
mock_db.add = MagicMock() # add is synchronous
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
# Mock the activity_exists check to return False (no duplicates)
mock_db.execute = AsyncMock()
mock_db.execute.return_value.scalar_one_or_none = AsyncMock(return_value=None)
service = WorkoutSyncService(mock_db)
# Mock the garmin service methods
service.garmin_service.get_activities = AsyncMock(return_value=[
{
'activityId': '123456',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15T08:00:00Z',
'duration': 3600,
'distance': 25000
}
])
service.garmin_service.get_activity_details = AsyncMock(return_value={
'averageHR': 150,
'maxHR': 180,
'avgPower': 250,
'elevationGain': 500
})
result = await service.sync_recent_activities(days_back=7)
assert result == 1
assert mock_db.add.call_count >= 2 # sync_log and workout
mock_db.commit.assert_awaited()
@pytest.mark.asyncio
async def test_duplicate_activity_handling():
"""Test skipping duplicate activities"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
# Mock activity_exists to return True (activity exists)
service.activity_exists = AsyncMock(return_value=True)
service.garmin_service.get_activities = AsyncMock(return_value=[
{'activityId': '123456', 'startTimeLocal': '2024-01-15T08:00:00Z'}
])
result = await service.sync_recent_activities()
assert result == 0 # No new activities synced
mock_db.commit.assert_awaited()
@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()
assert service.garmin_service.get_activity_details.call_count == 2
assert result == 1
@pytest.mark.asyncio
async def test_auth_error_handling():
"""Test authentication error handling"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
# Mock authentication failure
service.garmin_service.get_activities = AsyncMock(
side_effect=GarminAuthError("Authentication failed")
)
with pytest.raises(GarminAuthError):
await service.sync_recent_activities()
# Check that sync log was created with auth error status
sync_log_calls = [call for call in mock_db.add.call_args_list
if isinstance(call[0][0], GarminSyncLog)]
assert len(sync_log_calls) >= 1
sync_log = sync_log_calls[0][0][0]
assert sync_log.status == "auth_error"
@pytest.mark.asyncio
async def test_api_error_handling():
"""Test API error handling"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
service.garmin_service.get_activities = AsyncMock(
side_effect=GarminAPIError("API rate limit exceeded")
)
with pytest.raises(GarminAPIError):
await service.sync_recent_activities()
# Check sync log status
sync_log_calls = [call for call in mock_db.add.call_args_list
if isinstance(call[0][0], GarminSyncLog)]
sync_log = sync_log_calls[0][0][0]
assert sync_log.status == "api_error"
assert "rate limit" in sync_log.error_message.lower()
@pytest.mark.asyncio
async def test_get_sync_status():
"""Test retrieval of latest sync status"""
mock_db = AsyncMock()
mock_log = GarminSyncLog(
status="success",
activities_synced=5,
last_sync_time=datetime.now()
)
# Mock the database query
mock_result = AsyncMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_log)
mock_db.execute = AsyncMock(return_value=mock_result)
service = WorkoutSyncService(mock_db)
result = await service.get_latest_sync_status()
assert result.status == "success"
assert result.activities_synced == 5
mock_db.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_activity_exists_check():
"""Test the activity_exists helper method"""
mock_db = AsyncMock()
# Mock existing activity
mock_workout = Workout(garmin_activity_id="123456")
mock_result = AsyncMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_workout)
mock_db.execute = AsyncMock(return_value=mock_result)
service = WorkoutSyncService(mock_db)
exists = await service.activity_exists("123456")
assert exists is True
mock_db.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_activity_does_not_exist():
"""Test activity_exists when activity doesn't exist"""
mock_db = AsyncMock()
# Mock no existing activity
mock_result = AsyncMock()
mock_result.scalar_one_or_none = AsyncMock(return_value=None)
mock_db.execute = AsyncMock(return_value=mock_result)
service = WorkoutSyncService(mock_db)
exists = await service.activity_exists("nonexistent")
assert exists is False
@pytest.mark.asyncio
async def test_parse_activity_data():
"""Test parsing of Garmin activity data"""
mock_db = AsyncMock()
service = WorkoutSyncService(mock_db)
activity_data = {
'activityId': '987654321',
'activityType': {'typeKey': 'cycling'},
'startTimeLocal': '2024-01-15T08:30:00Z',
'duration': 7200,
'distance': 50000,
'averageHR': 145,
'maxHR': 175,
'avgPower': 230,
'maxPower': 450,
'averageBikingCadenceInRevPerMinute': 85,
'elevationGain': 800
}
result = await service.parse_activity_data(activity_data)
assert result['garmin_activity_id'] == '987654321'
assert result['activity_type'] == 'cycling'
assert result['duration_seconds'] == 7200
assert result['distance_m'] == 50000
assert result['avg_hr'] == 145
assert result['max_hr'] == 175
assert result['avg_power'] == 230
assert result['max_power'] == 450
assert result['avg_cadence'] == 85
assert result['elevation_gain_m'] == 800
assert result['metrics'] == activity_data # Full data stored as JSONB
@pytest.mark.asyncio
async def test_sync_with_network_timeout():
"""Test handling of network timeouts during sync"""
mock_db = AsyncMock()
mock_db.add = MagicMock()
mock_db.commit = AsyncMock()
mock_db.refresh = AsyncMock()
service = WorkoutSyncService(mock_db)
# Simulate timeout error
import asyncio
service.garmin_service.get_activities = AsyncMock(
side_effect=asyncio.TimeoutError("Request timed out")
)
with pytest.raises(Exception): # Should raise the timeout error
await service.sync_recent_activities()
# Verify error was logged
sync_log_calls = [call for call in mock_db.add.call_args_list
if isinstance(call[0][0], GarminSyncLog)]
sync_log = sync_log_calls[0][0][0]
assert sync_log.status == "error"