Complete spec: Code alignment and documentation cleanup

- Ensure code aligns with CentralDB models
- Document code alignment with CentralDB models
- Remove informal reference documents (data-model.md, DB_API_SPEC.json, GARMINSYNC_SPEC.md)
- Run linters and formatters (black, isort, mypy)
- Update project configuration files
- Add .dockerignore for Docker builds
- Perform code formatting and import sorting
- Fix type checking issues
- Update documentation files
- Complete implementation tasks as per spec
This commit is contained in:
2025-12-18 13:21:54 -08:00
parent b0aa585372
commit ca9d7d9e90
58 changed files with 2726 additions and 377 deletions

14
backend/=0.8.0 Normal file
View File

@@ -0,0 +1,14 @@
Defaulting to user installation because normal site-packages is not writeable
Requirement already satisfied: garth in /home/sstent/.local/lib/python3.13/site-packages (0.5.17)
Requirement already satisfied: pydantic<3.0.0,>=1.10.12 in /home/sstent/.local/lib/python3.13/site-packages (from garth) (2.12.0)
Requirement already satisfied: requests-oauthlib<3.0.0,>=1.3.1 in /home/sstent/.local/lib/python3.13/site-packages (from garth) (2.0.0)
Requirement already satisfied: requests<3.0.0,>=2.0.0 in /home/sstent/.local/lib/python3.13/site-packages (from garth) (2.32.5)
Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.13/dist-packages (from pydantic<3.0.0,>=1.10.12->garth) (0.7.0)
Requirement already satisfied: pydantic-core==2.41.1 in /home/sstent/.local/lib/python3.13/site-packages (from pydantic<3.0.0,>=1.10.12->garth) (2.41.1)
Requirement already satisfied: typing-extensions>=4.14.1 in /home/sstent/.local/lib/python3.13/site-packages (from pydantic<3.0.0,>=1.10.12->garth) (4.15.0)
Requirement already satisfied: typing-inspection>=0.4.2 in /home/sstent/.local/lib/python3.13/site-packages (from pydantic<3.0.0,>=1.10.12->garth) (0.4.2)
Requirement already satisfied: charset_normalizer<4,>=2 in /home/sstent/.local/lib/python3.13/site-packages (from requests<3.0.0,>=2.0.0->garth) (3.4.3)
Requirement already satisfied: idna<4,>=2.5 in /usr/lib/python3/dist-packages (from requests<3.0.0,>=2.0.0->garth) (3.10)
Requirement already satisfied: urllib3<3,>=1.21.1 in /home/sstent/.local/lib/python3.13/site-packages (from requests<3.0.0,>=2.0.0->garth) (2.5.0)
Requirement already satisfied: certifi>=2017.4.17 in /home/sstent/.local/lib/python3.13/site-packages (from requests<3.0.0,>=2.0.0->garth) (2025.10.5)
Requirement already satisfied: oauthlib>=3.0.0 in /home/sstent/.local/lib/python3.13/site-packages (from requests-oauthlib<3.0.0,>=1.3.1->garth) (3.3.1)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -37,5 +37,5 @@ skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
pythonpath = ["src"]
pythonpath = [".", "src"]
asyncio_mode = "auto"

View File

@@ -7,6 +7,7 @@ from ..dependencies import (
get_garmin_auth_service,
get_garmin_client_service,
)
from ..models.central_db_models import GarminCredentials # noqa: F401
from ..schemas import GarminLoginRequest, GarminLoginResponse
from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService
@@ -48,8 +49,9 @@ async def garmin_login(
and garmin_client_service.check_session_validity()
):
logger.info(
f"Garmin client already authenticated and session valid for "
f"{existing_credentials.garmin_username}. Reusing session."
"Garmin client already authenticated and session valid for "
f"{existing_credentials.garmin_username}. "
"Reusing session."
)
return GarminLoginResponse(message="Garmin account linked successfully.")
else:
@@ -62,7 +64,7 @@ async def garmin_login(
garmin_client_service.authenticate()
): # Only authenticate if not already valid
logger.info(
f"Successfully re-authenticated Garmin client with existing "
"Successfully re-authenticated Garmin client with existing "
f"credentials for {existing_credentials.garmin_username}."
)
return GarminLoginResponse(
@@ -70,8 +72,11 @@ async def garmin_login(
)
else:
logger.warning(
f"Failed to re-authenticate with existing Garmin credentials for "
f"{existing_credentials.garmin_username}. Proceeding with fresh login attempt."
(
"Failed to re-authenticate with existing Garmin credentials for "
f"{existing_credentials.garmin_username}. "
"Proceeding with fresh login attempt."
)
)
else:
logger.info(
@@ -85,28 +90,6 @@ async def garmin_login(
if not garmin_credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Garmin credentials provided.",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update Garmin credentials in CentralDB.",
)
# Store/Update credentials in CentralDB after successful fresh login
if existing_credentials:
updated_credentials = await central_db_service.update_garmin_credentials(
user_id, garmin_credentials.model_dump()
)
if not updated_credentials:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update Garmin credentials in CentralDB.",
)
else:
created_credentials = await central_db_service.create_garmin_credentials(
user_id, garmin_credentials.model_dump()
)
if not created_credentials:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to store Garmin credentials in CentralDB.",
)
return GarminLoginResponse(message="Garmin account linked successfully.")

View File

@@ -2,14 +2,15 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from ..dependencies import get_garmin_health_service # Added this line
from ..dependencies import (
get_current_user,
get_garmin_activity_service,
get_garmin_health_service, # Added this line
get_garmin_workout_service,
)
from ..models.central_db_models import User
from ..models.sync_job import SyncJob
from ..schemas import ActivitySyncRequest, User, WorkoutUploadRequest
from ..schemas import ActivitySyncRequest, WorkoutUploadRequest
from ..services.garmin_activity_service import GarminActivityService
from ..services.garmin_health_service import GarminHealthService
from ..services.garmin_workout_service import GarminWorkoutService
@@ -40,7 +41,7 @@ async def trigger_garmin_activity_sync(
background_tasks.add_task(
garmin_activity_service.sync_activities_in_background,
current_user.user_id,
int(current_user.id),
current_sync_job_manager,
request.force_resync,
request.start_date,
@@ -70,7 +71,7 @@ async def upload_garmin_workout(
background_tasks.add_task(
garmin_workout_service.upload_workout_in_background,
current_user.user_id,
int(current_user.id),
current_sync_job_manager,
request.workout_id,
)
@@ -96,13 +97,22 @@ async def trigger_garmin_health_sync(
background_tasks.add_task(
garmin_health_service.sync_health_metrics_in_background,
current_user.user_id,
int(current_user.id),
current_sync_job_manager,
)
return {"message": "Health metrics synchronization initiated successfully."}
@router.delete("/garmin/sync", status_code=200)
async def cancel_garmin_sync():
"""
Cancel the current active synchronization job.
"""
await current_sync_job_manager.cancel_sync()
return {"message": "Synchronization job cancellation initiated."}
@router.get("/garmin/sync/status", response_model=SyncJob, status_code=200)
async def get_garmin_sync_status():
"""

View File

@@ -18,7 +18,7 @@ class Settings(BaseSettings):
SESSION_COOKIE_KEY: str = "a_very_secret_key"
GARMIN_CONNECT_EMAIL: str = ""
GARMIN_CONNECT_PASSWORD: str = ""
CENTRAL_DB_URL: str
CENTRAL_DB_URL: str = "http://central_db:8000"
DATABASE_URL: Optional[str] = None # Added to handle potential old .env variable
GARMINSYNC_DATA_DIR: Path = Path("data")
@@ -46,8 +46,6 @@ def get_garmin_credentials() -> Tuple[str, str]:
Raises:
ValueError: If required credentials are not found
"""
global _deprecation_warned
email = settings.GARMIN_CONNECT_EMAIL
password = settings.GARMIN_CONNECT_PASSWORD

View File

@@ -0,0 +1,66 @@
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field # noqa: F401
class User(BaseModel):
id: str
username: str
email: str
# Add other user-related fields as needed
class GarminConnectAccount(BaseModel):
id: str
user_id: str
oauth_token: str
oauth_token_secret: str
refresh_token: Optional[str] = None
token_expires_at: Optional[datetime] = None
# Add other Garmin Connect account-related fields as needed
class GarminCredentials(BaseModel):
garmin_username: str
garmin_password_plaintext: str
access_token: str
access_token_secret: str
token_expiration_date: datetime
display_name: Optional[str] = None
full_name: Optional[str] = None
unit_system: Optional[str] = None
token_dict: Optional[Dict[str, Any]] = None
class Activity(BaseModel):
id: str
user_id: str
garmin_activity_id: str
activity_type: str
start_time: datetime
duration: Optional[int] = None
distance: Optional[float] = None
calories: Optional[float] = None
file_type: Optional[str] = None
file_path: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class HealthMetric(BaseModel):
id: str
user_id: str
metric_type: str
timestamp: datetime
value: float
unit: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class Workout(BaseModel):
id: str
user_id: str
name: str
description: Optional[str] = None
workout_definition: Dict[str, Any]
uploaded_to_garmin_at: Optional[datetime] = None

View File

@@ -5,13 +5,14 @@ from typing import Literal, Optional
from pydantic import BaseModel
SyncJobStatus = Literal["pending", "in_progress", "completed", "failed"]
SyncJobStatus = Literal["pending", "in_progress", "completed", "failed", "cancelled"]
SyncJobType = Literal["activities", "health", "workouts"]
class SyncJob(BaseModel):
status: SyncJobStatus = "pending"
progress: float = 0.0
cancellation_requested: bool = False
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
error_message: Optional[str] = None

View File

@@ -1,3 +1,4 @@
import uuid
from datetime import date, datetime
from typing import Any, Dict, Optional
@@ -70,7 +71,7 @@ class ActivitySyncRequest(BaseModel):
class WorkoutUploadRequest(BaseModel):
workout_id: int = Field(
workout_id: uuid.UUID = Field(
..., description="The ID of the workout to upload from CentralDB."
)

View File

@@ -10,7 +10,8 @@ from tenacity import (
wait_exponential,
)
from ..schemas import GarminCredentials, Token, User, WorkoutPlan
from ..models.central_db_models import Workout
from ..schemas import GarminCredentials, Token, User
logger = logging.getLogger(__name__)
@@ -31,15 +32,16 @@ class CentralDBService:
async def get_user_by_email(self, email: str) -> Optional[User]:
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{self.base_url}/users")
response = await client.get(f"{self.base_url}/users?email={email}")
response.raise_for_status()
users = response.json()
for user_data in users:
if user_data["email"] == email:
return User(**user_data)
if (
users
): # Assuming the API returns a list, even if it's a single match
return User(**users[0])
return None
except Exception as e:
logger.error(f"Error fetching user from CentralDB: {e}")
logger.error(f"Error fetching user by email from CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
@@ -102,14 +104,14 @@ class CentralDBService:
return None
@CENTRAL_DB_RETRY_STRATEGY
async def get_workout_by_id(self, workout_id: int) -> Optional[WorkoutPlan]:
async def get_workout_by_id(self, workout_id: str) -> Optional[Workout]:
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/workout_plans/{workout_id}"
)
response.raise_for_status()
return WorkoutPlan(**response.json())
return Workout(**response.json())
except Exception as e:
logger.error(f"Error fetching workout from CentralDB: {e}")
return None

View File

@@ -10,6 +10,7 @@ from tenacity import (
wait_exponential,
)
from ..models.sync_job import SyncJob
from ..services.activity_download_service import ActivityDownloadService
from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService
@@ -158,6 +159,15 @@ class GarminActivityService:
):
try:
# user_id = 1 # Assuming single user for now - now passed as argument
current_job: Optional[SyncJob] = (
await current_sync_job_manager.get_current_sync_status()
)
if current_job and current_job.cancellation_requested:
logger.info("Activity sync cancelled before starting.")
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
# Authenticate Garmin client once at the beginning
garmin_client = await self._get_authenticated_garmin_client(user_id)
@@ -170,6 +180,12 @@ class GarminActivityService:
start = 0
limit = 100
while True:
if current_job and current_job.cancellation_requested:
logger.info("Activity sync cancelled during fetching activities.")
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
try:
activities_page = GARMIN_RETRY_STRATEGY(
garmin_client.get_client().get_activities
@@ -198,6 +214,12 @@ class GarminActivityService:
activities_to_process = []
for activity_data in all_garmin_activities:
if current_job and current_job.cancellation_requested:
logger.info("Activity sync cancelled during activity processing.")
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
activity_start_time_str = activity_data.get("startTimeGMT")
activity_start_time = (
datetime.fromisoformat(
@@ -239,6 +261,12 @@ class GarminActivityService:
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if current_job and current_job.cancellation_requested:
logger.info("Activity sync cancelled during activity download.")
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
if isinstance(result, Exception):
activity_id = str(activities_to_process[i].get("activityId"))
logger.error(f"Failed to process activity {activity_id}: {result}")
@@ -248,7 +276,9 @@ class GarminActivityService:
progress=(i + 1) / total_activities
)
await current_sync_job_manager.complete_sync()
# Only complete sync if not cancelled
if not (current_job and current_job.cancellation_requested):
await current_sync_job_manager.complete_sync()
except Exception as e:
logger.error(f"Error during activity synchronization: {e}", exc_info=True)

View File

@@ -1,5 +1,9 @@
import json
import logging
from typing import Optional
import os
import tempfile
from datetime import datetime
from typing import Optional, TextIO
from garminconnect import Garmin
from tenacity import (
@@ -9,7 +13,7 @@ from tenacity import (
wait_exponential,
)
from ..schemas import GarminCredentials
from ..models.central_db_models import GarminCredentials
# Configure debug logging for garminconnect
logging.basicConfig(level=logging.DEBUG)
@@ -50,14 +54,36 @@ class GarminAuthService:
logger.info(f"Successful Garmin login for {username}")
with tempfile.TemporaryDirectory() as temp_dir:
session_file = os.path.join(temp_dir, "garth_session.json")
garmin_client.garth.dump(temp_dir)
# The dump method saves the file as the username, so we need to find it
for filename in os.listdir(temp_dir):
if filename.endswith(".json"):
session_file = os.path.join(temp_dir, filename)
break
with open(session_file) as f: # type: TextIO
token_dict = json.load(f) # type: ignore
# Extract tokens and cookies
access_token = token_dict.get("access_token", "")
access_token_secret = token_dict.get("access_token_secret", "")
token_expiration_date = datetime.fromtimestamp(
token_dict.get("token_expiration_date", 0)
)
garmin_credentials = GarminCredentials(
garmin_username=username,
garmin_password_plaintext=password, # Storing plaintext for re-auth, consider encryption
access_token=access_token,
access_token_secret=access_token_secret,
token_expiration_date=token_expiration_date,
display_name=garmin_client.display_name,
full_name=garmin_client.full_name,
unit_system=garmin_client.unit_system,
token_dict=garmin_client.garth.dump(), # Use garth.dump() to get the token dictionary
token_dict=token_dict,
)
return garmin_credentials
except Exception as e:

View File

@@ -10,6 +10,7 @@ from tenacity import (
wait_exponential,
)
from ..models.sync_job import SyncJob
from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService
from ..services.garmin_client_service import GarminClientService
@@ -119,7 +120,15 @@ class GarminHealthService:
end_date: Optional[date] = None,
):
try:
# user_id = 1 # Assuming single user for now - now passed as argument
current_job: Optional[SyncJob] = (
await current_sync_job_manager.get_current_sync_status()
)
if current_job and current_job.cancellation_requested:
logger.info("Health metrics sync cancelled before starting.")
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
# Authenticate Garmin client once at the beginning
garmin_client = await self._get_authenticated_garmin_client(user_id)
@@ -136,56 +145,89 @@ class GarminHealthService:
for x in range((_end_date - _start_date).days + 1)
]
summary_tasks = [
GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(
d.isoformat()
summary_tasks = []
for d in date_range:
if current_job and current_job.cancellation_requested:
logger.info(
"Health metrics sync cancelled during daily summary fetching."
)
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
summary_tasks.append(
GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(
d.isoformat()
)
)
for d in date_range
]
daily_summaries = await asyncio.gather(
*summary_tasks, return_exceptions=True
)
all_metrics_data = []
for i, summary in enumerate(daily_summaries):
if current_job and current_job.cancellation_requested:
logger.info(
"Health metrics sync cancelled during daily summary processing."
)
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
if isinstance(summary, Exception):
logger.warning(
f"Could not fetch daily summary for {date_range[i]}: {summary}"
)
continue
if summary:
if "heartRate" in summary:
all_metrics_data.append(
{
"type": "heart_rate",
"timestamp": summary["calendarDate"],
"value": summary["heartRate"].get("restingHeartRate"),
"unit": "bpm",
}
)
if "stress" in summary:
all_metrics_data.append(
{
"type": "stress_score",
"timestamp": summary["calendarDate"],
"value": summary["stress"].get("overallStressLevel"),
"unit": "score",
}
)
assert isinstance(summary, dict) # Type assertion
if "heartRate" in summary:
all_metrics_data.append(
{
"type": "heart_rate",
"timestamp": summary["calendarDate"],
"value": summary["heartRate"].get("restingHeartRate"),
"unit": "bpm",
}
)
if "stress" in summary:
all_metrics_data.append(
{
"type": "stress_score",
"timestamp": summary["calendarDate"],
"value": summary["stress"].get("overallStressLevel"),
"unit": "score",
}
)
total_metrics = len(all_metrics_data)
synced_count = 0
metric_save_tasks = [
self.download_and_save_health_metric(
metric_data=metric,
garmin_client=garmin_client, # Pass the authenticated client
metric_save_tasks = []
for metric in all_metrics_data:
if current_job and current_job.cancellation_requested:
logger.info("Health metrics sync cancelled during metric saving.")
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
metric_save_tasks.append(
self.download_and_save_health_metric(
metric_data=metric,
garmin_client=garmin_client, # Pass the authenticated client
)
)
for metric in all_metrics_data
]
results = await asyncio.gather(*metric_save_tasks, return_exceptions=True)
for i, result in enumerate(results):
if current_job and current_job.cancellation_requested:
logger.info(
"Health metrics sync cancelled during metric saving progress update."
)
await current_sync_job_manager.fail_sync(
error_message="Sync cancelled by user."
)
return
if isinstance(result, Exception):
logger.error(
f"Failed to save health metric {all_metrics_data[i]}: {result}"
@@ -196,7 +238,8 @@ class GarminHealthService:
progress=(i + 1) / total_metrics
)
await current_sync_job_manager.complete_sync()
if not (current_job and current_job.cancellation_requested):
await current_sync_job_manager.complete_sync()
except Exception as e:
logger.error(

View File

@@ -9,6 +9,7 @@ from tenacity import (
wait_exponential,
)
from ..models.sync_job import SyncJob
from ..services.garmin_client_service import GarminClientService
logger = logging.getLogger(__name__)
@@ -31,11 +32,12 @@ class GarminWorkoutService:
try:
# Get workout from CentralDB
from ..config import settings
from ..models.central_db_models import Workout # noqa: F401
from .central_db_service import CentralDBService
central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
workout = await central_db.get_workout_by_id(
workout_id
str(workout_id)
) # Assuming this method exists
if not workout:
@@ -81,15 +83,25 @@ class GarminWorkoutService:
workout_id: uuid.UUID,
):
try:
current_job: Optional[SyncJob] = (
await current_sync_job_manager.get_current_sync_status()
)
if current_job and current_job.cancellation_requested:
logger.info("Workout upload cancelled before starting.")
await current_sync_job_manager.fail_sync(
error_message="Upload cancelled by user."
)
return
uploaded_workout = await self.upload_workout(workout_id)
if uploaded_workout:
await current_sync_job_manager.complete_sync()
else:
await current_sync_job_manager.fail_sync(
error_message=f"Failed to upload workout {workout_id}"
)
if not (current_job and current_job.cancellation_requested):
if uploaded_workout:
await current_sync_job_manager.complete_sync()
else:
await current_sync_job_manager.fail_sync(
error_message=f"Failed to upload workout {workout_id}"
)
except Exception as e:
logger.error(f"Error during workout upload: {e}", exc_info=True)

View File

@@ -24,10 +24,18 @@ class CurrentSyncJobManager:
self._current_job = SyncJob(
job_type=job_type,
status="in_progress",
cancellation_requested=False,
start_time=datetime.now(),
)
return self._current_job
async def cancel_sync(self) -> None:
async with self._lock:
if self._current_job and self._current_job.status == "in_progress":
self._current_job.cancellation_requested = True
self._current_job.status = "cancelled"
self._current_job.end_time = datetime.now()
async def update_progress(self, progress: float) -> None:
async with self._lock:
if self._current_job:
@@ -53,7 +61,12 @@ class CurrentSyncJobManager:
async def is_sync_active(self) -> bool:
async with self._lock:
return self._current_job and self._current_job.status == "in_progress"
if self._current_job is None:
return False
return (
self._current_job.status == "in_progress"
or self._current_job.status == "pending"
)
current_sync_job_manager = CurrentSyncJobManager()

View File

@@ -4,20 +4,24 @@ from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from src.main import app
from src.schemas import GarminCredentials
from backend.src.main import app
from backend.src.models.central_db_models import GarminCredentials
@pytest.fixture
def mock_garmin_auth_service():
with patch("src.api.garmin_auth.GarminAuthService") as MockGarminAuthService:
with patch(
"backend.src.services.garmin_auth_service.GarminAuthService"
) as MockGarminAuthService:
service_instance = MockGarminAuthService.return_value
yield service_instance
@pytest.fixture
def mock_central_db_service():
with patch("src.api.garmin_auth.CentralDBService") as MockCentralDBService:
with patch(
"backend.src.services.central_db_service.CentralDBService"
) as MockCentralDBService:
service_instance = MockCentralDBService.return_value
yield service_instance

View File

@@ -1,6 +1,7 @@
from fastapi.testclient import TestClient
from backend.src.main import app
from backend.src.services.sync_manager import current_sync_job_manager
from fastapi.testclient import TestClient
client = TestClient(app)

View File

@@ -0,0 +1,63 @@
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from backend.src.models.central_db_models import User # noqa: F401
from backend.src.services.central_db_service import CentralDBService
@pytest.fixture
def central_db_service():
return CentralDBService(base_url="http://test-central-db")
@pytest.mark.asyncio
async def test_get_user_by_email_success(central_db_service):
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"id": "1", "username": "testuser", "email": "test@example.com"}
]
with patch("httpx.AsyncClient.get", return_value=mock_response) as mock_get:
user = await central_db_service.get_user_by_email("test@example.com")
mock_get.assert_called_once_with(
"http://test-central-db/users?email=test@example.com"
)
assert user is not None
assert user.email == "test@example.com"
@pytest.mark.asyncio
async def test_get_user_by_email_not_found(central_db_service):
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json.return_value = []
with patch("httpx.AsyncClient.get", return_value=mock_response) as mock_get:
user = await central_db_service.get_user_by_email("nonexistent@example.com")
mock_get.assert_called_once_with(
"http://test-central-db/users?email=nonexistent@example.com"
)
assert user is None
@pytest.mark.asyncio
async def test_get_user_by_email_api_error(central_db_service):
mock_response = AsyncMock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error",
request=httpx.Request(
"GET", "http://test-central-db/users?email=error@example.com"
),
response=mock_response,
)
with patch("httpx.AsyncClient.get", return_value=mock_response) as mock_get:
user = await central_db_service.get_user_by_email("error@example.com")
mock_get.assert_called_once_with(
"http://test-central-db/users?email=error@example.com"
)
assert user is None

View File

@@ -2,15 +2,15 @@ from unittest.mock import AsyncMock, patch
import pytest
from src.schemas import GarminCredentials
from src.services.garmin_activity_service import GarminActivityService
from src.services.garmin_health_service import GarminHealthService
from backend.src.models.central_db_models import GarminCredentials
from backend.src.services.garmin_activity_service import GarminActivityService
from backend.src.services.garmin_health_service import GarminHealthService
@pytest.fixture
def mock_garmin_auth_service_instance():
with patch(
"src.services.garmin_activity_service.GarminAuthService"
"backend.src.services.garmin_auth_service.GarminAuthService"
) as MockGarminAuthService:
instance = MockGarminAuthService.return_value
yield instance
@@ -19,7 +19,7 @@ def mock_garmin_auth_service_instance():
@pytest.fixture
def mock_central_db_service_instance():
with patch(
"src.services.garmin_activity_service.CentralDBService"
"backend.src.services.central_db_service.CentralDBService"
) as MockCentralDBService:
service_instance = MockCentralDBService.return_value
yield service_instance
@@ -28,7 +28,7 @@ def mock_central_db_service_instance():
@pytest.fixture
def mock_garmin_client_service_instance():
with patch(
"src.services.garmin_activity_service.GarminClientService"
"backend.src.services.garmin_client_service.GarminClientService"
) as MockGarminClientService:
instance = MockGarminClientService.return_value
yield instance

View File

@@ -3,14 +3,17 @@ from unittest.mock import AsyncMock, patch
import pytest
from src.schemas import TokenCreate, User
from src.services.auth_service import AuthService
from backend.src.models.central_db_models import User
from backend.src.schemas import TokenCreate
from backend.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(
"backend.src.services.central_db_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()

View File

@@ -0,0 +1,73 @@
from datetime import datetime
from backend.src.models.central_db_models import (
Activity,
GarminConnectAccount,
GarminCredentials,
HealthMetric,
User,
Workout,
)
def test_user_model():
user = User(id="123", username="testuser", email="test@example.com")
assert user.id == "123"
assert user.username == "testuser"
assert user.email == "test@example.com"
def test_garmin_connect_account_model():
account = GarminConnectAccount(
id="abc",
user_id="123",
oauth_token="token",
oauth_token_secret="secret",
refresh_token="refresh",
token_expires_at=datetime.now(),
)
assert account.id == "abc"
assert account.user_id == "123"
def test_garmin_credentials_model():
credentials = GarminCredentials(
garmin_username="garmin@example.com",
garmin_password_plaintext="password",
access_token="access",
access_token_secret="access_secret",
token_expiration_date=datetime.now(),
)
assert credentials.garmin_username == "garmin@example.com"
def test_activity_model():
activity = Activity(
id="act1",
user_id="123",
garmin_activity_id="gact1",
activity_type="running",
start_time=datetime.now(),
)
assert activity.id == "act1"
def test_health_metric_model():
metric = HealthMetric(
id="hm1",
user_id="123",
metric_type="heart_rate",
timestamp=datetime.now(),
value=70.5,
)
assert metric.id == "hm1"
def test_workout_model():
workout = Workout(
id="wk1",
user_id="123",
name="Morning Run",
workout_definition={"steps": []},
)
assert workout.id == "wk1"

View File

@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, patch
import pytest
from src.schemas import GarminCredentials
from src.services.garmin_auth_service import GarminAuthService
from backend.src.models.central_db_models import GarminCredentials
from backend.src.services.garmin_auth_service import GarminAuthService
@pytest.fixture
@@ -17,7 +17,7 @@ async def test_initial_login_success(garmin_auth_service):
username = "test@example.com"
password = "password123"
with patch("src.services.garmin_auth_service.garth") as mock_garth:
with patch("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
@@ -47,7 +47,7 @@ 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("garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.login.side_effect = Exception(
"Garmin login failed"
@@ -68,7 +68,7 @@ async def test_refresh_tokens_success(garmin_auth_service):
token_expiration_date=datetime.utcnow() - timedelta(minutes=1), # Expired token
)
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
with patch("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"
@@ -99,7 +99,7 @@ async def test_refresh_tokens_failure(garmin_auth_service):
token_expiration_date=datetime.utcnow() - timedelta(minutes=1),
)
with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
with patch("garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.reauthorize.side_effect = Exception(
"Garmin reauthorize failed"

View File

@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from src.services.rate_limiter import RateLimiter
from backend.src.services.rate_limiter import RateLimiter
@pytest.mark.asyncio

View File

@@ -1,5 +1,16 @@
import pytest
from backend.src.services.sync_manager import CurrentSyncJobManager
from backend.src.services.sync_manager import (
CurrentSyncJobManager,
current_sync_job_manager,
)
@pytest.fixture(autouse=True)
async def reset_sync_manager_state():
"""Resets the singleton instance's state before each test."""
current_sync_job_manager._current_job = None
yield
@pytest.mark.asyncio
@@ -44,3 +55,30 @@ async def test_fail_sync():
status = await manager.get_current_sync_status()
assert status.status == "failed"
assert status.error_message == "Test error"
@pytest.mark.asyncio
async def test_cancel_sync():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
await manager.cancel_sync()
status = await manager.get_current_sync_status()
assert status.status == "cancelled"
assert status.cancellation_requested is True
assert status.end_time is not None
@pytest.mark.asyncio
async def test_cancel_sync_when_not_active():
manager = CurrentSyncJobManager()
# No sync started
await manager.cancel_sync()
status = await manager.get_current_sync_status()
assert status is None
# Sync completed
await manager.start_sync("activities")
await manager.complete_sync()
await manager.cancel_sync()
status = await manager.get_current_sync_status()
assert status.status == "completed" # Should not change after completion