223 lines
9.1 KiB
Python
223 lines
9.1 KiB
Python
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()
|