import pytest from unittest.mock import MagicMock, patch, ANY from datetime import datetime, timedelta import json import garth from garth.exc import GarthException 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.api_token import APIToken # Needed for AuthMixin @pytest.fixture def mock_db_session(): """Fixture for a mock SQLAlchemy session.""" session = MagicMock(spec=Session) session.query.return_value.filter_by.return_value.first.return_value = None return session @pytest.fixture def mock_garmin_client(): """Fixture for a mock GarminClient.""" client = MagicMock(spec=GarminClient) client.is_connected = True 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} mock_db_session.add.assert_called_once() # For sync_log assert mock_db_session.commit.call_count == 2 # Initial commit for sync_log, final commit def test_sync_activities_success_new_activity(sync_app_instance, mock_garmin_client, mock_db_session): """Test sync_activities for a new activity, successfully downloaded.""" 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_db_session.query.return_value.filter_by.return_value.first.return_value = None # No existing activity mock_garmin_client.download_activity.return_value = b"tcx_content" result = sync_app_instance.sync_activities(days_back=1) mock_garmin_client.get_activities.assert_called_once() mock_garmin_client.download_activity.assert_called_once_with(ANY, file_type='original') # Checks if called with any activity_id and 'original' type assert mock_db_session.add.call_count == 2 # sync_log and new activity assert mock_db_session.commit.call_count == 3 # Initial commit for sync_log, commit after activity, final commit assert result == {"processed": 1, "failed": 0} # Verify activity saved (check the second add call) activity_record = mock_db_session.add.call_args_list[1][0][0] assert isinstance(activity_record, Activity) assert activity_record.garmin_activity_id == "1" assert activity_record.activity_type == "running" assert activity_record.file_content == b"tcx_content" assert activity_record.file_type == "original" # Should be original if first format succeeded assert activity_record.download_status == "downloaded" def test_sync_activities_already_downloaded(sync_app_instance, mock_garmin_client, mock_db_session): """Test sync_activities when activity is already downloaded.""" garmin_activity_data = { "activityId": "2", "activityName": "Walk", "activityType": {"typeKey": "walking"}, "startTimeLocal": "2023-01-02T11:00:00", "duration": 1800, } mock_garmin_client.get_activities.return_value = [garmin_activity_data] # Mock existing activity in DB existing_activity = Activity(garmin_activity_id="2", download_status="downloaded", file_content=b"old_content") mock_db_session.query.return_value.filter_by.return_value.first.return_value = existing_activity result = sync_app_instance.sync_activities(days_back=1) mock_garmin_client.get_activities.assert_called_once() mock_garmin_client.download_activity.assert_not_called() # Should not try to download again mock_db_session.add.assert_called_once_with(ANY) # For sync_log only assert mock_db_session.commit.call_count == 3 # Initial commit for sync_log, loop commit (0 activities), final commit assert result == {"processed": 0, "failed": 0} # No new processed/failed due to skipping def test_sync_activities_download_failure(sync_app_instance, mock_garmin_client, mock_db_session): """Test sync_activities when download fails for all formats.""" garmin_activity_data = { "activityId": "3", "activityName": "Swim", "activityType": {"typeKey": "swimming"}, "startTimeLocal": "2023-01-03T12:00:00", "duration": 2700, } mock_garmin_client.get_activities.return_value = [garmin_activity_data] mock_db_session.query.return_value.filter_by.return_value.first.return_value = None mock_garmin_client.download_activity.return_value = None # Download fails for all formats result = sync_app_instance.sync_activities(days_back=1) mock_garmin_client.get_activities.assert_called_once() assert mock_garmin_client.download_activity.call_count == 4 # Tries 'original', 'tcx', 'gpx', 'fit' assert mock_db_session.add.call_count == 2 # For sync_log and new activity assert mock_db_session.commit.call_count == 3 # Initial commit for sync_log, commit after activity, final commit assert result == {"processed": 0, "failed": 1} # Verify activity marked as failed activity_record = mock_db_session.add.call_args_list[1][0][0] assert activity_record.garmin_activity_id == "3" assert activity_record.download_status == "failed" assert activity_record.file_content is None # --- Tests for sync_health_metrics --- @patch.object(SyncApp, '_update_or_create_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_daily_metrics.return_value = { "steps": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), total_steps=10000)], "hrv": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), last_night_avg=50)], "sleep": [MagicMock(daily_sleep_dto=MagicMock(calendar_date=datetime(2023, 1, 1).date(), sleep_time_seconds=28800))] } result = sync_app_instance.sync_health_metrics(days_back=1) mock_garmin_client.get_daily_metrics.assert_called_once() assert mock_update_or_create_metric.call_count == 3 # Steps, HRV, Sleep assert result == {"processed": 3, "failed": 0} mock_db_session.add.assert_called_once() # For sync_log assert mock_db_session.commit.call_count == 2 # Initial commit for sync_log, final commit @patch.object(SyncApp, '_update_or_create_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_daily_metrics.return_value = { "steps": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), total_steps=10000)], "hrv": [MagicMock(calendar_date=datetime(2023, 1, 1).date(), last_night_avg=50)], "sleep": [MagicMock(daily_sleep_dto=MagicMock(calendar_date=datetime(2023, 1, 1).date(), sleep_time_seconds=28800))] } mock_update_or_create_metric.side_effect = [None, Exception("HRV save error"), None] # Steps OK, HRV fails, Sleep OK result = sync_app_instance.sync_health_metrics(days_back=1) assert mock_update_or_create_metric.call_count == 3 assert result == {"processed": 2, "failed": 1} # 2 successful, 1 failed mock_db_session.add.assert_called_once() assert mock_db_session.commit.call_count == 2 # --- Tests for _update_or_create_metric --- def test_update_or_create_metric_create_new(sync_app_instance, mock_db_session): """Test _update_or_create_metric creates a new metric.""" mock_db_session.query.return_value.filter_by.return_value.first.return_value = None # No existing metric sync_app_instance._update_or_create_metric("steps", datetime(2023, 1, 1).date(), 10000, "steps") mock_db_session.add.assert_called_once() mock_db_session.commit.assert_called_once() metric_record = mock_db_session.add.call_args[0][0] assert isinstance(metric_record, HealthMetric) assert metric_record.metric_type == "steps" assert metric_record.metric_value == 10000 def test_update_or_create_metric_update_existing(sync_app_instance, mock_db_session): """Test _update_or_create_metric updates an existing metric.""" existing_metric = HealthMetric( metric_type="steps", date=datetime(2023, 1, 1).date(), metric_value=5000, unit="steps", source="garmin" ) mock_db_session.query.return_value.filter_by.return_value.first.return_value = existing_metric sync_app_instance._update_or_create_metric("steps", datetime(2023, 1, 1).date(), 12000, "steps") mock_db_session.add.assert_not_called() # Should not add new record mock_db_session.commit.assert_called_once() assert existing_metric.metric_value == 12000 assert existing_metric.updated_at is not None