added activity view

This commit is contained in:
2026-01-09 09:59:36 -08:00
parent c45e41b6a9
commit 55e37fbca8
168 changed files with 8799 additions and 2426 deletions

View File

@@ -2,8 +2,6 @@ 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
@@ -11,13 +9,17 @@ 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
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
@@ -25,6 +27,8 @@ 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
@@ -34,20 +38,17 @@ def sync_app_instance(mock_db_session, 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."""
"""Test sync_activities for a new activity."""
garmin_activity_data = {
"activityId": "1",
"activityName": "Run",
@@ -56,144 +57,166 @@ def test_sync_activities_success_new_activity(sync_app_instance, mock_garmin_cli
"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"
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.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"
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 when activity is already downloaded."""
garmin_activity_data = {
"activityId": "2",
"activityName": "Walk",
"activityType": {"typeKey": "walking"},
"startTimeLocal": "2023-01-02T11:00:00",
"duration": 1800,
}
"""Test sync_activities already downloaded."""
garmin_activity_data = {"activityId": "2", "activityType": {"typeKey": "walking"}}
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
# 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.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
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 when download fails for all formats."""
garmin_activity_data = {
"activityId": "3",
"activityName": "Swim",
"activityType": {"typeKey": "swimming"},
"startTimeLocal": "2023-01-03T12:00:00",
"duration": 2700,
}
"""Test sync_activities download failure."""
garmin_activity_data = {"activityId": "3", "activityName": "Swim"}
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
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)
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
assert mock_garmin_client.download_activity.call_count == 4
assert result['failed'] == 1
# --- Tests for sync_health_metrics ---
@patch.object(SyncApp, '_update_or_create_metric')
@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_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_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)
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
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.object(SyncApp, '_update_or_create_metric')
@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_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_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 = [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
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.
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
@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()