feat: add resumable per-user sync pipeline
This commit is contained in:
200
tests/sync/conftest.py
Normal file
200
tests/sync/conftest.py
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
Reference in New Issue
Block a user