fix: address final review findings for sync-scheduler-web plan

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>
This commit is contained in:
Bastian Wagner
2026-08-15 17:09:55 +02:00
parent aed9d6bb48
commit 990a55af14
5 changed files with 457 additions and 195 deletions

View File

@@ -3,7 +3,13 @@ from pathlib import Path
import pytest
from sqlalchemy import select
from app.db.models import ActivityStatus, SyncRun, SyncRunStatus, SyncUser
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
@@ -86,3 +92,127 @@ async def test_sync_run_repository_wiring_records_run(manager, seeded_user: Sync
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"]