Files
mywhoosh2garmin/tests/test_acceptance.py
2026-08-15 16:43:45 +02:00

251 lines
8.7 KiB
Python

"""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
@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)