"""Application-level, multi-user acceptance tests for the full MyWhoosh -> Garmin sync pipeline (Task 7 of the sync-scheduler-web plan). These tests build a real `SyncManager` wired to a real SQLite database and a real `CredentialCipher`, but with fake MyWhoosh/Garmin factories, so they exercise the whole state machine end-to-end for two independent users without touching any real external service. """ from __future__ import annotations from pathlib import Path import pytest from cryptography.fernet import Fernet from sqlalchemy import func, select from sqlalchemy.orm import sessionmaker from app.db.models import Activity, ActivityStatus, HealthState, SyncUser from app.db.repositories import UserRepository from app.db.session import create_db_engine, create_session_factory, initialize_schema from app.garmin.uploader import GarminUploadBlocked, UploadResult from app.mywhoosh.models import MyWhooshActivity from app.security.credentials import CredentialCipher from app.sync.manager import SyncManager class StubSettings: """Minimal stand-in for app.config.Settings exposing only the two properties SyncManager needs.""" def __init__(self, tmp_path: Path) -> None: self.tokens_dir = tmp_path / "tokens" self.activities_dir = tmp_path / "activities" class FakeMyWhooshClient: def __init__(self, activities, fit_bytes: bytes) -> None: self.activities = activities self.fit_bytes = fit_bytes self.list_calls = 0 self.download_calls = 0 async def list_activities(self, email, password): self.list_calls += 1 return list(self.activities) async def download_fit(self, activity_file_id, email, password): self.download_calls += 1 return self.fit_bytes class FakeGarminUploader: def __init__(self, result: UploadResult | None = None, error: Exception | None = None) -> None: self.result = result or UploadResult("imported", False, "g-1", {"activityId": "g-1"}) self.error = error self.calls = 0 def import_fit(self, fit_path, mfa_code=None): self.calls += 1 if self.error is not None: raise self.error return self.result def fake_fit_converter(source_path: Path, output_path: Path) -> None: output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(b"fake-converted-fit-bytes") def count_terminal_activities(session_factory: sessionmaker, user_id: int) -> int: with session_factory() as session: return session.scalar( select(func.count()) .select_from(Activity) .where( Activity.user_id == user_id, Activity.status.in_([ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE]), ) ) def _create_user(session, cipher: CredentialCipher, *, name: str) -> SyncUser: return UserRepository(session).create( name=name, enabled=True, mywhoosh_email_enc=cipher.encrypt(f"{name.lower()}-mywhoosh@example.com"), mywhoosh_password_enc=cipher.encrypt("mw-secret"), garmin_email_enc=cipher.encrypt(f"{name.lower()}-garmin@example.com"), garmin_password_enc=cipher.encrypt("garmin-secret"), ) @pytest.fixture def session_factory(tmp_path: Path): db_path = tmp_path / "acceptance.db" engine = create_db_engine(f"sqlite:///{db_path}") initialize_schema(engine) factory = create_session_factory(engine) try: yield factory finally: engine.dispose() @pytest.fixture def cipher() -> CredentialCipher: return CredentialCipher(Fernet.generate_key().decode("ascii")) @pytest.fixture def settings(tmp_path: Path) -> StubSettings: return StubSettings(tmp_path) @pytest.fixture def two_users(session_factory, cipher: CredentialCipher): with session_factory() as session: user_a = _create_user(session, cipher, name="Alice") user_b = _create_user(session, cipher, name="Bob") return user_a, user_b def _build_manager( *, session_factory, cipher: CredentialCipher, settings: StubSettings, user_a: SyncUser, user_b: SyncUser, fake_mw_a: FakeMyWhooshClient, fake_mw_b: FakeMyWhooshClient, fake_garmin_a: FakeGarminUploader, fake_garmin_b: FakeGarminUploader, ): mywhoosh_fakes = {str(user_a.id): fake_mw_a, str(user_b.id): fake_mw_b} garmin_fakes = {str(user_a.id): fake_garmin_a, str(user_b.id): fake_garmin_b} garmin_factory_calls: list[tuple[str, str, Path]] = [] def mywhoosh_factory(token_store): user_key = token_store.path.parent.name return mywhoosh_fakes[user_key] def garmin_factory(email, password, tokenstore): garmin_factory_calls.append((email, password, tokenstore)) user_key = tokenstore.parent.name return garmin_fakes[user_key] manager = SyncManager( session_factory=session_factory, credential_cipher=cipher, settings=settings, mywhoosh_factory=mywhoosh_factory, garmin_factory=garmin_factory, fit_converter=fake_fit_converter, ) return manager, garmin_factory_calls @pytest.mark.asyncio async def test_two_user_happy_path(session_factory, cipher, settings, two_users): user_a, user_b = two_users remote_a = MyWhooshActivity( id="remote-a-1", title="Alice Ride", activity_file_id="file-a-1", started_at=None ) remote_b = MyWhooshActivity( id="remote-b-1", title="Bob Ride", activity_file_id="file-b-1", started_at=None ) fake_mw_a = FakeMyWhooshClient(activities=[remote_a], fit_bytes=b"alice-source-bytes") fake_mw_b = FakeMyWhooshClient(activities=[remote_b], fit_bytes=b"bob-source-bytes") fake_garmin_a = FakeGarminUploader() fake_garmin_b = FakeGarminUploader() manager, garmin_factory_calls = _build_manager( session_factory=session_factory, cipher=cipher, settings=settings, user_a=user_a, user_b=user_b, fake_mw_a=fake_mw_a, fake_mw_b=fake_mw_b, fake_garmin_a=fake_garmin_a, fake_garmin_b=fake_garmin_b, ) results = await manager.sync_all_enabled() assert all(result.status == "success" for result in results) assert count_terminal_activities(session_factory, user_a.id) == 1 assert count_terminal_activities(session_factory, user_b.id) == 1 with session_factory() as session: activity_a = session.scalar(select(Activity).where(Activity.user_id == user_a.id)) activity_b = session.scalar(select(Activity).where(Activity.user_id == user_b.id)) assert Path(activity_a.source_fit_path).exists() assert Path(activity_b.source_fit_path).exists() assert Path(activity_a.source_fit_path).parent != Path(activity_b.source_fit_path).parent tokenstores = {str(call[2]) for call in garmin_factory_calls} assert len(tokenstores) == 2 # Idempotency (spec 26.8): re-running sync_all_enabled with no new remote # activities must not re-download/re-convert/re-import anything, and must # not create a second terminal Activity row for the same MyWhoosh activity. download_calls_a_before = fake_mw_a.download_calls download_calls_b_before = fake_mw_b.download_calls garmin_calls_a_before = fake_garmin_a.calls garmin_calls_b_before = fake_garmin_b.calls second_results = await manager.sync_all_enabled() assert all(result.status == "success" for result in second_results) assert fake_mw_a.download_calls == download_calls_a_before assert fake_mw_b.download_calls == download_calls_b_before assert fake_garmin_a.calls == garmin_calls_a_before assert fake_garmin_b.calls == garmin_calls_b_before assert count_terminal_activities(session_factory, user_a.id) == 1 assert count_terminal_activities(session_factory, user_b.id) == 1 @pytest.mark.asyncio async def test_isolation_when_one_user_requires_mfa(session_factory, cipher, settings, two_users): user_a, user_b = two_users remote_a = MyWhooshActivity( id="remote-a-1", title="Alice Ride", activity_file_id="file-a-1", started_at=None ) remote_b = MyWhooshActivity( id="remote-b-1", title="Bob Ride", activity_file_id="file-b-1", started_at=None ) fake_mw_a = FakeMyWhooshClient(activities=[remote_a], fit_bytes=b"alice-source-bytes") fake_mw_b = FakeMyWhooshClient(activities=[remote_b], fit_bytes=b"bob-source-bytes") fake_garmin_a = FakeGarminUploader() fake_garmin_b = FakeGarminUploader(error=GarminUploadBlocked("Garmin requested MFA")) manager, garmin_factory_calls = _build_manager( session_factory=session_factory, cipher=cipher, settings=settings, user_a=user_a, user_b=user_b, fake_mw_a=fake_mw_a, fake_mw_b=fake_mw_b, fake_garmin_a=fake_garmin_a, fake_garmin_b=fake_garmin_b, ) results = await manager.sync_all_enabled() results_by_user = {result.user_id: result for result in results} assert results_by_user[user_a.id].status == "success" assert results_by_user[user_b.id].status != "success" assert results_by_user[user_b.id].status == "failed" assert count_terminal_activities(session_factory, user_a.id) == 1 with session_factory() as session: reloaded_b = UserRepository(session).get(user_b.id) assert reloaded_b.health_state == HealthState.ACTION_REQUIRED assert reloaded_b.action_reason == "garmin_mfa_required" reloaded_a = UserRepository(session).get(user_a.id) assert reloaded_a.health_state == HealthState.HEALTHY assert reloaded_a.action_reason is None activity_a = session.scalar(select(Activity).where(Activity.user_id == user_a.id)) assert activity_a.status in (ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE)