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:
132
tests/db/test_sync_state.py
Normal file
132
tests/db/test_sync_state.py
Normal 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]
|
||||
Reference in New Issue
Block a user