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:
@@ -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
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