mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-01-25 16:41:58 +00:00
366 lines
13 KiB
Python
366 lines
13 KiB
Python
import pytest
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
from sqlalchemy import select
|
|
from backend.app.database import Base
|
|
from backend.app.services.workout_sync import WorkoutSyncService
|
|
from backend.app.services.garmin import GarminConnectService as GarminService, GarminAPIError, GarminAuthError
|
|
from backend.app.models.workout import Workout
|
|
from backend.app.models.garmin_sync_log import GarminSyncLog, GarminSyncStatus
|
|
from datetime import datetime, timedelta
|
|
import os
|
|
from dotenv import load_dotenv
|
|
from backend.app.config import Settings
|
|
|
|
# --- Completely Rewritten Fixtures ---
|
|
|
|
@pytest.fixture(scope="function")
|
|
def test_engine():
|
|
"""Create a test engine for each test function."""
|
|
engine = create_async_engine(
|
|
"sqlite+aiosqlite:///:memory:",
|
|
echo=False,
|
|
poolclass=StaticPool,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
return engine
|
|
|
|
@pytest.fixture(scope="function")
|
|
async def setup_database(test_engine):
|
|
"""Set up the database schema."""
|
|
async with test_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield
|
|
async with test_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
await test_engine.dispose()
|
|
|
|
@pytest.fixture
|
|
async def db_session(test_engine, setup_database):
|
|
"""Create a database session for testing."""
|
|
async_session_factory = async_sessionmaker(
|
|
bind=test_engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False
|
|
)
|
|
|
|
session = async_session_factory()
|
|
yield session
|
|
await session.close()
|
|
|
|
@pytest.fixture
|
|
def mock_garmin_service():
|
|
"""Mock the GarminService for testing."""
|
|
mock_service = MagicMock(spec=GarminService)
|
|
mock_service.authenticate = AsyncMock(return_value=True)
|
|
mock_service.get_activities = AsyncMock(return_value=[])
|
|
mock_service.get_activity_details = AsyncMock(return_value={})
|
|
return mock_service
|
|
|
|
@pytest.fixture
|
|
def settings() -> Settings:
|
|
"""Load settings from .env file."""
|
|
load_dotenv()
|
|
return Settings()
|
|
|
|
@pytest.fixture
|
|
def real_garmin_service(settings: Settings) -> GarminService:
|
|
"""Return a real GarminService instance with credentials from settings."""
|
|
if not settings.GARMIN_USERNAME or not settings.GARMIN_PASSWORD:
|
|
pytest.skip("GARMIN_USERNAME and GARMIN_PASSWORD must be set in .env for functional tests.")
|
|
return GarminService()
|
|
|
|
# --- Test Cases ---
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_successful_sync_functional(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
|
"""Test successful synchronization of recent activities."""
|
|
# Create service with the actual session
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = mock_garmin_service
|
|
|
|
# Arrange
|
|
mock_garmin_service.get_activities.return_value = [
|
|
{
|
|
'activityId': '1001',
|
|
'activityType': {'typeKey': 'cycling'},
|
|
'startTimeLocal': (datetime.now() - timedelta(days=1)).isoformat(),
|
|
'duration': 3600,
|
|
'distance': 50000,
|
|
'averageHR': 150,
|
|
'maxHR': 180,
|
|
'avgPower': 200,
|
|
'elevationGain': 500
|
|
}
|
|
]
|
|
mock_garmin_service.get_activity_details.return_value = {
|
|
'avgPower': 200,
|
|
'elevationGain': 500,
|
|
'temperature': 25
|
|
}
|
|
|
|
# Act
|
|
synced_count = await service.sync_recent_activities(days_back=7)
|
|
|
|
# Assert
|
|
assert synced_count == 1
|
|
|
|
# Verify workout in DB
|
|
result = await db_session.execute(select(Workout))
|
|
workouts = result.scalars().all()
|
|
assert len(workouts) == 1
|
|
assert workouts[0].garmin_activity_id == '1001'
|
|
assert workouts[0].activity_type == 'cycling'
|
|
assert workouts[0].avg_power == 200.0
|
|
assert 'temperature' in workouts[0].metrics
|
|
|
|
# Verify sync log in DB
|
|
result = await db_session.execute(select(GarminSyncLog))
|
|
sync_logs = result.scalars().all()
|
|
assert len(sync_logs) == 1
|
|
assert sync_logs[0].status == GarminSyncStatus.COMPLETED
|
|
assert sync_logs[0].activities_synced == 1
|
|
assert sync_logs[0].error_message is None
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_sync_with_no_new_activities(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
|
"""Test synchronization when no new activities are found."""
|
|
# Create service with the actual session
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = mock_garmin_service
|
|
|
|
# Arrange
|
|
mock_garmin_service.get_activities.return_value = [] # No activities
|
|
|
|
# Act
|
|
synced_count = await service.sync_recent_activities(days_back=7)
|
|
|
|
# Assert
|
|
assert synced_count == 0
|
|
result = await db_session.execute(select(Workout))
|
|
workouts = result.scalars().all()
|
|
assert len(workouts) == 0
|
|
|
|
result = await db_session.execute(select(GarminSyncLog))
|
|
sync_logs = result.scalars().all()
|
|
assert len(sync_logs) == 1
|
|
assert sync_logs[0].status == GarminSyncStatus.COMPLETED
|
|
assert sync_logs[0].activities_synced == 0
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_sync_with_authentication_error(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
|
"""Test synchronization failure due to Garmin authentication error."""
|
|
# Create service with the actual session
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = mock_garmin_service
|
|
|
|
# Arrange
|
|
mock_garmin_service.get_activities.side_effect = GarminAuthError("Invalid credentials")
|
|
|
|
# Act & Assert
|
|
with pytest.raises(GarminAuthError):
|
|
await service.sync_recent_activities(days_back=7)
|
|
|
|
# Verify sync log in DB
|
|
result = await db_session.execute(select(GarminSyncLog))
|
|
sync_logs = result.scalars().all()
|
|
assert len(sync_logs) == 1
|
|
assert sync_logs[0].status == GarminSyncStatus.AUTH_FAILED
|
|
assert "Invalid credentials" in sync_logs[0].error_message
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_sync_with_api_error(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
|
"""Test synchronization failure due to general Garmin API error."""
|
|
# Create service with the actual session
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = mock_garmin_service
|
|
|
|
# Arrange
|
|
mock_garmin_service.get_activities.side_effect = GarminAPIError("Garmin service unavailable")
|
|
|
|
# Act & Assert
|
|
with pytest.raises(GarminAPIError):
|
|
await service.sync_recent_activities(days_back=7)
|
|
|
|
# Verify sync log in DB
|
|
result = await db_session.execute(select(GarminSyncLog))
|
|
sync_logs = result.scalars().all()
|
|
assert len(sync_logs) == 1
|
|
assert sync_logs[0].status == GarminSyncStatus.FAILED
|
|
assert "Garmin service unavailable" in sync_logs[0].error_message
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_sync_with_activity_details_retry_success(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
|
"""Test successful retry of activity details fetch after initial failure."""
|
|
# Create service with the actual session
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = mock_garmin_service
|
|
|
|
# Arrange
|
|
mock_garmin_service.get_activities.return_value = [
|
|
{
|
|
'activityId': '1002',
|
|
'activityType': {'typeKey': 'running'},
|
|
'startTimeLocal': (datetime.now() - timedelta(days=2)).isoformat(),
|
|
'duration': 3000,
|
|
'distance': 10000
|
|
}
|
|
]
|
|
# First call to get_activity_details fails, second succeeds
|
|
mock_garmin_service.get_activity_details.side_effect = [
|
|
GarminAPIError("Temporary network issue"),
|
|
{'averageHR': 160, 'maxHR': 190}
|
|
]
|
|
|
|
# Act
|
|
# Mock asyncio.sleep to avoid actual delays during tests
|
|
with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
synced_count = await service.sync_recent_activities(days_back=7)
|
|
mock_sleep.assert_awaited_with(1) # First retry delay
|
|
|
|
# Assert
|
|
assert synced_count == 1
|
|
result = await db_session.execute(select(Workout))
|
|
workouts = result.scalars().all()
|
|
assert len(workouts) == 1
|
|
assert workouts[0].garmin_activity_id == '1002'
|
|
assert workouts[0].avg_hr == 160
|
|
assert mock_garmin_service.get_activity_details.call_count == 2
|
|
|
|
result = await db_session.execute(select(GarminSyncLog))
|
|
sync_logs = result.scalars().all()
|
|
assert len(sync_logs) == 1
|
|
assert sync_logs[0].status == GarminSyncStatus.COMPLETED
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_sync_with_activity_details_retry_failure(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
|
"""Test activity details fetch eventually fails after multiple retries."""
|
|
# Create service with the actual session
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = mock_garmin_service
|
|
|
|
# Arrange
|
|
mock_garmin_service.get_activities.return_value = [
|
|
{
|
|
'activityId': '1003',
|
|
'activityType': {'typeKey': 'swimming'},
|
|
'startTimeLocal': (datetime.now() - timedelta(days=3)).isoformat(),
|
|
'duration': 2000,
|
|
'distance': 2000
|
|
}
|
|
]
|
|
# All calls to get_activity_details fail
|
|
mock_garmin_service.get_activity_details.side_effect = [
|
|
GarminAPIError("Service unavailable"),
|
|
GarminAPIError("Service unavailable"),
|
|
GarminAPIError("Service unavailable")
|
|
]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(GarminAPIError), \
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
await service.sync_recent_activities(days_back=7)
|
|
assert mock_garmin_service.get_activity_details.call_count == 3
|
|
mock_sleep.assert_awaited_with(4) # Last retry delay (2**(3-1))
|
|
|
|
# Verify sync log in DB
|
|
result = await db_session.execute(select(GarminSyncLog))
|
|
sync_logs = result.scalars().all()
|
|
assert len(sync_logs) == 1
|
|
assert sync_logs[0].status == GarminSyncStatus.FAILED
|
|
assert "Service unavailable" in sync_logs[0].error_message
|
|
|
|
# No workout should be saved
|
|
result = await db_session.execute(select(Workout))
|
|
workouts = result.scalars().all()
|
|
assert len(workouts) == 0
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_sync_with_duplicate_activities_in_garmin_feed(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
|
"""Test handling of duplicate activities appearing in the Garmin feed."""
|
|
# Create service with the actual session
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = mock_garmin_service
|
|
|
|
# Arrange
|
|
# First sync: add activity 1004
|
|
mock_garmin_service.get_activities.return_value = [
|
|
{
|
|
'activityId': '1004',
|
|
'activityType': {'typeKey': 'cycling'},
|
|
'startTimeLocal': (datetime.now() - timedelta(days=4)).isoformat(),
|
|
'duration': 4000,
|
|
'distance': 60000
|
|
}
|
|
]
|
|
mock_garmin_service.get_activity_details.return_value = {'averageHR': 140}
|
|
await service.sync_recent_activities(days_back=7)
|
|
|
|
# Second sync: activity 1004 is present again, plus a new activity 1005
|
|
mock_garmin_service.get_activities.return_value = [
|
|
{
|
|
'activityId': '1004',
|
|
'activityType': {'typeKey': 'cycling'},
|
|
'startTimeLocal': (datetime.now() - timedelta(days=4)).isoformat(),
|
|
'duration': 4000,
|
|
'distance': 60000
|
|
},
|
|
{
|
|
'activityId': '1005',
|
|
'activityType': {'typeKey': 'running'},
|
|
'startTimeLocal': (datetime.now() - timedelta(days=5)).isoformat(),
|
|
'duration': 2500,
|
|
'distance': 5000
|
|
}
|
|
]
|
|
mock_garmin_service.get_activity_details.return_value = {'averageHR': 130} # for activity 1005
|
|
|
|
# Act
|
|
synced_count = await service.sync_recent_activities(days_back=7)
|
|
|
|
# Assert
|
|
assert synced_count == 1 # Only 1005 should be synced
|
|
result = await db_session.execute(select(Workout))
|
|
workouts = result.scalars().all()
|
|
assert len(workouts) == 2
|
|
assert any(w.garmin_activity_id == '1004' for w in workouts)
|
|
assert any(w.garmin_activity_id == '1005' for w in workouts)
|
|
|
|
result = await db_session.execute(select(GarminSyncLog))
|
|
sync_logs = result.scalars().all()
|
|
assert len(sync_logs) == 2
|
|
assert sync_logs[1].activities_synced == 1 # Second log should show 1 activity synced
|
|
|
|
@pytest.mark.functional
|
|
@pytest.mark.asyncio
|
|
async def test_garmin_sync_with_real_creds(db_session: AsyncSession, real_garmin_service: GarminService):
|
|
"""
|
|
Test a real Garmin sync. This is a functional test that makes a live API call.
|
|
It requires GARMIN_USERNAME and GARMIN_PASSWORD to be set in the .env file.
|
|
"""
|
|
# Arrange
|
|
service = WorkoutSyncService(db=db_session)
|
|
service.garmin_service = real_garmin_service
|
|
|
|
# Act
|
|
# We sync the last 1 day to keep the test fast
|
|
synced_count = await service.sync_recent_activities(days_back=1)
|
|
|
|
# Assert
|
|
assert synced_count >= 0 # We can't know the exact count, but it should not fail
|
|
|
|
# Verify sync log in DB
|
|
result = await db_session.execute(select(GarminSyncLog).order_by(GarminSyncLog.id.desc()))
|
|
latest_log = result.scalars().first()
|
|
|
|
assert latest_log is not None
|
|
assert latest_log.status == GarminSyncStatus.COMPLETED
|
|
assert latest_log.activities_synced == synced_count |