added garmin functional tests

This commit is contained in:
2025-09-28 07:09:36 -07:00
parent 88fb6a601a
commit dcc7f4e6fa
5 changed files with 4931 additions and 109 deletions

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff