feat: add durable sync state transitions

Add state-transition methods to ActivityRepository for advancing activity
stages (mark_downloaded, mark_converted, mark_imported, mark_duplicate,
mark_failed) with proper retention of last_completed_stage on failure.
Add list_pending_for_user to filter activities for processing. Implement
SyncRunRepository for creating and finalizing sync runs with counts and
summary errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-15 15:33:46 +02:00
parent 2f65c0178c
commit 1d5bbdb2a2
3 changed files with 268 additions and 4 deletions

View File

@@ -8,8 +8,8 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.config import Settings
from app.db.models import Base
from app.db.repositories import ActivityRepository, UserRepository
from app.db.models import Activity, Base, HealthState
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.main import create_app
@@ -39,6 +39,11 @@ def activity_repository(db_session: Session) -> ActivityRepository:
return ActivityRepository(db_session)
@pytest.fixture
def sync_run_repository(db_session: Session) -> SyncRunRepository:
return SyncRunRepository(db_session)
@pytest.fixture
def client(tmp_path: Path) -> TestClient:
settings = Settings(
@@ -54,3 +59,23 @@ def client(tmp_path: Path) -> TestClient:
yield TestClient(app)
finally:
app.state.db_engine.dispose()
@pytest.fixture
def seeded_activity(db_session: Session, user_repository: UserRepository, activity_repository: ActivityRepository) -> Activity:
user = user_repository.create(
name="Test User",
enabled=True,
health_state=HealthState.HEALTHY,
mywhoosh_email_enc="test@example.com",
mywhoosh_password_enc="password",
garmin_email_enc="test@garmin.com",
garmin_password_enc="garmin_password",
)
activity, _ = activity_repository.get_or_create_discovered(
user_id=user.id,
mywhoosh_activity_id="mw-test-123",
activity_name="Test Activity",
activity_timestamp=None,
)
return activity

132
tests/db/test_sync_state.py Normal file
View File

@@ -0,0 +1,132 @@
from app.db.models import ActivityStatus, SyncRunStatus
def test_failure_retains_last_completed_stage(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_failed(seeded_activity.id, "Garmin timeout", retryable=True)
activity = activity_repository.get(seeded_activity.id)
assert activity.status == ActivityStatus.FAILED
assert activity.last_completed_stage == ActivityStatus.DOWNLOADED
assert activity.retryable is True
def test_converted_activity_is_pending_until_terminal(activity_repository, seeded_activity) -> None:
activity_repository.mark_converted(seeded_activity.id, "/data/activities/1/a/converted.fit")
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id in ids
def test_list_pending_excludes_imported(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_converted(seeded_activity.id, "/data/activities/1/a/converted.fit")
activity_repository.mark_imported(seeded_activity.id, "garmin-123")
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id not in ids
def test_list_pending_excludes_duplicate(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_duplicate(seeded_activity.id)
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id not in ids
def test_list_pending_excludes_non_retryable_failed(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_failed(seeded_activity.id, "Cannot retry", retryable=False)
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id not in ids
def test_list_pending_includes_retryable_failed(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_failed(seeded_activity.id, "Temporary error", retryable=True)
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id in ids
def test_sync_run_start_and_finish(sync_run_repository, user_repository) -> None:
user = user_repository.create(
name="Test User",
enabled=True,
health_state="healthy",
mywhoosh_email_enc="test@example.com",
mywhoosh_password_enc="password",
garmin_email_enc="test@garmin.com",
garmin_password_enc="garmin_password",
)
sync_run = sync_run_repository.start(user.id)
assert sync_run.status == SyncRunStatus.RUNNING
assert sync_run.discovered_count == 0
assert sync_run.imported_count == 0
assert sync_run.skipped_count == 0
assert sync_run.failed_count == 0
finished = sync_run_repository.finish(
sync_run.id,
status=SyncRunStatus.SUCCESS,
discovered=5,
imported=3,
skipped=1,
failed=1,
)
assert finished.status == SyncRunStatus.SUCCESS
assert finished.discovered_count == 5
assert finished.imported_count == 3
assert finished.skipped_count == 1
assert finished.failed_count == 1
assert finished.finished_at is not None
# Reload from DB to verify persisted
reloaded = sync_run_repository.get(sync_run.id)
assert reloaded.status == SyncRunStatus.SUCCESS
assert reloaded.discovered_count == 5
assert reloaded.imported_count == 3
def test_sync_run_finish_with_error(sync_run_repository, user_repository) -> None:
user = user_repository.create(
name="Test User",
enabled=True,
health_state="healthy",
mywhoosh_email_enc="test@example.com",
mywhoosh_password_enc="password",
garmin_email_enc="test@garmin.com",
garmin_password_enc="garmin_password",
)
sync_run = sync_run_repository.start(user.id)
error_msg = "Connection timeout"
finished = sync_run_repository.finish(
sync_run.id,
status=SyncRunStatus.FAILED,
discovered=0,
imported=0,
skipped=0,
failed=0,
summary_error=error_msg,
)
assert finished.summary_error == error_msg
# Verify truncation works
long_error = "x" * 5000
finished_long = sync_run_repository.finish(
sync_run.id,
status=SyncRunStatus.FAILED,
discovered=0,
imported=0,
skipped=0,
failed=0,
summary_error=long_error,
)
assert len(finished_long.summary_error) == 2000
assert finished_long.summary_error == long_error[:2000]