"""Repository contract tests for cloud task progress columns (task 3.6). Separate from ``test_cloud_repository_contract.py``; covers the new ``progress_*`` fields added by migration 0008. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from cloud.database import CloudDatabase from cloud.pool import PooledDevice from cloud.repository import AssignmentProgressSnapshot from cloud.scheduler import ScheduledTask, TaskConstraints def _device(device_id: str, host_id: str) -> PooledDevice: return PooledDevice( device_id=device_id, host_id=host_id, driver_type="wda", status="idle", capability_tags=["ios"], synced_at=datetime(2026, 7, 12, tzinfo=UTC), ) def _task(task_id: str, *, created_at: datetime | None = None) -> ScheduledTask: return ScheduledTask( id=task_id, goal="test progress", workflow_definition_id=None, constraints=TaskConstraints(), created_at=created_at or datetime(2026, 7, 12, tzinfo=UTC), ) def _setup_assigned_and_claimed( database: CloudDatabase, host_id: str, device_id: str, task_id: str ): """Enqueue, assign, and claim a task. Returns LeasedAssignment.""" now = datetime(2026, 7, 12, 5, 0, tzinfo=UTC) database.repository.upsert_host(host_id, address=None, last_seen_at=now) database.repository.replace_host_devices(host_id, [_device(device_id, host_id)]) database.repository.enqueue_task(_task(task_id, created_at=now)) database.repository.assign_task( task_id=task_id, host_id=host_id, device_id=device_id, lease_id="lease-1", lease_expires_at=now + timedelta(minutes=10), now=now, ) assignment = database.repository.claim_assignment( host_id=host_id, now=now + timedelta(seconds=1), ) assert assignment is not None return assignment, now # --------------------------------------------------------------------------- # # renew_lease writes progress on success # --------------------------------------------------------------------------- # def test_renew_lease_writes_progress_on_success(tmp_path) -> None: database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress.sqlite3').as_posix()}") host_id = "progress-host" device_id = "progress-device" task_id = "progress-task" try: assignment, now = _setup_assigned_and_claimed( database, host_id, device_id, task_id ) progress = AssignmentProgressSnapshot( step_index=2, step_status="running", summary="executing step 2", updated_at=now + timedelta(seconds=5), ) result = database.repository.renew_lease( task_id=task_id, attempt=assignment.attempt, lease_id=assignment.lease_id, host_id=host_id, lease_expires_at=now + timedelta(minutes=15), now=now + timedelta(seconds=5), progress=progress, ) assert result == "renewed" task = database.repository.get_task(task_id) assert task is not None assert task.progress_step_index == 2 assert task.progress_step_status == "running" assert task.progress_summary == "executing step 2" assert task.progress_updated_at is not None finally: database.close() # --------------------------------------------------------------------------- # # renew_lease without progress leaves previous untouched # --------------------------------------------------------------------------- # def test_renew_lease_without_progress_leaves_previous_untouched(tmp_path) -> None: database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress2.sqlite3').as_posix()}") host_id = "retain-host" device_id = "retain-device" task_id = "retain-task" try: assignment, now = _setup_assigned_and_claimed( database, host_id, device_id, task_id ) # First renew WITH progress. progress = AssignmentProgressSnapshot( step_index=1, step_status="completed", summary="step 1 done", updated_at=now + timedelta(seconds=2), ) database.repository.renew_lease( task_id=task_id, attempt=assignment.attempt, lease_id=assignment.lease_id, host_id=host_id, lease_expires_at=now + timedelta(minutes=15), now=now + timedelta(seconds=2), progress=progress, ) # Second renew WITHOUT progress (None). database.repository.renew_lease( task_id=task_id, attempt=assignment.attempt, lease_id=assignment.lease_id, host_id=host_id, lease_expires_at=now + timedelta(minutes=20), now=now + timedelta(seconds=4), progress=None, ) task = database.repository.get_task(task_id) assert task is not None # Previous values must be retained, not cleared. assert task.progress_step_index == 1 assert task.progress_step_status == "completed" assert task.progress_summary == "step 1 done" finally: database.close() # --------------------------------------------------------------------------- # # record_task_result clears progress on terminal # --------------------------------------------------------------------------- # def test_record_task_result_clears_progress_on_terminal(tmp_path) -> None: database = CloudDatabase(f"sqlite:///{(tmp_path / 'clear.sqlite3').as_posix()}") host_id = "clear-host" device_id = "clear-device" task_id = "clear-task" try: assignment, now = _setup_assigned_and_claimed( database, host_id, device_id, task_id ) # Write progress first. progress = AssignmentProgressSnapshot( step_index=3, step_status="running", summary="almost done", updated_at=now + timedelta(seconds=3), ) database.repository.renew_lease( task_id=task_id, attempt=assignment.attempt, lease_id=assignment.lease_id, host_id=host_id, lease_expires_at=now + timedelta(minutes=15), now=now + timedelta(seconds=3), progress=progress, ) task = database.repository.get_task(task_id) assert task is not None assert task.progress_step_index == 3 # Record terminal result. result = database.repository.record_task_result( task_id=task_id, attempt=assignment.attempt, lease_id=assignment.lease_id, host_id=host_id, status="done", failure_reason=None, terminal_result={"output": "success"}, completed_at=now + timedelta(seconds=10), ) assert result == "recorded" task = database.repository.get_task(task_id) assert task is not None assert task.status == "done" assert task.progress_step_index is None assert task.progress_step_status is None assert task.progress_summary is None assert task.progress_updated_at is None finally: database.close() # --------------------------------------------------------------------------- # # Task list/detail response includes progress fields # --------------------------------------------------------------------------- # def test_list_tasks_includes_progress_fields(tmp_path) -> None: database = CloudDatabase(f"sqlite:///{(tmp_path / 'list.sqlite3').as_posix()}") host_id = "list-host" device_id = "list-device" task_id = "list-task" try: assignment, now = _setup_assigned_and_claimed( database, host_id, device_id, task_id ) progress = AssignmentProgressSnapshot( step_index=1, step_status="running", summary="running step 1", updated_at=now + timedelta(seconds=5), ) database.repository.renew_lease( task_id=task_id, attempt=assignment.attempt, lease_id=assignment.lease_id, host_id=host_id, lease_expires_at=now + timedelta(minutes=15), now=now + timedelta(seconds=5), progress=progress, ) tasks = database.repository.list_tasks() matching = [t for t in tasks if t.id == task_id] assert len(matching) == 1 task = matching[0] assert task.progress_step_index == 1 assert task.progress_step_status == "running" assert task.progress_summary == "running step 1" assert task.progress_updated_at is not None finally: database.close() def test_get_task_includes_progress_fields(tmp_path) -> None: database = CloudDatabase(f"sqlite:///{(tmp_path / 'get.sqlite3').as_posix()}") host_id = "get-host" device_id = "get-device" task_id = "get-task" try: assignment, now = _setup_assigned_and_claimed( database, host_id, device_id, task_id ) progress = AssignmentProgressSnapshot( step_index=5, step_status="failed", summary="step 5 failed", updated_at=now + timedelta(seconds=8), ) database.repository.renew_lease( task_id=task_id, attempt=assignment.attempt, lease_id=assignment.lease_id, host_id=host_id, lease_expires_at=now + timedelta(minutes=15), now=now + timedelta(seconds=8), progress=progress, ) task = database.repository.get_task(task_id) assert task is not None assert task.progress_step_index == 5 assert task.progress_step_status == "failed" assert task.progress_summary == "step 5 failed" finally: database.close() def test_progress_fields_default_null_before_any_renewal(tmp_path) -> None: database = CloudDatabase(f"sqlite:///{(tmp_path / 'null.sqlite3').as_posix()}") host_id = "null-host" device_id = "null-device" task_id = "null-task" try: _setup_assigned_and_claimed(database, host_id, device_id, task_id) task = database.repository.get_task(task_id) assert task is not None assert task.progress_step_index is None assert task.progress_step_status is None assert task.progress_summary is None assert task.progress_updated_at is None finally: database.close()