Update spec files to match current implementation state

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2025-12-25 13:25:29 -08:00
parent 85e95f64d6
commit b9291861da
56 changed files with 943 additions and 36 deletions

View File

@@ -0,0 +1,228 @@
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta
import json
import garth
from garth.exc import GarthException
from sqlalchemy.orm import Session
from src.services.garmin.client import GarminClient
from src.models.api_token import APIToken
from garth.http import Client # Import Client for mocking
@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 garmin_client_instance():
"""Fixture for a GarminClient instance with test credentials."""
return GarminClient(username="testuser", password="testpassword", is_china=False)
@pytest.fixture
def garmin_client_mfa_instance():
"""Fixture for a GarminClient instance for MFA testing."""
return GarminClient(username="testmfauser", password="testmfapassword", is_china=False)
@patch("src.services.garmin.auth.garth.login")
@patch("src.services.garmin.auth.garth.client")
def test_login_success(mock_garth_client, mock_garth_login, mock_db_session, garmin_client_instance):
"""Test successful login scenario."""
# Mock garth.login to return successfully
mock_garth_login.return_value = (None, None) # Placeholder for successful return
# Mock garth.client tokens
mock_garth_client.oauth1_token = {"oauth1": "token"}
mock_garth_client.oauth2_token = {"oauth2": "token"}
# Call the login method
status = garmin_client_instance.login(mock_db_session)
# Assertions
mock_garth_login.assert_called_once_with("testuser", "testpassword")
assert status == "success"
assert garmin_client_instance.is_connected is True
# Verify update_tokens was called and session committed
mock_db_session.query.return_value.filter_by.return_value.first.assert_called_once()
mock_db_session.add.called = False # Reset add mock if it was called before update_tokens
# patch update_tokens to prevent it from failing tests.
with patch.object(garmin_client_instance, 'update_tokens') as mock_update_tokens:
garmin_client_instance.login(mock_db_session)
mock_update_tokens.assert_called_once_with(mock_db_session, {"oauth1": "token"}, {"oauth2": "token"})
# Verify token record attributes (mocked API_Token)
# The actual token record is added via update_tokens, which we are patching.
# To properly test this, we'd need to mock update_tokens more deeply or test it separately.
# For now, we'll ensure update_tokens was called with the right arguments.
# token_record = mock_db_session.add.call_args[0][0] # This won't work if update_tokens is patched
@patch("src.services.garmin.auth.garth.login")
@patch("src.services.garmin.auth.garth.client")
def test_login_mfa_required(mock_garth_client, mock_garth_login, mock_db_session, garmin_client_mfa_instance):
"""Test login scenario when MFA is required."""
# Mock garth.login to raise GarthException indicating MFA
mock_garth_login.side_effect = GarthException("needs-mfa")
# Mock garth.client.mfa_state
mock_client_for_mfa = MagicMock()
mock_client_for_mfa._session = MagicMock() # Mock _session
mock_client_for_mfa._session.cookies.get_dict.return_value = {"cookie1": "val1"}
mock_client_for_mfa.domain = "garmin.com" # Ensure domain returns a string
mock_garth_client.mfa_state = {
"signin_params": {"param1": "value1"},
"client": mock_client_for_mfa
}
# Call the login method
status = garmin_client_mfa_instance.login(mock_db_session)
# Assertions
mock_garth_login.assert_called_once_with("testmfauser", "testmfapassword")
assert status == "mfa_required"
assert garmin_client_mfa_instance.is_connected is False
# Verify initiate_mfa was called and session committed
mock_db_session.query.return_value.filter_by.return_value.first.assert_called_once()
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
# Verify mfa_state record attributes
token_record = mock_db_session.add.call_args[0][0]
mfa_state_data = json.loads(token_record.mfa_state)
assert mfa_state_data["signin_params"] == {"param1": "value1"}
assert mfa_state_data["cookies"] == {"cookie1": "val1"}
assert mfa_state_data["domain"] == "garmin.com"
@patch("src.services.garmin.auth.garth.login")
def test_login_failure(mock_garth_login, mock_db_session, garmin_client_instance):
"""Test login scenario when authentication fails (not MFA)."""
# Mock garth.login to raise a generic GarthException
mock_garth_login.side_effect = GarthException("Invalid credentials")
# Call the login method
status = garmin_client_instance.login(mock_db_session)
# Assertions
mock_garth_login.assert_called_once_with("testuser", "testpassword")
assert status == "error"
assert garmin_client_instance.is_connected is False
mock_db_session.commit.assert_not_called() # No commit on failure
@patch("src.services.garmin.auth.garth.client.resume_login")
@patch("garth.http.Client")
@patch("src.services.garmin.auth.garth.client") # Patch garth.client itself
@patch.object(GarminClient, 'update_tokens')
def test_handle_mfa_success(mock_update_tokens, mock_garth_client_global, mock_garth_client_class, mock_garth_resume_login, mock_db_session, garmin_client_instance):
"""Test successful MFA completion."""
# Setup mock MFA state in DB
mfa_state_data = {
"signin_params": {"param1": "value1"},
"cookies": {"cookie1": "val1"},
"domain": "garmin.com"
}
mock_token_record = MagicMock(spec=APIToken)
mock_token_record.mfa_state = json.dumps(mfa_state_data)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_token_record
# Mock the Client constructor
mock_client_instance = MagicMock(spec=Client)
mock_client_instance.domain = mfa_state_data["domain"]
mock_client_instance._session = MagicMock() # Mock the _session
mock_client_instance._session.cookies = MagicMock() # Mock cookies
mock_client_instance._session.cookies.update = MagicMock() # Mock update method
mock_garth_client_class.return_value = mock_client_instance # When Client() is called, return this mock
# Mock garth.resume_login to succeed
mock_garth_resume_login.return_value = ({"oauth1": "token"}, {"oauth2": "token"})
# Explicitly set the values on the global garth.client mock
mock_garth_client_global.oauth1_token = {"oauth1": "token_updated"}
mock_garth_client_global.oauth2_token = {"oauth2": "token_updated"}
# Call handle_mfa
result = garmin_client_instance.handle_mfa(mock_db_session, "123456")
# Assertions
mock_garth_client_class.assert_called_once_with(domain=mfa_state_data["domain"])
mock_client_instance._session.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
# We'll assert that resume_login was called once, and then check its arguments
call_args, call_kwargs = mock_garth_resume_login.call_args
assert call_args[1] == "123456" # Second arg is verification_code
passed_mfa_state = call_args[0] # First arg is the mfa_state dict
assert passed_mfa_state["signin_params"] == mfa_state_data["signin_params"]
assert passed_mfa_state["client"] is mock_client_instance # Ensure the reconstructed client is passed
assert result is True
# Verify update_tokens was called with the correct arguments
mock_update_tokens.assert_called_once_with(mock_db_session, {"oauth1": "token_updated"}, {"oauth2": "token_updated"})
mock_db_session.commit.assert_not_called() # update_tokens will commit
@patch("src.services.garmin.auth.garth.client.resume_login")
@patch("garth.http.Client")
@patch("src.services.garmin.auth.garth.client") # Patch garth.client itself
@patch.object(GarminClient, 'update_tokens')
def test_handle_mfa_failure(mock_update_tokens, mock_garth_client_global, mock_garth_client_class, mock_garth_resume_login, mock_db_session, garmin_client_instance):
"""Test MFA completion failure due to GarthException."""
# Setup mock MFA state in DB
mfa_state_data = {
"signin_params": {"param1": "value1"},
"cookies": {"cookie1": "val1"},
"domain": "garmin.com"
}
mock_token_record = MagicMock(spec=APIToken)
mock_token_record.mfa_state = json.dumps(mfa_state_data)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_token_record
# Mock the Client constructor
mock_client_instance = MagicMock(spec=Client)
mock_client_instance.domain = mfa_state_data["domain"]
mock_client_instance._session = MagicMock() # Mock the _session
mock_client_instance._session.cookies = MagicMock() # Mock cookies
mock_client_instance._session.cookies.update = MagicMock() # Mock update method
mock_garth_client_class.return_value = mock_client_instance
# Mock garth.resume_login to raise GarthException
mock_garth_resume_login.side_effect = GarthException("Invalid MFA code")
# Call handle_mfa and expect an exception
with pytest.raises(GarthException, match="Invalid MFA code"):
garmin_client_instance.handle_mfa(mock_db_session, "wrongcode")
# Explicitly set the values on the global garth.client mock after failure (shouldn't be set by successful resume_login)
mock_garth_client_global.oauth1_token = None
mock_garth_client_global.oauth2_token = None
mock_garth_client_class.assert_called_once_with(domain=mfa_state_data["domain"])
mock_client_instance._session.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
mock_garth_resume_login.assert_called_once()
mock_update_tokens.assert_not_called()
mock_db_session.commit.assert_not_called() # No commit on failure
def test_handle_mfa_no_pending_state(mock_db_session, garmin_client_instance):
"""Test MFA completion when no pending MFA state is found."""
# Mock no MFA state in DB
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
# Call handle_mfa and expect an exception
with pytest.raises(Exception, match="No pending MFA session found."):
garmin_client_instance.handle_mfa(mock_db_session, "123456")
mock_db_session.commit.assert_not_called()

View File

@@ -0,0 +1,115 @@
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta
import garth
from garth.exc import GarthException
from src.services.garmin.client import GarminClient
from src.models.api_token import APIToken # Needed for AuthMixin
@pytest.fixture
def garmin_client_instance():
"""Fixture for a GarminClient instance with test credentials."""
client = GarminClient(username="testuser", password="testpassword")
client.is_connected = True # Assume connected for data tests
return client
@patch("garth.client.connectapi")
def test_get_activities_success(mock_connectapi, garmin_client_instance):
"""Test successful fetching of activities."""
mock_connectapi.return_value = [{"activityId": 1, "activityName": "Run"}, {"activityId": 2, "activityName": "Bike"}]
start_date = "2023-01-01"
end_date = "2023-01-07"
limit = 2
activities = garmin_client_instance.get_activities(start_date, end_date, limit)
mock_connectapi.assert_called_once_with(
"/activitylist-service/activities/search/activities",
params={"startDate": start_date, "endDate": end_date, "limit": limit}
)
assert len(activities) == 2
assert activities[0]["activityName"] == "Run"
@patch("garth.client.connectapi")
def test_get_activities_failure(mock_connectapi, garmin_client_instance):
"""Test failure during fetching of activities."""
mock_connectapi.side_effect = GarthException("API error")
start_date = "2023-01-01"
end_date = "2023-01-07"
limit = 2
with pytest.raises(GarthException, match="API error"):
garmin_client_instance.get_activities(start_date, end_date, limit)
mock_connectapi.assert_called_once()
@patch("garth.client.download")
def test_download_activity_success(mock_download, garmin_client_instance):
"""Test successful downloading of an activity file."""
mock_download.return_value = b"file_content_mock"
activity_id = "12345"
file_type = "tcx"
file_content = garmin_client_instance.download_activity(activity_id, file_type)
mock_download.assert_called_once_with(f"/download-service/export/{file_type}/activity/{activity_id}")
assert file_content == b"file_content_mock"
@patch("garth.client.download")
def test_download_activity_failure(mock_download, garmin_client_instance):
"""Test failure during downloading of an activity file."""
mock_download.side_effect = GarthException("Download error")
activity_id = "12345"
file_type = "gpx"
file_content = garmin_client_instance.download_activity(activity_id, file_type)
mock_download.assert_called_once()
assert file_content is None # Should return None on exception
@patch("src.services.garmin.data.DailySteps")
@patch("src.services.garmin.data.DailyHRV")
@patch("src.services.garmin.data.SleepData")
def test_get_daily_metrics_success(mock_sleep_data, mock_daily_hrv, mock_daily_steps, garmin_client_instance):
"""Test successful fetching of daily metrics."""
mock_daily_steps.list.return_value = [MagicMock(calendar_date="2023-01-01", total_steps=1000)]
mock_daily_hrv.list.return_value = [MagicMock(calendar_date="2023-01-01", last_night_avg=50)]
mock_sleep_data.list.return_value = [MagicMock(daily_sleep_dto=MagicMock(calendar_date="2023-01-01", sleep_time_seconds=28800))]
start_date = "2023-01-01"
end_date = "2023-01-01"
metrics = garmin_client_instance.get_daily_metrics(start_date, end_date)
mock_daily_steps.list.assert_called_once_with(datetime(2023, 1, 1).date(), period=1)
mock_daily_hrv.list.assert_called_once_with(datetime(2023, 1, 1).date(), period=1)
mock_sleep_data.list.assert_called_once_with(datetime(2023, 1, 1).date(), days=1)
assert len(metrics["steps"]) == 1
assert metrics["steps"][0].total_steps == 1000
assert len(metrics["hrv"]) == 1
assert metrics["hrv"][0].last_night_avg == 50
assert len(metrics["sleep"]) == 1
assert metrics["sleep"][0].daily_sleep_dto.sleep_time_seconds == 28800
@patch("src.services.garmin.data.DailySteps")
@patch("src.services.garmin.data.DailyHRV")
@patch("src.services.garmin.data.SleepData")
def test_get_daily_metrics_partial_failure(mock_sleep_data, mock_daily_hrv, mock_daily_steps, garmin_client_instance):
"""Test fetching daily metrics with some failures."""
mock_daily_steps.list.side_effect = GarthException("Steps error")
mock_daily_hrv.list.return_value = [MagicMock(calendar_date="2023-01-01", last_night_avg=50)]
mock_sleep_data.list.return_value = []
start_date = "2023-01-01"
end_date = "2023-01-01"
metrics = garmin_client_instance.get_daily_metrics(start_date, end_date)
assert metrics["steps"] == [] # Should return empty list on error
assert len(metrics["hrv"]) == 1
assert metrics["sleep"] == []

View File

@@ -0,0 +1,199 @@
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