feat: Implement Analyze and Plan features, Smart Sync, Theming, and Optional Auth

This commit is contained in:
Moritz Graf
2026-01-01 14:22:48 +01:00
commit 8e55078c14
51 changed files with 5433 additions and 0 deletions
View File
+118
View File
@@ -0,0 +1,118 @@
import pytest
import os
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from main import app
client = TestClient(app)
@pytest.fixture
def mock_sync():
with patch("main.GarminSync") as mock:
yield mock
@pytest.fixture
def mock_engine():
with patch("main.RecommendationEngine") as mock:
yield mock
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_get_activities_success(mock_sync):
mock_instance = mock_sync.return_value
mock_instance.load_local_activities.return_value = [{"activityId": 1}]
response = client.get("/activities")
assert response.status_code == 200
assert response.json() == [{"activityId": 1}]
def test_get_activities_error(mock_sync):
mock_instance = mock_sync.return_value
mock_instance.load_local_activities.side_effect = Exception("Load failed")
response = client.get("/activities")
assert response.status_code == 500
assert "Load failed" in response.json()["detail"]
def test_get_recommendation(mock_sync, mock_engine):
mock_sync_instance = mock_sync.return_value
mock_sync_instance.load_local_activities.return_value = []
mock_engine_instance = mock_engine.return_value
mock_engine_instance.get_recommendation.return_value = "Great job!"
response = client.get("/recommendation")
assert response.status_code == 200
assert response.json() == {"recommendation": "Great job!"}
def test_auth_status_unauthenticated(monkeypatch):
monkeypatch.setenv("GARMIN_EMAIL", "")
response = client.get("/auth/status")
assert response.json()["authenticated"] is False
def test_auth_status_failure(monkeypatch):
monkeypatch.setenv("GARMIN_EMAIL", "test@test.com")
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "FAILURE"
response = client.get("/auth/status")
assert response.json()["authenticated"] is False
assert response.json()["message"] == "Login failed"
def test_auth_status_success(monkeypatch, mock_sync):
monkeypatch.setenv("GARMIN_EMAIL", "test@test.com")
monkeypatch.setenv("GARMIN_PASSWORD", "pass")
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "SUCCESS"
response = client.get("/auth/status")
assert response.json()["authenticated"] is True
def test_auth_status_mfa_required(monkeypatch):
monkeypatch.setenv("GARMIN_EMAIL", "test@test.com")
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "MFA_REQUIRED"
response = client.get("/auth/status")
assert response.json()["status"] == "MFA_REQUIRED"
def test_login_success(mock_sync):
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "SUCCESS"
with patch("builtins.open", MagicMock()):
response = client.post("/auth/login", json={"email": "a", "password": "b"})
assert response.status_code == 200
assert response.json()["status"] == "SUCCESS"
def test_login_mfa_required():
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "MFA_REQUIRED"
response = client.post("/auth/login", json={"email": "a", "password": "b"})
assert response.json()["status"] == "MFA_REQUIRED"
def test_login_missing_data(monkeypatch):
monkeypatch.setenv("GARMIN_EMAIL", "")
monkeypatch.setenv("GARMIN_PASSWORD", "")
response = client.post("/auth/login", json={})
assert response.status_code == 400
def test_login_invalid_creds():
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "FAILURE"
response = client.post("/auth/login", json={"email": "a", "password": "b"})
assert response.status_code == 401
def test_trigger_sync_success(mock_sync):
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "SUCCESS"
mock_sync.return_value.sync_activities.return_value = 5
response = client.post("/sync")
assert response.status_code == 200
assert response.json()["synced_count"] == 5
def test_trigger_sync_unauthorized():
with patch("main.GarminClient") as mock_client:
mock_client.return_value.login.return_value = "FAILURE"
response = client.post("/sync")
assert response.status_code == 401
+145
View File
@@ -0,0 +1,145 @@
import pytest
import os
from unittest.mock import MagicMock, patch
from datetime import date
from garmin.client import GarminClient
@pytest.fixture
def mock_garmin():
with patch("garmin.client.Garmin") as mock:
yield mock
@pytest.fixture
def mock_garth():
with patch("garmin.client.garth") as mock:
yield mock
@pytest.fixture
def mock_sso():
with patch("garmin.client.garth_login") as mock_login, \
patch("garmin.client.resume_login") as mock_resume_login:
yield mock_login, mock_resume_login
def test_client_init():
client = GarminClient(email="test@example.com", password="password")
assert client.email == "test@example.com"
assert client.password == "password"
def test_login_success(mock_sso, mock_garmin, mock_garth):
mock_login, _ = mock_sso
mock_login.return_value = (MagicMock(), MagicMock())
client = GarminClient(email="test@example.com", password="password")
with patch("os.path.exists", return_value=False):
assert client.login(force_login=True) == "SUCCESS"
mock_login.assert_called_once()
def test_login_mfa_required(mock_sso):
mock_login, _ = mock_sso
mock_login.return_value = ("needs_mfa", {"some": "state"})
client = GarminClient(email="test@example.com", password="password")
with patch("os.path.exists", return_value=False):
assert client.login(force_login=True) == "MFA_REQUIRED"
assert GarminClient._temp_client_state == {"some": "state"}
def test_login_mfa_complete(mock_sso, mock_garmin, mock_garth):
_, mock_resume_login = mock_sso
mock_client = MagicMock()
mock_client.oauth1_token = MagicMock()
state = {"some": "state", "client": mock_client}
GarminClient._temp_client_state = state
client = GarminClient(email="test@example.com", password="password")
assert client.login(mfa_code="123456") == "SUCCESS"
mock_resume_login.assert_called_with(state, "123456")
assert GarminClient._temp_client_state is None
assert mock_client.dump.called
def test_login_resume_success(mock_garmin):
client = GarminClient(email="test@example.com", password="password")
inst = MagicMock()
mock_garmin.return_value = inst
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=100):
assert client.login() == "SUCCESS"
inst.login.assert_called_with(tokenstore=client.token_store)
def test_login_resume_fail_no_force(mock_garmin, mock_sso):
mock_login, _ = mock_sso
inst = MagicMock()
inst.login.side_effect = Exception("Resume fail")
mock_garmin.return_value = inst
client = GarminClient(email="test", password="test")
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=100):
assert client.login() == "FAILURE"
assert mock_login.call_count == 0
def test_login_resume_fail_with_force(mock_garmin, mock_sso):
mock_login, _ = mock_sso
mock_login.return_value = (MagicMock(), MagicMock())
inst1 = MagicMock()
inst1.login.side_effect = Exception("Resume fail")
inst2 = MagicMock()
inst2.login.return_value = None
mock_garmin.side_effect = [inst1, inst2]
client = GarminClient(email="test", password="test")
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=100), \
patch("os.remove") as mock_remove:
assert client.login(force_login=True) == "SUCCESS"
assert mock_login.call_count == 1
def test_login_failure(mock_sso):
mock_login, _ = mock_sso
mock_login.side_effect = Exception("Fatal error")
client = GarminClient(email="test@example.com", password="password")
with patch("os.path.exists", return_value=False):
assert client.login(force_login=True) == "FAILURE"
def test_get_activities_not_logged_in():
client = GarminClient()
with pytest.raises(RuntimeError, match="Client not logged in"):
client.get_activities(date.today(), date.today())
def test_get_activities_success(mock_garmin):
mock_instance = mock_garmin.return_value
mock_instance.get_activities_by_date.return_value = [{"activityId": 123}]
client = GarminClient()
client.client = mock_instance
activities = client.get_activities(date(2023, 1, 1), date(2023, 1, 2))
assert activities == [{"activityId": 123}]
def test_get_activities_failure(mock_garmin):
mock_instance = mock_garmin.return_value
mock_instance.get_activities_by_date.side_effect = Exception("err")
client = GarminClient()
client.client = mock_instance
assert client.get_activities(date.today(), date.today()) == []
def test_get_stats_success(mock_garmin):
mock_instance = mock_garmin.return_value
mock_instance.get_stats.return_value = {"steps": 1000}
client = GarminClient()
client.client = mock_instance
stats = client.get_stats(date(2023, 1, 1))
assert stats == {"steps": 1000}
def test_get_user_summary_success(mock_garmin):
mock_instance = mock_garmin.return_value
mock_instance.get_user_summary.return_value = {"calories": 2000}
client = GarminClient()
client.client = mock_instance
summary = client.get_user_summary(date(2023, 1, 1))
assert summary == {"calories": 2000}
+44
View File
@@ -0,0 +1,44 @@
import os
import json
import pytest
from unittest.mock import MagicMock, patch
from datetime import date
from garmin.sync import GarminSync
@pytest.fixture
def mock_client():
return MagicMock()
@pytest.fixture
def temp_storage(tmp_path):
return str(tmp_path / "data")
def test_sync_activities(mock_client, temp_storage):
mock_client.get_activities.return_value = [
{"activityId": 1, "name": "Running"},
{"activityId": 2, "name": "Cycling"}
]
sync = GarminSync(mock_client, storage_dir=temp_storage)
count = sync.sync_activities(days=1)
assert count == 2
assert os.path.exists(os.path.join(temp_storage, "activity_1.json"))
assert os.path.exists(os.path.join(temp_storage, "activity_2.json"))
def test_load_local_activities(mock_client, temp_storage):
os.makedirs(temp_storage, exist_ok=True)
with open(os.path.join(temp_storage, "activity_1.json"), "w") as f:
json.dump({"activityId": 1}, f)
sync = GarminSync(mock_client, storage_dir=temp_storage)
activities = sync.load_local_activities()
assert len(activities) == 1
assert activities[0]["activityId"] == 1
def test_save_activity_no_id(mock_client, temp_storage):
sync = GarminSync(mock_client, storage_dir=temp_storage)
sync._save_activity({"name": "No ID"})
assert len(os.listdir(temp_storage)) == 0 if os.path.exists(temp_storage) else True
+51
View File
@@ -0,0 +1,51 @@
import os
import json
import pytest
from garmin.workout import GarminWorkoutCreator, StrengthWorkout, WorkoutStep
@pytest.fixture
def temp_workout_dir(tmp_path):
return str(tmp_path / "workouts")
def test_create_local_workout(temp_workout_dir):
creator = GarminWorkoutCreator(storage_dir=temp_workout_dir)
workout = StrengthWorkout(
name="Test Workout",
steps=[WorkoutStep(name="Bench Press", reps=10)]
)
path = creator.create_local_workout(workout)
assert os.path.exists(path)
with open(path, "r") as f:
data = json.load(f)
assert data["name"] == "Test Workout"
assert len(data["steps"]) == 1
def test_format_for_garmin():
creator = GarminWorkoutCreator()
workout = StrengthWorkout(
name="Test",
steps=[WorkoutStep(name="Squat", reps=15, rest_seconds=30)]
)
garmin_json = creator.format_for_garmin(workout)
assert garmin_json["workoutName"] == "Test"
# 1 exercise step + 1 rest step
assert len(garmin_json["workoutSteps"]) == 2
assert garmin_json["workoutSteps"][0]["endConditionValue"] == 15
assert garmin_json["workoutSteps"][1]["stepType"]["stepTypeKey"] == "rest"
assert garmin_json["workoutSteps"][1]["endConditionValue"] == 30
def test_load_local_workouts(temp_workout_dir):
os.makedirs(temp_workout_dir, exist_ok=True)
with open(os.path.join(temp_workout_dir, "test.json"), "w") as f:
f.write(json.dumps({
"name": "Stored Workout",
"steps": [{"name": "Pullup", "reps": 5}]
}))
creator = GarminWorkoutCreator(storage_dir=temp_workout_dir)
workouts = creator.load_local_workouts()
assert len(workouts) == 1
assert workouts[0].name == "Stored Workout"
+37
View File
@@ -0,0 +1,37 @@
import pytest
from recommendations.engine import RecommendationEngine
def test_get_recommendation_cycling():
engine = RecommendationEngine()
history = [{"activityName": "Morning Ride", "activityType": {"typeKey": "cycling"}}]
objective = "endurance"
rec = engine.get_recommendation(history, objective)
assert "HIIT" in rec
def test_get_recommendation_strength():
engine = RecommendationEngine()
history = [{"activityName": "Upper Body", "activityType": {"typeKey": "strength_training"}}]
objective = "strong"
rec = engine.get_recommendation(history, objective)
assert "leg strength" in rec
def test_get_recommendation_default():
engine = RecommendationEngine()
history = []
objective = "fitness"
rec = engine.get_recommendation(history, objective)
assert "consistent work" in rec
def test_summarize_history_empty():
engine = RecommendationEngine()
summary = engine._summarize_history([])
assert "No recent training data" in summary
def test_summarize_history_with_data():
engine = RecommendationEngine()
history = [{"activityName": "Run", "activityType": {"typeKey": "running"}}]
summary = engine._summarize_history(history)
assert "- Run (running)" in summary