import pytest from unittest.mock import MagicMock, patch, ANY from datetime import datetime, timedelta import json from sqlalchemy.orm import Session from src.services.sync_app import SyncApp from src.services.garmin.client import GarminClient from src.models.activity import Activity from src.models.health_metric import HealthMetric from src.models.sync_log import SyncLog from src.models.activity_state import GarminActivityState from src.models.health_state import HealthSyncState @pytest.fixture def mock_db_session(): """Fixture for a mock SQLAlchemy session.""" session = MagicMock(spec=Session) # Default behavior: return None session.query.return_value.filter_by.return_value.first.return_value = None session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [] session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] return session @pytest.fixture def mock_garmin_client(): """Fixture for a mock GarminClient.""" client = MagicMock(spec=GarminClient) client.is_connected = True # Important: mock client attribute for internal access (e.g. client.client.get_activity) client.client = MagicMock() return client @pytest.fixture def sync_app_instance(mock_db_session, mock_garmin_client): """Fixture for a SyncApp instance.""" return SyncApp(db_session=mock_db_session, garmin_client=mock_garmin_client) # --- Tests for sync_activities --- def test_sync_activities_no_activities(sync_app_instance, mock_garmin_client, mock_db_session): """Test sync_activities when no activities are fetched from Garmin.""" mock_garmin_client.get_activities.return_value = [] result = sync_app_instance.sync_activities(days_back=1) mock_garmin_client.get_activities.assert_called_once() assert result == {"processed": 0, "failed": 0} def test_sync_activities_success_new_activity(sync_app_instance, mock_garmin_client, mock_db_session): """Test sync_activities for a new activity.""" garmin_activity_data = { "activityId": "1", "activityName": "Run", "activityType": {"typeKey": "running"}, "startTimeLocal": "2023-01-01T10:00:00", "duration": 3600, } mock_garmin_client.get_activities.return_value = [garmin_activity_data] mock_garmin_client.download_activity.return_value = b"fit_content" # Mock full details fetch mock_garmin_client.client.get_activity.return_value = {"activityId": "1", "summaryDTO": {}} mock_state = GarminActivityState( garmin_activity_id="1", sync_status="new", activity_name="Run", activity_type="running", start_time=datetime(2023,1,1,10,0,0) ) # Mock Activity record that will be found during redownload mock_activity = Activity(garmin_activity_id="1", download_status="pending") def query_side_effect(model): m = MagicMock() if model == GarminActivityState: q_pending = MagicMock() q_pending.order_by.return_value.limit.return_value.all.return_value = [mock_state] q_pending.order_by.return_value.all.return_value = [mock_state] q_scan = MagicMock() q_scan.first.return_value = None # Scan finds nothing m.filter.return_value = q_pending m.filter_by.return_value = q_scan return m if model == Activity: # Scan checks Activity -> Pending status or None? # If we return mock_activity (pending), scan sees is_downloaded=False. Correct. # Redownload checks Activity -> Pending. m.filter_by.return_value.first.return_value = mock_activity return m return m mock_db_session.query.side_effect = query_side_effect result = sync_app_instance.sync_activities(days_back=1) mock_garmin_client.download_activity.assert_called_once_with(ANY, file_type='fit') assert result['processed'] == 1 assert result['failed'] == 0 def test_sync_activities_already_downloaded(sync_app_instance, mock_garmin_client, mock_db_session): """Test sync_activities already downloaded.""" garmin_activity_data = {"activityId": "2", "activityType": {"typeKey": "walking"}} mock_garmin_client.get_activities.return_value = [garmin_activity_data] # Mock DB existing_activity = Activity(garmin_activity_id="2", download_status="downloaded", file_content=b"content") def query_side_effect(model): m = MagicMock() if model == Activity: m.filter_by.return_value.first.return_value = existing_activity return m if model == GarminActivityState: m.filter_by.return_value.first.return_value = None # Scan sees no state # Pending query -> Empty q_pending = MagicMock() q_pending.order_by.return_value.all.return_value = [] q_pending.order_by.return_value.limit.return_value.all.return_value = [] m.filter.return_value = q_pending return m return m mock_db_session.query.side_effect = query_side_effect result = sync_app_instance.sync_activities(days_back=1) mock_garmin_client.download_activity.assert_not_called() assert result == {"processed": 0, "failed": 0} def test_sync_activities_download_failure(sync_app_instance, mock_garmin_client, mock_db_session): """Test sync_activities download failure.""" garmin_activity_data = {"activityId": "3", "activityName": "Swim"} mock_garmin_client.get_activities.return_value = [garmin_activity_data] mock_garmin_client.download_activity.return_value = None # Fail mock_garmin_client.client.get_activity.return_value = {} mock_state = GarminActivityState(garmin_activity_id="3", sync_status="new") mock_activity = Activity(garmin_activity_id="3", download_status="pending") def query_side_effect(model): m = MagicMock() if model == GarminActivityState: q_pending = MagicMock() q_pending.order_by.return_value.limit.return_value.all.return_value = [mock_state] q_pending.order_by.return_value.all.return_value = [mock_state] m.filter.return_value = q_pending q_scan = MagicMock() q_scan.first.return_value = None m.filter_by.return_value = q_scan return m if model == Activity: m.filter_by.return_value.first.return_value = mock_activity return m return m mock_db_session.query.side_effect = query_side_effect result = sync_app_instance.sync_activities(days_back=1) assert mock_garmin_client.download_activity.call_count == 4 assert result['failed'] == 1 # --- Tests for sync_health_metrics --- @patch('src.services.sync.health.update_or_create_health_metric') def test_sync_health_metrics_success(mock_update_or_create_metric, sync_app_instance, mock_garmin_client, mock_db_session): """Test successful fetching and processing of health metrics.""" mock_garmin_client.get_all_metrics_for_date.return_value = { "steps": {"totalSteps": 10000}, "hrv": {"lastNightAvg": 50}, "sleep": {"dailySleepDTO": {"sleepTimeSeconds": 28800}} } mock_update_or_create_metric.return_value = 'new' result = sync_app_instance.sync_health_metrics(days_back=1) assert mock_garmin_client.get_all_metrics_for_date.call_count == 2 assert mock_update_or_create_metric.call_count == 6 assert result == {"processed": 6, "failed": 0} @patch('src.services.sync.health.update_or_create_health_metric') def test_sync_health_metrics_partial_failure(mock_update_or_create_metric, sync_app_instance, mock_garmin_client, mock_db_session): """Test partial failure during health metrics processing.""" mock_garmin_client.get_all_metrics_for_date.return_value = { "steps": {"totalSteps": 10000}, "hrv": {"lastNightAvg": 50}, "sleep": {"dailySleepDTO": {"sleepTimeSeconds": 28800}} } mock_update_or_create_metric.side_effect = [ 'new', 'error', 'new', # Day 1: Steps OK, HRV Error, Sleep OK 'new', 'new', 'new' # Day 2: All OK ] result = sync_app_instance.sync_health_metrics(days_back=1) assert mock_update_or_create_metric.call_count == 6 assert result["processed"] == 5 # 1 error assert result["failed"] == 0 # Errors inside metric processing aren't counted as 'failed' in stats dict, only in logs? # sync_health_metrics: failed_count is incremented if EXCEPTION occurs outside _process_day_metrics_dict. # But _process_day_metrics_dict swallows exceptions? # No, utils returns 'error'. # _process_day_metrics_dict calls update_stat(..., status=='updated'). # 'error' status is neither new nor updated. # So 'synced' count is NOT incremented. # So processed count should satisfy 5. @patch('src.services.sync_app.update_or_create_health_metric') def test_wrapper_update_metric(mock_utils_fn, sync_app_instance): sync_app_instance._update_or_create_metric("steps", datetime(2023,1,1).date(), 100, "steps") mock_utils_fn.assert_called_once()