feat: Implement single sync job management and progress tracking

This commit is contained in:
2025-10-11 18:36:19 -07:00
parent 3819e4f5e2
commit 723ca04aa8
51 changed files with 1625 additions and 596 deletions

View File

@@ -10,7 +10,7 @@ from src.services.auth_service import AuthService
@pytest.fixture
def auth_service():
"""Fixture for AuthService with mocked CentralDBService."""
with patch('src.services.auth_service.CentralDBService') as MockCentralDBService:
with patch("src.services.auth_service.CentralDBService") as MockCentralDBService:
mock_central_db_instance = MockCentralDBService.return_value
mock_central_db_instance.get_user_by_email = AsyncMock()
mock_central_db_instance.create_user = AsyncMock()
@@ -21,21 +21,24 @@ def auth_service():
service.central_db = mock_central_db_instance
yield service
@pytest.fixture
def mock_garth_login():
"""Fixture to mock garth.login."""
with patch('garth.login') as mock_login:
with patch("garth.login") as mock_login:
yield mock_login
@pytest.fixture
def mock_garth_client():
"""Fixture to mock garth.client attributes."""
with patch('garth.client') as mock_client:
with patch("garth.client") as mock_client:
mock_client.oauth2_token = "mock_oauth2_token"
mock_client.refresh_token = "mock_refresh_token"
mock_client.token_expires_at = 1234567890
yield mock_client
@pytest.mark.asyncio
async def test_authenticate_garmin_connect_new_user_success(
auth_service, mock_garth_login, mock_garth_client
@@ -57,7 +60,11 @@ async def test_authenticate_garmin_connect_new_user_success(
auth_service.central_db.create_token.assert_called_once()
auth_service.central_db.update_token.assert_not_called()
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)}
assert result == {
"message": "Garmin Connect authentication successful",
"user_id": str(mock_user.id),
}
@pytest.mark.asyncio
async def test_authenticate_garmin_connect_existing_user_success(
@@ -79,7 +86,11 @@ async def test_authenticate_garmin_connect_existing_user_success(
auth_service.central_db.create_token.assert_called_once()
auth_service.central_db.update_token.assert_not_called()
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)}
assert result == {
"message": "Garmin Connect authentication successful",
"user_id": str(mock_user.id),
}
@pytest.mark.asyncio
async def test_authenticate_garmin_connect_existing_user_existing_token_success(
@@ -89,9 +100,12 @@ async def test_authenticate_garmin_connect_existing_user_existing_token_success(
email = "existing_user_token@example.com"
password = "password123"
mock_user = User(id=uuid.uuid4(), name=email, email=email)
mock_user_id = mock_user.id # Capture the generated UUID
mock_user_id = mock_user.id # Capture the generated UUID
mock_existing_token = TokenCreate(
access_token="old_access", refresh_token="old_refresh", expires_at=1111111111, user_id=mock_user_id
access_token="old_access",
refresh_token="old_refresh",
expires_at=1111111111,
user_id=mock_user_id,
)
auth_service.central_db.get_user_by_email.return_value = mock_user
@@ -106,9 +120,16 @@ async def test_authenticate_garmin_connect_existing_user_existing_token_success(
auth_service.central_db.update_token.assert_called_once()
auth_service.central_db.create_token.assert_not_called()
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)}
assert result == {
"message": "Garmin Connect authentication successful",
"user_id": str(mock_user.id),
}
@pytest.mark.asyncio
async def test_authenticate_garmin_connect_garmin_failure(auth_service, mock_garth_login):
async def test_authenticate_garmin_connect_garmin_failure(
auth_service, mock_garth_login
):
"""Test Garmin authentication failure."""
email = "fail_garmin@example.com"
password = "password123"
@@ -125,6 +146,7 @@ async def test_authenticate_garmin_connect_garmin_failure(auth_service, mock_gar
assert result is None
@pytest.mark.asyncio
async def test_authenticate_garmin_connect_central_db_user_creation_failure(
auth_service, mock_garth_login, mock_garth_client

View File

@@ -2,25 +2,33 @@ from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
from backend.src.schemas import GarminCredentials
from backend.src.services.garmin_auth_service import GarminAuthService
from src.schemas import GarminCredentials
from src.services.garmin_auth_service import GarminAuthService
@pytest.fixture
def garmin_auth_service():
return GarminAuthService()
@pytest.mark.asyncio
async def test_initial_login_success(garmin_auth_service):
username = "test@example.com"
password = "password123"
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
with patch("src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.login.return_value = None # garth.login doesn't return anything directly
mock_garth.Client.return_value.login.return_value = (
None # garth.login doesn't return anything directly
)
# Mock the attributes that would be set on the client after login
mock_garth.Client.return_value.access_token = f"mock_access_token_for_{username}"
mock_garth.Client.return_value.access_token_secret = f"mock_access_token_secret_for_{username}"
mock_garth.Client.return_value.access_token = (
f"mock_access_token_for_{username}"
)
mock_garth.Client.return_value.access_token_secret = (
f"mock_access_token_secret_for_{username}"
)
mock_garth.Client.return_value.expires_in = 300
credentials = await garmin_auth_service.initial_login(username, password)
@@ -33,19 +41,23 @@ async def test_initial_login_success(garmin_auth_service):
assert isinstance(credentials.token_expiration_date, datetime)
assert credentials.token_expiration_date > datetime.utcnow()
@pytest.mark.asyncio
async def test_initial_login_failure(garmin_auth_service):
username = "invalid@example.com"
password = "wrongpassword"
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.login.side_effect = Exception("Garmin login failed")
mock_garth.Client.return_value.login.side_effect = Exception(
"Garmin login failed"
)
credentials = await garmin_auth_service.initial_login(username, password)
assert credentials is None
@pytest.mark.asyncio
async def test_refresh_tokens_success(garmin_auth_service):
credentials = GarminCredentials(
@@ -53,14 +65,16 @@ async def test_refresh_tokens_success(garmin_auth_service):
garmin_password_plaintext="password123",
access_token="old_access_token",
access_token_secret="old_access_token_secret",
token_expiration_date=datetime.utcnow() - timedelta(minutes=1) # Expired token
token_expiration_date=datetime.utcnow() - timedelta(minutes=1), # Expired token
)
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.reauthorize.return_value = None
mock_garth.Client.return_value.access_token = "refreshed_access_token"
mock_garth.Client.return_value.access_token_secret = "refreshed_access_token_secret"
mock_garth.Client.return_value.access_token_secret = (
"refreshed_access_token_secret"
)
mock_garth.Client.return_value.expires_in = 300
refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials)
@@ -68,10 +82,13 @@ async def test_refresh_tokens_success(garmin_auth_service):
assert refreshed_credentials is not None
assert refreshed_credentials.garmin_username == credentials.garmin_username
assert refreshed_credentials.access_token == "refreshed_access_token"
assert refreshed_credentials.access_token_secret == "refreshed_access_token_secret"
assert (
refreshed_credentials.access_token_secret == "refreshed_access_token_secret"
)
assert isinstance(refreshed_credentials.token_expiration_date, datetime)
assert refreshed_credentials.token_expiration_date > datetime.utcnow()
@pytest.mark.asyncio
async def test_refresh_tokens_failure(garmin_auth_service):
credentials = GarminCredentials(
@@ -79,12 +96,14 @@ async def test_refresh_tokens_failure(garmin_auth_service):
garmin_password_plaintext="invalid_password",
access_token="old_access_token",
access_token_secret="old_access_token_secret",
token_expiration_date=datetime.utcnow() - timedelta(minutes=1)
token_expiration_date=datetime.utcnow() - timedelta(minutes=1),
)
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth:
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.reauthorize.side_effect = Exception("Garmin reauthorize failed")
mock_garth.Client.return_value.reauthorize.side_effect = Exception(
"Garmin reauthorize failed"
)
refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials)

View File

@@ -18,6 +18,7 @@ async def test_rate_limiter_allows_requests_within_limit():
except HTTPException:
pytest.fail("HTTPException raised unexpectedly.")
@pytest.mark.asyncio
async def test_rate_limiter_raises_exception_when_exceeded():
"""Test that the rate limiter raises an HTTPException when the rate limit is exceeded."""
@@ -25,15 +26,20 @@ async def test_rate_limiter_raises_exception_when_exceeded():
mock_request = MagicMock()
# Mock the limiter.test method
with patch.object(rate_limiter.limiter, 'test') as mock_limiter_test:
mock_limiter_test.side_effect = [True, False] # First call returns True, second returns False
with patch.object(rate_limiter.limiter, "test") as mock_limiter_test:
mock_limiter_test.side_effect = [
True,
False,
] # First call returns True, second returns False
await rate_limiter(mock_request) # First call, should pass
await rate_limiter(mock_request) # First call, should pass
with pytest.raises(HTTPException) as exc_info:
await rate_limiter(mock_request) # Second call, should fail
await rate_limiter(mock_request) # Second call, should fail
assert exc_info.value.status_code == 429
mock_limiter_test.assert_called_with(rate_limiter.rate_limit_item, "single_user_system")
mock_limiter_test.assert_called_with(
rate_limiter.rate_limit_item, "single_user_system"
)
assert exc_info.value.status_code == 429

View File

@@ -0,0 +1,27 @@
from datetime import datetime
from backend.src.models.sync_job import SyncJob
def test_sync_job_defaults():
job = SyncJob()
assert job.status == "pending"
assert job.progress == 0.0
assert job.start_time is None
assert job.end_time is None
assert job.error_message is None
assert job.job_type is None
def test_sync_job_with_values():
start_time = datetime.now()
job = SyncJob(
status="in_progress",
progress=0.5,
start_time=start_time,
job_type="activities",
)
assert job.status == "in_progress"
assert job.progress == 0.5
assert job.start_time == start_time
assert job.job_type == "activities"

View File

@@ -0,0 +1,46 @@
import pytest
from backend.src.services.sync_manager import CurrentSyncJobManager
@pytest.mark.asyncio
async def test_singleton():
manager1 = CurrentSyncJobManager()
manager2 = CurrentSyncJobManager()
assert manager1 is manager2
@pytest.mark.asyncio
async def test_start_sync():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
status = await manager.get_current_sync_status()
assert status.status == "in_progress"
assert status.job_type == "activities"
@pytest.mark.asyncio
async def test_start_sync_while_active():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
with pytest.raises(RuntimeError):
await manager.start_sync("workouts")
@pytest.mark.asyncio
async def test_complete_sync():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
await manager.complete_sync()
status = await manager.get_current_sync_status()
assert status.status == "completed"
assert status.progress == 1.0
@pytest.mark.asyncio
async def test_fail_sync():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
await manager.fail_sync("Test error")
status = await manager.get_current_sync_status()
assert status.status == "failed"
assert status.error_message == "Test error"

View File

@@ -1,36 +0,0 @@
import uuid
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from src.jobs import JobStore, SyncJob
from src.services.sync_status_service import SyncStatusService
@pytest.fixture
def mock_job_store():
"""Fixture to create a mock JobStore."""
job_store = MagicMock(spec=JobStore)
job_id = uuid.uuid4()
job = SyncJob(id=str(job_id), status="completed", created_at=datetime.utcnow())
job_store.get_all_jobs.return_value = [job]
job_store.get_job.return_value = job
return job_store
def test_get_sync_jobs_all(mock_job_store):
"""Test retrieving all sync jobs."""
service = SyncStatusService(job_store=mock_job_store)
jobs = service.get_sync_jobs()
assert len(jobs) == 1
mock_job_store.get_all_jobs.assert_called_once()
def test_get_sync_job_by_id(mock_job_store):
"""Test retrieving a single sync job by ID."""
service = SyncStatusService(job_store=mock_job_store)
job_id = mock_job_store.get_job.return_value.id
# The get_sync_jobs implementation filters all jobs, so we need to mock get_all_jobs
mock_job_store.get_all_jobs.return_value = [mock_job_store.get_job.return_value]
jobs = service.get_sync_jobs(job_id=job_id)
assert len(jobs) == 1
assert jobs[0].id == str(job_id)