from pathlib import Path import pytest from sqlalchemy import select from app.db.models import ActivityStatus, HealthState, SyncRun, SyncRunStatus, SyncUser from app.db.repositories import UserRepository from app.garmin.uploader import UploadResult from app.mywhoosh.client import MyWhooshDeviceConflictError from app.mywhoosh.models import MyWhooshActivity from app.sync.manager import SyncManager from tests.sync.conftest import FakeFitConverter, _create_user from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier @pytest.mark.asyncio async def test_new_activity_downloads_converts_and_imports(manager, seeded_user: SyncUser, load_only_activity) -> None: outcome = await manager.sync_user(seeded_user.id) assert outcome.discovered == 1 assert outcome.imported == 1 assert outcome.failed == 0 activity = load_only_activity(seeded_user.id) assert activity.status == ActivityStatus.IMPORTED assert Path(activity.source_fit_path).exists() assert Path(activity.converted_fit_path).exists() @pytest.mark.asyncio @pytest.mark.parametrize( ("status", "last_stage", "expected_downloads", "expected_conversions", "expected_imports"), [ (ActivityStatus.DOWNLOADED, ActivityStatus.DOWNLOADED, 0, 1, 1), (ActivityStatus.CONVERTED, ActivityStatus.CONVERTED, 0, 0, 1), (ActivityStatus.IMPORTED, ActivityStatus.IMPORTED, 0, 0, 0), (ActivityStatus.FAILED, ActivityStatus.CONVERTED, 0, 0, 1), ], ) async def test_resume_from_durable_stage( manager_factory, seeded_activity_factory, status, last_stage, expected_downloads, expected_conversions, expected_imports, ) -> None: activity = seeded_activity_factory(status=status, last_completed_stage=last_stage, retryable=True) manager, mywhoosh, converter, garmin = manager_factory(activity) await manager.sync_user(activity.user_id) assert mywhoosh.download_calls == expected_downloads assert converter.calls == expected_conversions assert garmin.calls == expected_imports @pytest.mark.asyncio async def test_non_retryable_failed_activity_is_never_retried( manager_factory, seeded_activity_factory, load_only_activity, ) -> None: activity = seeded_activity_factory( status=ActivityStatus.FAILED, last_completed_stage=ActivityStatus.CONVERTED, retryable=False, ) manager, mywhoosh, converter, garmin = manager_factory(activity) outcome = await manager.sync_user(activity.user_id) assert mywhoosh.download_calls == 0 assert converter.calls == 0 assert garmin.calls == 0 assert outcome.imported == 0 assert outcome.skipped == 0 assert outcome.failed == 0 reloaded = load_only_activity(activity.user_id) assert reloaded.status == ActivityStatus.FAILED assert reloaded.retryable is False @pytest.mark.asyncio async def test_sync_run_repository_wiring_records_run(manager, seeded_user: SyncUser, session_factory) -> None: await manager.sync_user(seeded_user.id) with session_factory() as session: runs = list(session.scalars(select(SyncRun).where(SyncRun.user_id == seeded_user.id))) assert len(runs) == 1 run = runs[0] assert run.status == SyncRunStatus.SUCCESS assert run.discovered_count == 1 assert run.imported_count == 1 assert run.finished_at is not None class RecordingGarminUploader: """Fake Garmin uploader that records the mfa_code it was actually called with, so a test can prove the value genuinely threads through SyncManager._sync_user_locked's asyncio.to_thread(garmin.import_fit, ...) call rather than just through the web route's own fake manager.""" def __init__(self) -> None: self.received_mfa_codes: list[str | None] = [] def import_fit(self, fit_path, mfa_code=None): self.received_mfa_codes.append(mfa_code) return UploadResult("imported", False, "g-1", {"activityId": "g-1"}) @pytest.mark.asyncio async def test_garmin_action_required_not_cleared_by_run_with_no_garmin_work( session_factory, cipher, settings ) -> None: """Regression test for the health-state-reset bug: a user stuck at garmin_auth_required must NOT be silently cleared back to healthy just because a run's MyWhoosh listing succeeded trivially (zero remote activities means zero Garmin work was attempted -- no evidence Garmin was actually fixed).""" with session_factory() as session: user = _create_user(session, cipher) user.health_state = HealthState.ACTION_REQUIRED user.garmin_state = "auth_required" user.action_reason = "garmin_auth_required" session.commit() user_id = user.id mywhoosh = FakeMyWhooshClient(activities=[], fit_bytes=b"unused") converter = FakeFitConverter() garmin = RecordingGarminUploader() manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=lambda token_store: mywhoosh, garmin_factory=lambda email, password, tokenstore: garmin, fit_converter=converter, ) outcome = await manager.sync_user(user_id) assert outcome.status == "success" assert outcome.discovered == 0 with session_factory() as session: reloaded = UserRepository(session).get(user_id) assert reloaded.action_reason == "garmin_auth_required" assert reloaded.health_state == HealthState.ACTION_REQUIRED @pytest.mark.asyncio async def test_garmin_action_required_cleared_after_successful_import( session_factory, cipher, settings ) -> None: """Companion to the regression test above: the same starting action_required/garmin_auth_required state IS cleared once this run actually succeeds at a real Garmin import -- proving the fix doesn't just always refuse to clear.""" with session_factory() as session: user = _create_user(session, cipher) user.health_state = HealthState.ACTION_REQUIRED user.garmin_state = "auth_required" user.action_reason = "garmin_auth_required" session.commit() user_id = user.id remote = MyWhooshActivity( id="mw-recovery-1", title="Recovery Ride", activity_file_id="file-mw-recovery-1", started_at=None ) mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes") converter = FakeFitConverter() garmin = RecordingGarminUploader() manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=lambda token_store: mywhoosh, garmin_factory=lambda email, password, tokenstore: garmin, fit_converter=converter, ) outcome = await manager.sync_user(user_id) assert outcome.status == "success" assert outcome.imported == 1 with session_factory() as session: reloaded = UserRepository(session).get(user_id) assert reloaded.action_reason is None assert reloaded.health_state == HealthState.HEALTHY class DeviceConflictMyWhooshClient: """Fake MyWhoosh client that always raises MyWhooshDeviceConflictError from list_activities, simulating MyWhoosh's "already logged in from another device" response.""" async def list_activities(self, email: str, password: str): raise MyWhooshDeviceConflictError("You are already logged in from another device.") async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes: raise AssertionError("download_fit should not be reached in this test") @pytest.mark.asyncio async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser, session_factory, cipher, settings) -> None: """A MyWhoosh device-conflict response must be distinguishable in the UI from a generic auth failure, so users get an actionable hint instead of being told to re-check their password.""" mywhoosh = DeviceConflictMyWhooshClient() converter = FakeFitConverter() garmin = FakeGarminUploader() manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=lambda token_store: mywhoosh, garmin_factory=lambda email, password, tokenstore: garmin, fit_converter=converter, ) outcome = await manager.sync_user(seeded_user.id) assert outcome.status == "failed" assert "another device" in outcome.message with session_factory() as session: reloaded = UserRepository(session).get(seeded_user.id) assert reloaded.action_reason == "mywhoosh_device_conflict" assert reloaded.mywhoosh_state == "device_conflict" assert reloaded.health_state == HealthState.ACTION_REQUIRED @pytest.mark.asyncio async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None: with session_factory() as session: user = _create_user(session, cipher) user.notify_email_enabled = True user.notification_email = "alerts@example.com" session.commit() user_id = user.id notifier = FakeNotifier() manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(), garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(), fit_converter=FakeFitConverter(), notifier=notifier, ) await manager.sync_user(user_id) assert len(notifier.sent) == 1 assert notifier.sent[0]["to_address"] == "alerts@example.com" assert "another device" in notifier.sent[0]["body"] @pytest.mark.asyncio async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None: user_id = None with session_factory() as session: user = _create_user(session, cipher) user_id = user.id notifier = FakeNotifier() manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(), garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(), fit_converter=FakeFitConverter(), notifier=notifier, ) await manager.sync_user(user_id) assert notifier.sent == [] @pytest.mark.asyncio async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None: with session_factory() as session: user = _create_user(session, cipher) user.notify_email_enabled = True user.notification_email = "alerts@example.com" session.commit() user_id = user.id notifier = FakeNotifier() manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(), garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(), fit_converter=FakeFitConverter(), notifier=notifier, ) await manager.sync_user(user_id) await manager.sync_user(user_id) assert len(notifier.sent) == 1 @pytest.mark.asyncio async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager( session_factory, cipher, settings, seeded_user: SyncUser ) -> None: """Proves mfa_code genuinely threads through _sync_user_locked's asyncio.to_thread(garmin.import_fit, converted_path, mfa_code) call in the real (non-web-route) code path -- tests/web/test_mfa.py already covers the web route's own fake manager, but not the real SyncManager/GarminUploader interface.""" remote = MyWhooshActivity( id="mw-mfa-1", title="MFA Ride", activity_file_id="file-mw-mfa-1", started_at=None ) mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes") converter = FakeFitConverter() garmin = RecordingGarminUploader() manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=lambda token_store: mywhoosh, garmin_factory=lambda email, password, tokenstore: garmin, fit_converter=converter, ) outcome = await manager.sync_user(seeded_user.id, mfa_code="123456") assert outcome.status == "success" assert garmin.received_mfa_codes == ["123456"]