mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-01-25 08:34:51 +00:00
added garmin functional tests
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import garth
|
import garth
|
||||||
|
from garth.exc import GarthException
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -17,71 +18,61 @@ class GarminService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.username = os.getenv("GARMIN_USERNAME")
|
self.username = os.getenv("GARMIN_USERNAME")
|
||||||
self.password = os.getenv("GARMIN_PASSWORD")
|
self.password = os.getenv("GARMIN_PASSWORD")
|
||||||
logger.debug(f"GarminService initialized with username: {self.username is not None}, password: {self.password is not None}")
|
|
||||||
self.client: Optional[garth.Client] = None
|
|
||||||
self.session_dir = Path("data/sessions")
|
self.session_dir = Path("data/sessions")
|
||||||
|
|
||||||
# Ensure session directory exists
|
|
||||||
self.session_dir.mkdir(parents=True, exist_ok=True)
|
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
async def authenticate(self) -> bool:
|
async def authenticate(self) -> bool:
|
||||||
"""Authenticate with Garmin Connect and persist session."""
|
"""Authenticate with Garmin Connect and persist session."""
|
||||||
if not self.client:
|
|
||||||
self.client = garth.Client()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to load existing session
|
await asyncio.to_thread(garth.resume, self.session_dir)
|
||||||
await asyncio.to_thread(self.client.load, self.session_dir)
|
|
||||||
logger.info("Loaded existing Garmin session")
|
logger.info("Loaded existing Garmin session")
|
||||||
return True
|
except (FileNotFoundError, GarthException):
|
||||||
except Exception as e:
|
logger.warning("No existing session found. Attempting fresh authentication.")
|
||||||
logger.warning(f"Failed to load existing Garmin session: {e}. Attempting fresh authentication.")
|
|
||||||
# Fresh authentication required
|
|
||||||
if not self.username or not self.password:
|
if not self.username or not self.password:
|
||||||
logger.error("Garmin username or password not set in environment variables.")
|
logger.error("Garmin username or password not set in environment variables.")
|
||||||
raise GarminAuthError("Garmin username or password not configured.")
|
raise GarminAuthError("Garmin username or password not configured.")
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self.client.login, self.username, self.password)
|
await asyncio.to_thread(garth.login, self.username, self.password)
|
||||||
await asyncio.to_thread(self.client.save, self.session_dir)
|
await asyncio.to_thread(garth.save, self.session_dir)
|
||||||
logger.info("Successfully authenticated with Garmin Connect")
|
logger.info("Successfully authenticated with Garmin Connect")
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Garmin authentication failed: {str(e)}")
|
logger.error(f"Garmin authentication failed: {str(e)}")
|
||||||
raise GarminAuthError(f"Authentication failed: {str(e)}")
|
raise GarminAuthError(f"Authentication failed: {str(e)}")
|
||||||
|
return True
|
||||||
|
|
||||||
async def get_activities(self, limit: int = 10, start_date: datetime = None) -> List[Dict[str, Any]]:
|
async def get_activities(self, limit: int = 10, start_date: datetime = None) -> List[Dict[str, Any]]:
|
||||||
"""Fetch recent activities from Garmin Connect."""
|
"""Fetch recent activities from Garmin Connect."""
|
||||||
if not self.client:
|
await self.authenticate()
|
||||||
await self.authenticate()
|
|
||||||
|
|
||||||
if not start_date:
|
if not start_date:
|
||||||
start_date = datetime.now() - timedelta(days=7)
|
start_date = datetime.now() - timedelta(days=7)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
activities = await asyncio.to_thread(self.client.get_activities, limit=limit, start=start_date)
|
activities = await asyncio.to_thread(
|
||||||
|
garth.connectapi,
|
||||||
|
"/activity-service/activity/activities",
|
||||||
|
params={"limit": limit, "start": start_date.strftime("%Y-%m-%d")},
|
||||||
|
)
|
||||||
logger.info(f"Fetched {len(activities)} activities from Garmin")
|
logger.info(f"Fetched {len(activities)} activities from Garmin")
|
||||||
return activities
|
return activities or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch activities: {str(e)}")
|
logger.error(f"Failed to fetch activities: {str(e)}")
|
||||||
raise GarminAPIError(f"Failed to fetch activities: {str(e)}")
|
raise GarminAPIError(f"Failed to fetch activities: {str(e)}")
|
||||||
|
|
||||||
async def get_activity_details(self, activity_id: str) -> Dict[str, Any]:
|
async def get_activity_details(self, activity_id: str) -> Dict[str, Any]:
|
||||||
"""Get detailed activity data including metrics."""
|
"""Get detailed activity data including metrics."""
|
||||||
if not self.client:
|
await self.authenticate()
|
||||||
await self.authenticate()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
details = await asyncio.to_thread(self.client.get_activity, activity_id)
|
details = await asyncio.to_thread(
|
||||||
|
garth.connectapi, f"/activity-service/activity/{activity_id}"
|
||||||
|
)
|
||||||
logger.info(f"Fetched details for activity {activity_id}")
|
logger.info(f"Fetched details for activity {activity_id}")
|
||||||
return details
|
return details
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch activity details for {activity_id}: {str(e)}")
|
logger.error(f"Failed to fetch activity details for {activity_id}: {str(e)}")
|
||||||
raise GarminAPIError(f"Failed to fetch activity details: {str(e)}")
|
raise GarminAPIError(f"Failed to fetch activity details: {str(e)}")
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
|
||||||
"""Check if we have a valid authenticated session."""
|
|
||||||
return self.client is not None
|
|
||||||
|
|
||||||
|
|
||||||
class GarminAuthError(Exception):
|
class GarminAuthError(Exception):
|
||||||
"""Raised when Garmin authentication fails."""
|
"""Raised when Garmin authentication fails."""
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
||||||
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 backend.app.models.garmin_sync_log import GarminSyncLog
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
@@ -63,7 +62,7 @@ class WorkoutSyncService:
|
|||||||
synced_count += 1
|
synced_count += 1
|
||||||
|
|
||||||
# Update sync log
|
# Update sync log
|
||||||
sync_log.status = "success"
|
sync_log.status = GarminSyncStatus.COMPLETED
|
||||||
sync_log.activities_synced = synced_count
|
sync_log.activities_synced = synced_count
|
||||||
sync_log.last_sync_time = datetime.now()
|
sync_log.last_sync_time = datetime.now()
|
||||||
|
|
||||||
@@ -72,19 +71,19 @@ class WorkoutSyncService:
|
|||||||
return synced_count
|
return synced_count
|
||||||
|
|
||||||
except GarminAuthError as e:
|
except GarminAuthError as e:
|
||||||
sync_log.status = "auth_error"
|
sync_log.status = GarminSyncStatus.AUTH_FAILED
|
||||||
sync_log.error_message = str(e)
|
sync_log.error_message = str(e)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
logger.error(f"Garmin authentication failed: {str(e)}")
|
logger.error(f"Garmin authentication failed: {str(e)}")
|
||||||
raise
|
raise
|
||||||
except GarminAPIError as e:
|
except GarminAPIError as e:
|
||||||
sync_log.status = "api_error"
|
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()
|
||||||
logger.error(f"Garmin API error during sync: {str(e)}")
|
logger.error(f"Garmin API error during sync: {str(e)}")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sync_log.status = "error"
|
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()
|
||||||
logger.error(f"Unexpected error during sync: {str(e)}")
|
logger.error(f"Unexpected error during sync: {str(e)}")
|
||||||
@@ -104,7 +103,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)
|
||||||
)
|
)
|
||||||
return (await result.scalar_one_or_none()) is not None
|
return result.scalar_one_or_none() is not None # Remove the await here
|
||||||
|
|
||||||
async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]:
|
async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Parse Garmin activity data into workout model format."""
|
"""Parse Garmin activity data into workout model format."""
|
||||||
|
|||||||
@@ -2,4 +2,7 @@
|
|||||||
testpaths = tests
|
testpaths = tests
|
||||||
addopts = -p no:warnings --verbose
|
addopts = -p no:warnings --verbose
|
||||||
python_files = test_*.py
|
python_files = test_*.py
|
||||||
log_cli = true
|
log_cli = true
|
||||||
|
asyncio_mode = auto
|
||||||
|
markers =
|
||||||
|
asyncio: marks tests as async
|
||||||
@@ -1,67 +1,86 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlalchemy import select
|
||||||
from backend.app.database import Base
|
from backend.app.database import Base
|
||||||
from backend.app.services.workout_sync import WorkoutSyncService
|
from backend.app.services.workout_sync import WorkoutSyncService
|
||||||
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
||||||
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 datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from backend.app.config import Settings
|
||||||
|
|
||||||
# --- Fixtures for Functional Testing ---
|
# --- Completely Rewritten Fixtures ---
|
||||||
|
|
||||||
@pytest.fixture(name="async_engine")
|
@pytest.fixture(scope="function")
|
||||||
def async_engine_fixture():
|
def test_engine():
|
||||||
"""Provides an asynchronous engine for an in-memory SQLite database."""
|
"""Create a test engine for each test function."""
|
||||||
return create_async_engine(
|
engine = create_async_engine(
|
||||||
"sqlite+aiosqlite:///:memory:",
|
"sqlite+aiosqlite:///:memory:",
|
||||||
echo=False,
|
echo=False,
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
)
|
)
|
||||||
|
return engine
|
||||||
|
|
||||||
@pytest.fixture(name="async_session")
|
@pytest.fixture(scope="function")
|
||||||
async def async_session_fixture(async_engine):
|
async def setup_database(test_engine):
|
||||||
"""Provides an asynchronous session for an in-memory SQLite database."""
|
"""Set up the database schema."""
|
||||||
async with async_engine.begin() as conn:
|
async with test_engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
AsyncSessionLocal = sessionmaker(
|
async with test_engine.begin() as conn:
|
||||||
autocommit=False, autoflush=False, bind=async_engine, class_=AsyncSession
|
|
||||||
)
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
yield session
|
|
||||||
|
|
||||||
async with async_engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
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
|
@pytest.fixture
|
||||||
def mock_garmin_service():
|
def mock_garmin_service():
|
||||||
"""Mocks the GarminService for functional tests."""
|
"""Mock the GarminService for testing."""
|
||||||
with patch('backend.app.services.workout_sync.GarminService', autospec=True) as MockGarminService:
|
mock_service = MagicMock(spec=GarminService)
|
||||||
mock_instance = MockGarminService.return_value
|
mock_service.authenticate = AsyncMock(return_value=True)
|
||||||
mock_instance.login = AsyncMock(return_value=True)
|
mock_service.get_activities = AsyncMock(return_value=[])
|
||||||
mock_instance.get_activities = AsyncMock(return_value=[])
|
mock_service.get_activity_details = AsyncMock(return_value={})
|
||||||
mock_instance.get_activity_details = AsyncMock(return_value={})
|
return mock_service
|
||||||
yield mock_instance
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def workout_sync_service(async_session: AsyncSession, mock_garmin_service: MagicMock) -> WorkoutSyncService:
|
def settings() -> Settings:
|
||||||
"""Provides a WorkoutSyncService instance with a correctly resolved async session."""
|
"""Load settings from .env file."""
|
||||||
import asyncio
|
load_dotenv()
|
||||||
session = asyncio.run(async_session.__anext__())
|
return Settings()
|
||||||
service = WorkoutSyncService(db=session)
|
|
||||||
service.garmin_service = mock_garmin_service
|
@pytest.fixture
|
||||||
return service
|
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 ---
|
# --- Test Cases ---
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_successful_sync_functional(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock):
|
async def test_successful_sync_functional(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
||||||
"""Test successful synchronization of recent activities."""
|
"""Test successful synchronization of recent activities."""
|
||||||
|
# Create service with the actual session
|
||||||
|
service = WorkoutSyncService(db=db_session)
|
||||||
|
service.garmin_service = mock_garmin_service
|
||||||
|
|
||||||
# Arrange
|
# Arrange
|
||||||
mock_garmin_service.get_activities.return_value = [
|
mock_garmin_service.get_activities.return_value = [
|
||||||
{
|
{
|
||||||
@@ -83,79 +102,106 @@ async def test_successful_sync_functional(workout_sync_service: WorkoutSyncServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
synced_count = await service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert synced_count == 1
|
assert synced_count == 1
|
||||||
|
|
||||||
# Verify workout in DB
|
# Verify workout in DB
|
||||||
workouts = (await async_session.execute(select(Workout))).scalars().all()
|
result = await db_session.execute(select(Workout))
|
||||||
|
workouts = result.scalars().all()
|
||||||
assert len(workouts) == 1
|
assert len(workouts) == 1
|
||||||
assert workouts[0].garmin_activity_id == '1001'
|
assert workouts[0].garmin_activity_id == '1001'
|
||||||
assert workouts[0].activity_type == 'cycling'
|
assert workouts[0].activity_type == 'cycling'
|
||||||
assert workouts[0].avg_power == 200
|
assert workouts[0].avg_power == 200.0
|
||||||
assert 'temperature' in workouts[0].metrics
|
assert 'temperature' in workouts[0].metrics
|
||||||
|
|
||||||
# Verify sync log in DB
|
# Verify sync log in DB
|
||||||
sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all()
|
result = await db_session.execute(select(GarminSyncLog))
|
||||||
|
sync_logs = result.scalars().all()
|
||||||
assert len(sync_logs) == 1
|
assert len(sync_logs) == 1
|
||||||
assert sync_logs[0].status == 'success'
|
assert sync_logs[0].status == GarminSyncStatus.COMPLETED
|
||||||
assert sync_logs[0].activities_synced == 1
|
assert sync_logs[0].activities_synced == 1
|
||||||
assert sync_logs[0].error_message is None
|
assert sync_logs[0].error_message is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_with_no_new_activities(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock):
|
async def test_sync_with_no_new_activities(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
||||||
"""Test synchronization when no new activities are found."""
|
"""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
|
# Arrange
|
||||||
mock_garmin_service.get_activities.return_value = [] # No activities
|
mock_garmin_service.get_activities.return_value = [] # No activities
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
synced_count = await service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert synced_count == 0
|
assert synced_count == 0
|
||||||
workouts = (await async_session.execute(select(Workout))).scalars().all()
|
result = await db_session.execute(select(Workout))
|
||||||
|
workouts = result.scalars().all()
|
||||||
assert len(workouts) == 0
|
assert len(workouts) == 0
|
||||||
sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all()
|
|
||||||
|
result = await db_session.execute(select(GarminSyncLog))
|
||||||
|
sync_logs = result.scalars().all()
|
||||||
assert len(sync_logs) == 1
|
assert len(sync_logs) == 1
|
||||||
assert sync_logs[0].status == 'success'
|
assert sync_logs[0].status == GarminSyncStatus.COMPLETED
|
||||||
assert sync_logs[0].activities_synced == 0
|
assert sync_logs[0].activities_synced == 0
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_with_authentication_error(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock):
|
async def test_sync_with_authentication_error(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
||||||
"""Test synchronization failure due to Garmin authentication error."""
|
"""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
|
# Arrange
|
||||||
mock_garmin_service.get_activities.side_effect = GarminAuthError("Invalid credentials")
|
mock_garmin_service.get_activities.side_effect = GarminAuthError("Invalid credentials")
|
||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(GarminAuthError):
|
with pytest.raises(GarminAuthError):
|
||||||
await workout_sync_service.sync_recent_activities(days_back=7)
|
await service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
# Verify sync log in DB
|
# Verify sync log in DB
|
||||||
sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all()
|
result = await db_session.execute(select(GarminSyncLog))
|
||||||
|
sync_logs = result.scalars().all()
|
||||||
assert len(sync_logs) == 1
|
assert len(sync_logs) == 1
|
||||||
assert sync_logs[0].status == 'auth_error'
|
assert sync_logs[0].status == GarminSyncStatus.AUTH_FAILED
|
||||||
assert "Invalid credentials" in sync_logs[0].error_message
|
assert "Invalid credentials" in sync_logs[0].error_message
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_with_api_error(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock):
|
async def test_sync_with_api_error(db_session: AsyncSession, mock_garmin_service: MagicMock):
|
||||||
"""Test synchronization failure due to general Garmin API error."""
|
"""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
|
# Arrange
|
||||||
mock_garmin_service.get_activities.side_effect = GarminAPIError("Garmin service unavailable")
|
mock_garmin_service.get_activities.side_effect = GarminAPIError("Garmin service unavailable")
|
||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(GarminAPIError):
|
with pytest.raises(GarminAPIError):
|
||||||
await workout_sync_service.sync_recent_activities(days_back=7)
|
await service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
# Verify sync log in DB
|
# Verify sync log in DB
|
||||||
sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all()
|
result = await db_session.execute(select(GarminSyncLog))
|
||||||
|
sync_logs = result.scalars().all()
|
||||||
assert len(sync_logs) == 1
|
assert len(sync_logs) == 1
|
||||||
assert sync_logs[0].status == 'api_error'
|
assert sync_logs[0].status == GarminSyncStatus.FAILED
|
||||||
assert "Garmin service unavailable" in sync_logs[0].error_message
|
assert "Garmin service unavailable" in sync_logs[0].error_message
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_with_activity_details_retry_success(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock):
|
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."""
|
"""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
|
# Arrange
|
||||||
mock_garmin_service.get_activities.return_value = [
|
mock_garmin_service.get_activities.return_value = [
|
||||||
{
|
{
|
||||||
@@ -175,24 +221,31 @@ async def test_sync_with_activity_details_retry_success(workout_sync_service: Wo
|
|||||||
# 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=AsyncMock) as mock_sleep:
|
||||||
synced_count = await workout_sync_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_awaited_with(1) # First retry delay
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert synced_count == 1
|
assert synced_count == 1
|
||||||
workouts = (await async_session.execute(select(Workout))).scalars().all()
|
result = await db_session.execute(select(Workout))
|
||||||
|
workouts = result.scalars().all()
|
||||||
assert len(workouts) == 1
|
assert len(workouts) == 1
|
||||||
assert workouts[0].garmin_activity_id == '1002'
|
assert workouts[0].garmin_activity_id == '1002'
|
||||||
assert workouts[0].avg_hr == 160
|
assert workouts[0].avg_hr == 160
|
||||||
assert mock_garmin_service.get_activity_details.call_count == 2
|
assert mock_garmin_service.get_activity_details.call_count == 2
|
||||||
|
|
||||||
sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all()
|
result = await db_session.execute(select(GarminSyncLog))
|
||||||
|
sync_logs = result.scalars().all()
|
||||||
assert len(sync_logs) == 1
|
assert len(sync_logs) == 1
|
||||||
assert sync_logs[0].status == 'success'
|
assert sync_logs[0].status == GarminSyncStatus.COMPLETED
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_with_activity_details_retry_failure(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock):
|
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."""
|
"""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
|
# Arrange
|
||||||
mock_garmin_service.get_activities.return_value = [
|
mock_garmin_service.get_activities.return_value = [
|
||||||
{
|
{
|
||||||
@@ -213,23 +266,30 @@ async def test_sync_with_activity_details_retry_failure(workout_sync_service: Wo
|
|||||||
# 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=AsyncMock) as mock_sleep:
|
||||||
await workout_sync_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_awaited_with(4) # Last retry delay (2**(3-1))
|
||||||
|
|
||||||
# Verify sync log in DB
|
# Verify sync log in DB
|
||||||
sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all()
|
result = await db_session.execute(select(GarminSyncLog))
|
||||||
|
sync_logs = result.scalars().all()
|
||||||
assert len(sync_logs) == 1
|
assert len(sync_logs) == 1
|
||||||
assert sync_logs[0].status == 'api_error'
|
assert sync_logs[0].status == GarminSyncStatus.FAILED
|
||||||
assert "Service unavailable" in sync_logs[0].error_message
|
assert "Service unavailable" in sync_logs[0].error_message
|
||||||
|
|
||||||
# No workout should be saved
|
# No workout should be saved
|
||||||
workouts = (await async_session.execute(select(Workout))).scalars().all()
|
result = await db_session.execute(select(Workout))
|
||||||
|
workouts = result.scalars().all()
|
||||||
assert len(workouts) == 0
|
assert len(workouts) == 0
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_with_duplicate_activities_in_garmin_feed(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock):
|
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."""
|
"""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
|
# Arrange
|
||||||
# First sync: add activity 1004
|
# First sync: add activity 1004
|
||||||
mock_garmin_service.get_activities.return_value = [
|
mock_garmin_service.get_activities.return_value = [
|
||||||
@@ -242,7 +302,7 @@ async def test_sync_with_duplicate_activities_in_garmin_feed(workout_sync_servic
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
mock_garmin_service.get_activity_details.return_value = {'averageHR': 140}
|
mock_garmin_service.get_activity_details.return_value = {'averageHR': 140}
|
||||||
await workout_sync_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
|
||||||
mock_garmin_service.get_activities.return_value = [
|
mock_garmin_service.get_activities.return_value = [
|
||||||
@@ -261,18 +321,46 @@ async def test_sync_with_duplicate_activities_in_garmin_feed(workout_sync_servic
|
|||||||
'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 = {'averageHR': 130} # for activity 1005
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
synced_count = await service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert synced_count == 1 # Only 1005 should be synced
|
assert synced_count == 1 # Only 1005 should be synced
|
||||||
workouts = (await async_session.execute(select(Workout))).scalars().all()
|
result = await db_session.execute(select(Workout))
|
||||||
|
workouts = result.scalars().all()
|
||||||
assert len(workouts) == 2
|
assert len(workouts) == 2
|
||||||
assert any(w.garmin_activity_id == '1004' for w in workouts)
|
assert any(w.garmin_activity_id == '1004' for w in workouts)
|
||||||
assert any(w.garmin_activity_id == '1005' for w in workouts)
|
assert any(w.garmin_activity_id == '1005' for w in workouts)
|
||||||
|
|
||||||
sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all()
|
result = await db_session.execute(select(GarminSyncLog))
|
||||||
|
sync_logs = result.scalars().all()
|
||||||
assert len(sync_logs) == 2
|
assert len(sync_logs) == 2
|
||||||
assert sync_logs[1].activities_synced == 1 # Second log should show 1 activity synced
|
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
|
||||||
4741
git_scape_garth_digest.txt
Executable file
4741
git_scape_garth_digest.txt
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user