from pathlib import Path from typing import Callable import pytest from cryptography.fernet import Fernet from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.models import Activity, ActivityStatus, Base, SyncUser from app.db.repositories import ActivityRepository, UserRepository from app.mywhoosh.models import MyWhooshActivity from app.security.credentials import CredentialCipher from app.sync.manager import SyncManager from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient class FakeFitConverter: """Fit converter stub that mimics convert_fit_device's side effect of writing bytes to output_path, without doing any real FIT parsing.""" def __init__(self) -> None: self.calls = 0 def __call__(self, source_path: Path, output_path: Path): self.calls += 1 output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(b"fake-fit-bytes") return None class StubSettings: """Minimal stand-in for app.config.Settings exposing only the two properties SyncManager needs; avoids constructing a full Settings with its several required env-backed fields.""" def __init__(self, tmp_path: Path) -> None: self.tokens_dir = tmp_path / "tokens" self.activities_dir = tmp_path / "activities" @pytest.fixture def session_factory(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) 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 load_only_activity(session_factory) -> Callable[[int], Activity]: def _load(user_id: int) -> Activity: with session_factory() as session: activities = ActivityRepository(session).session.query(Activity).filter_by(user_id=user_id).all() assert len(activities) == 1, f"expected exactly one activity for user {user_id}, found {len(activities)}" return activities[0] return _load def _create_user(session: Session, cipher: CredentialCipher) -> SyncUser: return UserRepository(session).create( name="Test User", enabled=True, mywhoosh_email_enc=cipher.encrypt("mywhoosh@example.com"), mywhoosh_password_enc=cipher.encrypt("mywhoosh-pass"), garmin_email_enc=cipher.encrypt("garmin@example.com"), garmin_password_enc=cipher.encrypt("garmin-pass"), ) @pytest.fixture def seeded_user(session_factory, cipher: CredentialCipher) -> SyncUser: with session_factory() as session: return _create_user(session, cipher) @pytest.fixture def manager_factory(session_factory, cipher: CredentialCipher, settings: StubSettings): """Build a SyncManager plus its injected fakes, wired so the fake MyWhoosh client's single remote activity matches the given (already seeded) Activity's mywhoosh_activity_id -- so get_or_create_discovered resolves to the existing row instead of creating a new one.""" def _factory(activity: Activity): remote = MyWhooshActivity( id=activity.mywhoosh_activity_id, title=activity.activity_name, activity_file_id=f"file-{activity.mywhoosh_activity_id}", started_at=activity.activity_timestamp, ) mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes") 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, ) return manager, mywhoosh, converter, garmin return _factory @pytest.fixture def manager(session_factory, cipher: CredentialCipher, settings: StubSettings, seeded_user: SyncUser): """A manager wired for the happy-path new-activity scenario: one remote MyWhoosh activity that seeded_user has never seen before.""" remote = MyWhooshActivity( id="mw-1", title="Morning Ride", activity_file_id="file-mw-1", started_at=None, ) mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes") converter = FakeFitConverter() garmin = FakeGarminUploader() sync_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, ) # Exposed for tests that want to introspect fakes without a # manager_factory-style scenario. sync_manager.fake_mywhoosh = mywhoosh sync_manager.fake_converter = converter sync_manager.fake_garmin = garmin return sync_manager @pytest.fixture def seeded_activity_factory(session_factory, cipher: CredentialCipher): def _factory( *, status: ActivityStatus, last_completed_stage: ActivityStatus, retryable: bool, mywhoosh_activity_id: str = "mw-1", ) -> Activity: with session_factory() as session: user = _create_user(session, cipher) activity_repo = ActivityRepository(session) activity, _created = activity_repo.get_or_create_discovered( user_id=user.id, mywhoosh_activity_id=mywhoosh_activity_id, activity_name="Test Activity", activity_timestamp=None, ) activity.status = status activity.last_completed_stage = last_completed_stage activity.retryable = retryable if status in ( ActivityStatus.DOWNLOADED, ActivityStatus.CONVERTED, ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE, ) or last_completed_stage in ( ActivityStatus.DOWNLOADED, ActivityStatus.CONVERTED, ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE, ): activity.source_fit_path = "seed-source.fit" if status in ( ActivityStatus.CONVERTED, ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE, ) or last_completed_stage in ( ActivityStatus.CONVERTED, ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE, ): activity.converted_fit_path = "seed-converted.fit" session.commit() return activity return _factory