Files
mywhoosh2garmin/tests/sync/conftest.py
2026-08-15 15:53:27 +02:00

201 lines
7.0 KiB
Python

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