test: cover multi-user sync acceptance
This commit is contained in:
250
tests/test_acceptance.py
Normal file
250
tests/test_acceptance.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user