feat: add resumable per-user sync pipeline

This commit is contained in:
Bastian Wagner
2026-08-15 15:53:27 +02:00
parent 1d5bbdb2a2
commit c4e986e3f8
7 changed files with 581 additions and 0 deletions

0
tests/sync/__init__.py Normal file
View File

200
tests/sync/conftest.py Normal file
View 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

30
tests/sync/fakes.py Normal file
View File

@@ -0,0 +1,30 @@
from app.garmin.uploader import UploadResult
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: str, password: str):
self.list_calls += 1
return list(self.activities)
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
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

View File

@@ -0,0 +1,87 @@
from pathlib import Path
import pytest
from app.db.models import ActivityStatus, SyncRun, SyncRunStatus, SyncUser
@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 = session.query(SyncRun).filter_by(user_id=seeded_user.id).all()
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