Close the leaked httpx.AsyncClient in MyWhoosh sync runs, ensure hard failures finish sync_runs as FAILED instead of leaving them stuck at RUNNING, log (non-benign) exceptions surfaced by sync_all_enabled during scheduled ticks, classify GarminImportRejected as a non-retryable per-activity failure, fix a bug where a live Garmin-class action_required state could be silently cleared by a run that did no Garmin work, use the activity's DB primary key instead of the unsanitized remote id for filesystem paths, add regression/coverage tests for the health-state fix and MFA code threading through the real SyncManager, add idempotency coverage to the two-user acceptance test, and note the Dockerfile's single-process assumption. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
8.1 KiB
Python
219 lines
8.1 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import ActivityStatus, HealthState, SyncRun, SyncRunStatus, SyncUser
|
|
from app.db.repositories import UserRepository
|
|
from app.garmin.uploader import UploadResult
|
|
from app.mywhoosh.models import MyWhooshActivity
|
|
from app.sync.manager import SyncManager
|
|
from tests.sync.conftest import FakeFitConverter, _create_user
|
|
from tests.sync.fakes import FakeMyWhooshClient
|
|
|
|
|
|
@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 = list(session.scalars(select(SyncRun).where(SyncRun.user_id == seeded_user.id)))
|
|
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
|
|
|
|
|
|
class RecordingGarminUploader:
|
|
"""Fake Garmin uploader that records the mfa_code it was actually called
|
|
with, so a test can prove the value genuinely threads through
|
|
SyncManager._sync_user_locked's asyncio.to_thread(garmin.import_fit, ...)
|
|
call rather than just through the web route's own fake manager."""
|
|
|
|
def __init__(self) -> None:
|
|
self.received_mfa_codes: list[str | None] = []
|
|
|
|
def import_fit(self, fit_path, mfa_code=None):
|
|
self.received_mfa_codes.append(mfa_code)
|
|
return UploadResult("imported", False, "g-1", {"activityId": "g-1"})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_garmin_action_required_not_cleared_by_run_with_no_garmin_work(
|
|
session_factory, cipher, settings
|
|
) -> None:
|
|
"""Regression test for the health-state-reset bug: a user stuck at
|
|
garmin_auth_required must NOT be silently cleared back to healthy just
|
|
because a run's MyWhoosh listing succeeded trivially (zero remote
|
|
activities means zero Garmin work was attempted -- no evidence Garmin
|
|
was actually fixed)."""
|
|
with session_factory() as session:
|
|
user = _create_user(session, cipher)
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.garmin_state = "auth_required"
|
|
user.action_reason = "garmin_auth_required"
|
|
session.commit()
|
|
user_id = user.id
|
|
|
|
mywhoosh = FakeMyWhooshClient(activities=[], fit_bytes=b"unused")
|
|
converter = FakeFitConverter()
|
|
garmin = RecordingGarminUploader()
|
|
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,
|
|
)
|
|
|
|
outcome = await manager.sync_user(user_id)
|
|
assert outcome.status == "success"
|
|
assert outcome.discovered == 0
|
|
|
|
with session_factory() as session:
|
|
reloaded = UserRepository(session).get(user_id)
|
|
assert reloaded.action_reason == "garmin_auth_required"
|
|
assert reloaded.health_state == HealthState.ACTION_REQUIRED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_garmin_action_required_cleared_after_successful_import(
|
|
session_factory, cipher, settings
|
|
) -> None:
|
|
"""Companion to the regression test above: the same starting
|
|
action_required/garmin_auth_required state IS cleared once this run
|
|
actually succeeds at a real Garmin import -- proving the fix doesn't just
|
|
always refuse to clear."""
|
|
with session_factory() as session:
|
|
user = _create_user(session, cipher)
|
|
user.health_state = HealthState.ACTION_REQUIRED
|
|
user.garmin_state = "auth_required"
|
|
user.action_reason = "garmin_auth_required"
|
|
session.commit()
|
|
user_id = user.id
|
|
|
|
remote = MyWhooshActivity(
|
|
id="mw-recovery-1", title="Recovery Ride", activity_file_id="file-mw-recovery-1", started_at=None
|
|
)
|
|
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
|
|
converter = FakeFitConverter()
|
|
garmin = RecordingGarminUploader()
|
|
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,
|
|
)
|
|
|
|
outcome = await manager.sync_user(user_id)
|
|
assert outcome.status == "success"
|
|
assert outcome.imported == 1
|
|
|
|
with session_factory() as session:
|
|
reloaded = UserRepository(session).get(user_id)
|
|
assert reloaded.action_reason is None
|
|
assert reloaded.health_state == HealthState.HEALTHY
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
|
|
session_factory, cipher, settings, seeded_user: SyncUser
|
|
) -> None:
|
|
"""Proves mfa_code genuinely threads through _sync_user_locked's
|
|
asyncio.to_thread(garmin.import_fit, converted_path, mfa_code) call in the
|
|
real (non-web-route) code path -- tests/web/test_mfa.py already covers the
|
|
web route's own fake manager, but not the real SyncManager/GarminUploader
|
|
interface."""
|
|
remote = MyWhooshActivity(
|
|
id="mw-mfa-1", title="MFA Ride", activity_file_id="file-mw-mfa-1", started_at=None
|
|
)
|
|
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
|
|
converter = FakeFitConverter()
|
|
garmin = RecordingGarminUploader()
|
|
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,
|
|
)
|
|
|
|
outcome = await manager.sync_user(seeded_user.id, mfa_code="123456")
|
|
|
|
assert outcome.status == "success"
|
|
assert garmin.received_mfa_codes == ["123456"]
|