Spaces:
Running
Running
Yorrick Jansen
commited on
Commit
·
7045b70
1
Parent(s):
9fbd63d
Linting
Browse files- strava_mcp/api.py +6 -3
- strava_mcp/auth.py +5 -1
- strava_mcp/config.py +21 -1
- strava_mcp/oauth_server.py +5 -1
- strava_mcp/server.py +37 -11
- tests/test_api.py +1 -0
- tests/test_config.py +15 -4
- tests/test_server.py +52 -23
- tests/test_service.py +53 -23
strava_mcp/api.py
CHANGED
|
@@ -49,9 +49,12 @@ class StravaAPI:
|
|
| 49 |
# If it's a FastAPI app, use it directly
|
| 50 |
if hasattr(self.app, "add_api_route"):
|
| 51 |
fastapi_app = self.app
|
| 52 |
-
# If it's a FastMCP server, try to get its
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
if not fastapi_app:
|
| 57 |
logger.warning("Could not get FastAPI app from the provided object, auth flow will not be available")
|
|
|
|
| 49 |
# If it's a FastAPI app, use it directly
|
| 50 |
if hasattr(self.app, "add_api_route"):
|
| 51 |
fastapi_app = self.app
|
| 52 |
+
# If it's a FastMCP server, try to get its underlying app
|
| 53 |
+
# FastMCP doesn't have a public API for this, but we can use type checking
|
| 54 |
+
# to treat it as a FastAPI instance as it extends FastAPI
|
| 55 |
+
else:
|
| 56 |
+
# Just use the app itself since it should be a FastAPI instance or subclass
|
| 57 |
+
fastapi_app = self.app
|
| 58 |
|
| 59 |
if not fastapi_app:
|
| 60 |
logger.warning("Could not get FastAPI app from the provided object, auth flow will not be available")
|
strava_mcp/auth.py
CHANGED
|
@@ -137,7 +137,7 @@ class StravaAuthenticator:
|
|
| 137 |
}
|
| 138 |
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
| 139 |
|
| 140 |
-
def setup_routes(self, app: FastAPI = None):
|
| 141 |
"""Set up the routes for authentication.
|
| 142 |
|
| 143 |
Args:
|
|
@@ -147,6 +147,10 @@ class StravaAuthenticator:
|
|
| 147 |
if not target_app:
|
| 148 |
raise ValueError("No FastAPI app provided")
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
# Add route for the token exchange
|
| 151 |
target_app.add_api_route(self.redirect_path, self.exchange_token, methods=["GET"])
|
| 152 |
|
|
|
|
| 137 |
}
|
| 138 |
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
| 139 |
|
| 140 |
+
def setup_routes(self, app: FastAPI | None = None):
|
| 141 |
"""Set up the routes for authentication.
|
| 142 |
|
| 143 |
Args:
|
|
|
|
| 147 |
if not target_app:
|
| 148 |
raise ValueError("No FastAPI app provided")
|
| 149 |
|
| 150 |
+
# Make sure we have a valid FastAPI app
|
| 151 |
+
if not hasattr(target_app, "add_api_route"):
|
| 152 |
+
raise ValueError("Provided app does not appear to be a valid FastAPI instance")
|
| 153 |
+
|
| 154 |
# Add route for the token exchange
|
| 155 |
target_app.add_api_route(self.redirect_path, self.exchange_token, methods=["GET"])
|
| 156 |
|
strava_mcp/config.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from pydantic import Field
|
| 2 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
|
| 4 |
|
|
@@ -14,3 +14,23 @@ class StravaSettings(BaseSettings):
|
|
| 14 |
base_url: str = Field("https://www.strava.com/api/v3", description="Strava API base URL")
|
| 15 |
|
| 16 |
model_config = SettingsConfigDict(env_prefix="STRAVA_", env_file=".env", env_file_encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import Field, model_validator
|
| 2 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
|
| 4 |
|
|
|
|
| 14 |
base_url: str = Field("https://www.strava.com/api/v3", description="Strava API base URL")
|
| 15 |
|
| 16 |
model_config = SettingsConfigDict(env_prefix="STRAVA_", env_file=".env", env_file_encoding="utf-8")
|
| 17 |
+
|
| 18 |
+
@model_validator(mode="after")
|
| 19 |
+
def load_from_env(self):
|
| 20 |
+
"""Load values from environment variables if not directly provided."""
|
| 21 |
+
import os
|
| 22 |
+
|
| 23 |
+
# Only override empty values with environment values
|
| 24 |
+
if not self.client_id and os.environ.get("STRAVA_CLIENT_ID"):
|
| 25 |
+
self.client_id = os.environ["STRAVA_CLIENT_ID"]
|
| 26 |
+
|
| 27 |
+
if not self.client_secret and os.environ.get("STRAVA_CLIENT_SECRET"):
|
| 28 |
+
self.client_secret = os.environ["STRAVA_CLIENT_SECRET"]
|
| 29 |
+
|
| 30 |
+
if not self.refresh_token and os.environ.get("STRAVA_REFRESH_TOKEN"):
|
| 31 |
+
self.refresh_token = os.environ["STRAVA_REFRESH_TOKEN"]
|
| 32 |
+
|
| 33 |
+
if not self.base_url and os.environ.get("STRAVA_BASE_URL"):
|
| 34 |
+
self.base_url = os.environ["STRAVA_BASE_URL"]
|
| 35 |
+
|
| 36 |
+
return self
|
strava_mcp/oauth_server.py
CHANGED
|
@@ -122,8 +122,12 @@ class StravaOAuthServer:
|
|
| 122 |
|
| 123 |
async def _run_server(self):
|
| 124 |
"""Run the uvicorn server."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
config = uvicorn.Config(
|
| 126 |
-
app=self.app,
|
| 127 |
host=self.host,
|
| 128 |
port=self.port,
|
| 129 |
log_level="info",
|
|
|
|
| 122 |
|
| 123 |
async def _run_server(self):
|
| 124 |
"""Run the uvicorn server."""
|
| 125 |
+
# Ensure app is not None before passing to uvicorn
|
| 126 |
+
if not self.app:
|
| 127 |
+
raise ValueError("FastAPI app not initialized")
|
| 128 |
+
|
| 129 |
config = uvicorn.Config(
|
| 130 |
+
app=self.app, # The type checker should now be satisfied
|
| 131 |
host=self.host,
|
| 132 |
port=self.port,
|
| 133 |
log_level="info",
|
strava_mcp/server.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import logging
|
| 2 |
from collections.abc import AsyncIterator
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
-
from typing import Any
|
| 5 |
|
|
|
|
| 6 |
from mcp.server.fastmcp import Context, FastMCP
|
| 7 |
|
| 8 |
from strava_mcp.config import StravaSettings
|
|
@@ -28,16 +29,20 @@ async def lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
|
| 28 |
"""
|
| 29 |
# Load settings from environment variables
|
| 30 |
try:
|
| 31 |
-
settings = StravaSettings(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
logger.info("Loaded Strava API settings")
|
| 33 |
except Exception as e:
|
| 34 |
logger.error(f"Failed to load Strava API settings: {str(e)}")
|
| 35 |
raise
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
fastapi_app = server
|
| 39 |
|
| 40 |
-
# Initialize the Strava service with the FastAPI app
|
| 41 |
service = StravaService(settings, fastapi_app)
|
| 42 |
logger.info("Initialized Strava service")
|
| 43 |
|
|
@@ -81,9 +86,16 @@ async def get_user_activities(
|
|
| 81 |
Returns:
|
| 82 |
List of activities
|
| 83 |
"""
|
| 84 |
-
service = ctx.request_context.lifespan_context["service"]
|
| 85 |
-
|
| 86 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
activities = await service.get_activities(before, after, page, per_page)
|
| 88 |
return [activity.model_dump() for activity in activities]
|
| 89 |
except Exception as e:
|
|
@@ -107,9 +119,16 @@ async def get_activity(
|
|
| 107 |
Returns:
|
| 108 |
The activity details
|
| 109 |
"""
|
| 110 |
-
service = ctx.request_context.lifespan_context["service"]
|
| 111 |
-
|
| 112 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
activity = await service.get_activity(activity_id, include_all_efforts)
|
| 114 |
return activity.model_dump()
|
| 115 |
except Exception as e:
|
|
@@ -131,9 +150,16 @@ async def get_activity_segments(
|
|
| 131 |
Returns:
|
| 132 |
List of segment efforts for the activity
|
| 133 |
"""
|
| 134 |
-
service = ctx.request_context.lifespan_context["service"]
|
| 135 |
-
|
| 136 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
segments = await service.get_activity_segments(activity_id)
|
| 138 |
return [segment.model_dump() for segment in segments]
|
| 139 |
except Exception as e:
|
|
|
|
| 1 |
import logging
|
| 2 |
from collections.abc import AsyncIterator
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
+
from typing import Any, cast
|
| 5 |
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
from mcp.server.fastmcp import Context, FastMCP
|
| 8 |
|
| 9 |
from strava_mcp.config import StravaSettings
|
|
|
|
| 29 |
"""
|
| 30 |
# Load settings from environment variables
|
| 31 |
try:
|
| 32 |
+
settings = StravaSettings(
|
| 33 |
+
client_id="", # Will be overridden by env vars
|
| 34 |
+
client_secret="", # Will be overridden by env vars
|
| 35 |
+
base_url="https://www.strava.com/api/v3",
|
| 36 |
+
)
|
| 37 |
logger.info("Loaded Strava API settings")
|
| 38 |
except Exception as e:
|
| 39 |
logger.error(f"Failed to load Strava API settings: {str(e)}")
|
| 40 |
raise
|
| 41 |
|
| 42 |
+
# FastMCP extends FastAPI, so we can safely cast it for type checking
|
| 43 |
+
fastapi_app = cast(FastAPI, server)
|
| 44 |
|
| 45 |
+
# Initialize the Strava service with the FastAPI app
|
| 46 |
service = StravaService(settings, fastapi_app)
|
| 47 |
logger.info("Initialized Strava service")
|
| 48 |
|
|
|
|
| 86 |
Returns:
|
| 87 |
List of activities
|
| 88 |
"""
|
|
|
|
|
|
|
| 89 |
try:
|
| 90 |
+
# Safely access service from context
|
| 91 |
+
if not ctx.request_context.lifespan_context:
|
| 92 |
+
raise ValueError("Lifespan context not available")
|
| 93 |
+
|
| 94 |
+
# Cast service to StravaService to satisfy type checker
|
| 95 |
+
service = cast(StravaService, ctx.request_context.lifespan_context.get("service"))
|
| 96 |
+
if not service:
|
| 97 |
+
raise ValueError("Service not available in context")
|
| 98 |
+
|
| 99 |
activities = await service.get_activities(before, after, page, per_page)
|
| 100 |
return [activity.model_dump() for activity in activities]
|
| 101 |
except Exception as e:
|
|
|
|
| 119 |
Returns:
|
| 120 |
The activity details
|
| 121 |
"""
|
|
|
|
|
|
|
| 122 |
try:
|
| 123 |
+
# Safely access service from context
|
| 124 |
+
if not ctx.request_context.lifespan_context:
|
| 125 |
+
raise ValueError("Lifespan context not available")
|
| 126 |
+
|
| 127 |
+
# Cast service to StravaService to satisfy type checker
|
| 128 |
+
service = cast(StravaService, ctx.request_context.lifespan_context.get("service"))
|
| 129 |
+
if not service:
|
| 130 |
+
raise ValueError("Service not available in context")
|
| 131 |
+
|
| 132 |
activity = await service.get_activity(activity_id, include_all_efforts)
|
| 133 |
return activity.model_dump()
|
| 134 |
except Exception as e:
|
|
|
|
| 150 |
Returns:
|
| 151 |
List of segment efforts for the activity
|
| 152 |
"""
|
|
|
|
|
|
|
| 153 |
try:
|
| 154 |
+
# Safely access service from context
|
| 155 |
+
if not ctx.request_context.lifespan_context:
|
| 156 |
+
raise ValueError("Lifespan context not available")
|
| 157 |
+
|
| 158 |
+
# Cast service to StravaService to satisfy type checker
|
| 159 |
+
service = cast(StravaService, ctx.request_context.lifespan_context.get("service"))
|
| 160 |
+
if not service:
|
| 161 |
+
raise ValueError("Service not available in context")
|
| 162 |
+
|
| 163 |
segments = await service.get_activity_segments(activity_id)
|
| 164 |
return [segment.model_dump() for segment in segments]
|
| 165 |
except Exception as e:
|
tests/test_api.py
CHANGED
|
@@ -14,6 +14,7 @@ def settings():
|
|
| 14 |
client_id="test_client_id",
|
| 15 |
client_secret="test_client_secret",
|
| 16 |
refresh_token="test_refresh_token",
|
|
|
|
| 17 |
)
|
| 18 |
|
| 19 |
|
|
|
|
| 14 |
client_id="test_client_id",
|
| 15 |
client_secret="test_client_secret",
|
| 16 |
refresh_token="test_refresh_token",
|
| 17 |
+
base_url="https://www.strava.com/api/v3",
|
| 18 |
)
|
| 19 |
|
| 20 |
|
tests/test_config.py
CHANGED
|
@@ -15,6 +15,7 @@ def test_strava_settings_defaults():
|
|
| 15 |
client_id="test_client_id",
|
| 16 |
client_secret="test_client_secret",
|
| 17 |
refresh_token=None,
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
assert settings.client_id == "test_client_id"
|
|
@@ -34,7 +35,12 @@ def test_strava_settings_from_env():
|
|
| 34 |
"STRAVA_BASE_URL": "https://custom.strava.api/v3",
|
| 35 |
},
|
| 36 |
):
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
assert settings.client_id == "env_client_id"
|
| 40 |
assert settings.client_secret == "env_client_secret"
|
|
@@ -54,7 +60,9 @@ def test_strava_settings_override():
|
|
| 54 |
):
|
| 55 |
settings = StravaSettings(
|
| 56 |
client_id="direct_client_id",
|
|
|
|
| 57 |
refresh_token="direct_refresh_token",
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
# Direct values should override environment variables
|
|
@@ -65,6 +73,9 @@ def test_strava_settings_override():
|
|
| 65 |
|
| 66 |
def test_strava_settings_model_config():
|
| 67 |
"""Test model configuration for StravaSettings."""
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
client_id="test_client_id",
|
| 16 |
client_secret="test_client_secret",
|
| 17 |
refresh_token=None,
|
| 18 |
+
base_url="https://www.strava.com/api/v3",
|
| 19 |
)
|
| 20 |
|
| 21 |
assert settings.client_id == "test_client_id"
|
|
|
|
| 35 |
"STRAVA_BASE_URL": "https://custom.strava.api/v3",
|
| 36 |
},
|
| 37 |
):
|
| 38 |
+
# Even with env vars, we need to provide required params for type checking
|
| 39 |
+
settings = StravaSettings(
|
| 40 |
+
client_id="", # Will be overridden by env vars
|
| 41 |
+
client_secret="", # Will be overridden by env vars
|
| 42 |
+
base_url="", # Will be overridden by env vars
|
| 43 |
+
)
|
| 44 |
|
| 45 |
assert settings.client_id == "env_client_id"
|
| 46 |
assert settings.client_secret == "env_client_secret"
|
|
|
|
| 60 |
):
|
| 61 |
settings = StravaSettings(
|
| 62 |
client_id="direct_client_id",
|
| 63 |
+
client_secret="", # Will be taken from env vars
|
| 64 |
refresh_token="direct_refresh_token",
|
| 65 |
+
base_url="https://www.strava.com/api/v3",
|
| 66 |
)
|
| 67 |
|
| 68 |
# Direct values should override environment variables
|
|
|
|
| 73 |
|
| 74 |
def test_strava_settings_model_config():
|
| 75 |
"""Test model configuration for StravaSettings."""
|
| 76 |
+
# Access model_config safely, with type handling
|
| 77 |
+
model_config = StravaSettings.model_config
|
| 78 |
+
# We can safely access these fields as we know they exist in our configuration
|
| 79 |
+
assert model_config.get("env_prefix") == "STRAVA_"
|
| 80 |
+
assert model_config.get("env_file") == ".env"
|
| 81 |
+
assert model_config.get("env_file_encoding") == "utf-8"
|
tests/test_server.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
|
|
| 1 |
from unittest.mock import AsyncMock, MagicMock, patch
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
-
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort
|
| 6 |
|
| 7 |
# Patch the StravaOAuthServer._run_server method to prevent coroutine warnings
|
| 8 |
# This must be at the module level before any imports that might create the coroutine
|
|
@@ -45,8 +46,8 @@ async def test_get_user_activities(mock_ctx, mock_service):
|
|
| 45 |
total_elevation_gain=50,
|
| 46 |
type="Run",
|
| 47 |
sport_type="Run",
|
| 48 |
-
start_date="2023-01-01T10:00:
|
| 49 |
-
start_date_local="2023-01-01T10:00:
|
| 50 |
timezone="Europe/London",
|
| 51 |
achievement_count=2,
|
| 52 |
kudos_count=5,
|
|
@@ -63,6 +64,11 @@ async def test_get_user_activities(mock_ctx, mock_service):
|
|
| 63 |
has_heartrate=True,
|
| 64 |
average_heartrate=140,
|
| 65 |
max_heartrate=160,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
)
|
| 67 |
mock_service.get_activities.return_value = [mock_activity]
|
| 68 |
|
|
@@ -92,8 +98,8 @@ async def test_get_activity(mock_ctx, mock_service):
|
|
| 92 |
total_elevation_gain=50,
|
| 93 |
type="Run",
|
| 94 |
sport_type="Run",
|
| 95 |
-
start_date="2023-01-01T10:00:
|
| 96 |
-
start_date_local="2023-01-01T10:00:
|
| 97 |
timezone="Europe/London",
|
| 98 |
achievement_count=2,
|
| 99 |
kudos_count=5,
|
|
@@ -112,6 +118,19 @@ async def test_get_activity(mock_ctx, mock_service):
|
|
| 112 |
max_heartrate=160,
|
| 113 |
athlete={"id": 123},
|
| 114 |
description="Test description",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
)
|
| 116 |
mock_service.get_activity.return_value = mock_activity
|
| 117 |
|
|
@@ -139,26 +158,36 @@ async def test_get_activity_segments(mock_ctx, mock_service):
|
|
| 139 |
name="Test Segment",
|
| 140 |
elapsed_time=180,
|
| 141 |
moving_time=180,
|
| 142 |
-
start_date="2023-01-01T10:05:
|
| 143 |
-
start_date_local="2023-01-01T10:05:
|
| 144 |
distance=1000,
|
| 145 |
athlete={"id": 123},
|
| 146 |
-
segment=
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
)
|
| 163 |
mock_service.get_activity_segments.return_value = [mock_segment]
|
| 164 |
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
from unittest.mock import AsyncMock, MagicMock, patch
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
+
from strava_mcp.models import Activity, DetailedActivity, Segment, SegmentEffort
|
| 7 |
|
| 8 |
# Patch the StravaOAuthServer._run_server method to prevent coroutine warnings
|
| 9 |
# This must be at the module level before any imports that might create the coroutine
|
|
|
|
| 46 |
total_elevation_gain=50,
|
| 47 |
type="Run",
|
| 48 |
sport_type="Run",
|
| 49 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 50 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 51 |
timezone="Europe/London",
|
| 52 |
achievement_count=2,
|
| 53 |
kudos_count=5,
|
|
|
|
| 64 |
has_heartrate=True,
|
| 65 |
average_heartrate=140,
|
| 66 |
max_heartrate=160,
|
| 67 |
+
# Add required fields with default values
|
| 68 |
+
map=None,
|
| 69 |
+
workout_type=None,
|
| 70 |
+
elev_high=None,
|
| 71 |
+
elev_low=None,
|
| 72 |
)
|
| 73 |
mock_service.get_activities.return_value = [mock_activity]
|
| 74 |
|
|
|
|
| 98 |
total_elevation_gain=50,
|
| 99 |
type="Run",
|
| 100 |
sport_type="Run",
|
| 101 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 102 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 103 |
timezone="Europe/London",
|
| 104 |
achievement_count=2,
|
| 105 |
kudos_count=5,
|
|
|
|
| 118 |
max_heartrate=160,
|
| 119 |
athlete={"id": 123},
|
| 120 |
description="Test description",
|
| 121 |
+
# Add required fields with default values
|
| 122 |
+
map=None,
|
| 123 |
+
workout_type=None,
|
| 124 |
+
elev_high=None,
|
| 125 |
+
elev_low=None,
|
| 126 |
+
calories=None,
|
| 127 |
+
segment_efforts=None,
|
| 128 |
+
splits_metric=None,
|
| 129 |
+
splits_standard=None,
|
| 130 |
+
best_efforts=None,
|
| 131 |
+
photos=None,
|
| 132 |
+
gear=None,
|
| 133 |
+
device_name=None,
|
| 134 |
)
|
| 135 |
mock_service.get_activity.return_value = mock_activity
|
| 136 |
|
|
|
|
| 158 |
name="Test Segment",
|
| 159 |
elapsed_time=180,
|
| 160 |
moving_time=180,
|
| 161 |
+
start_date=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 162 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 163 |
distance=1000,
|
| 164 |
athlete={"id": 123},
|
| 165 |
+
segment=Segment(
|
| 166 |
+
id=12345,
|
| 167 |
+
name="Test Segment",
|
| 168 |
+
activity_type="Run",
|
| 169 |
+
distance=1000,
|
| 170 |
+
average_grade=5.0,
|
| 171 |
+
maximum_grade=10.0,
|
| 172 |
+
elevation_high=200,
|
| 173 |
+
elevation_low=150,
|
| 174 |
+
total_elevation_gain=50,
|
| 175 |
+
start_latlng=[51.5, -0.1],
|
| 176 |
+
end_latlng=[51.5, -0.2],
|
| 177 |
+
climb_category=0,
|
| 178 |
+
private=False,
|
| 179 |
+
starred=False,
|
| 180 |
+
city=None,
|
| 181 |
+
state=None,
|
| 182 |
+
country=None,
|
| 183 |
+
),
|
| 184 |
+
# Add required fields with default values
|
| 185 |
+
average_watts=None,
|
| 186 |
+
device_watts=None,
|
| 187 |
+
average_heartrate=None,
|
| 188 |
+
max_heartrate=None,
|
| 189 |
+
pr_rank=None,
|
| 190 |
+
achievements=None,
|
| 191 |
)
|
| 192 |
mock_service.get_activity_segments.return_value = [mock_segment]
|
| 193 |
|
tests/test_service.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
|
|
| 1 |
from unittest.mock import AsyncMock, patch
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from strava_mcp.config import StravaSettings
|
| 6 |
-
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort
|
| 7 |
from strava_mcp.service import StravaService
|
| 8 |
|
| 9 |
|
|
@@ -13,6 +14,7 @@ def settings():
|
|
| 13 |
client_id="test_client_id",
|
| 14 |
client_secret="test_client_secret",
|
| 15 |
refresh_token="test_refresh_token",
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
|
@@ -44,8 +46,8 @@ async def test_get_activities(service, mock_api):
|
|
| 44 |
total_elevation_gain=50,
|
| 45 |
type="Run",
|
| 46 |
sport_type="Run",
|
| 47 |
-
start_date="2023-01-01T10:00:
|
| 48 |
-
start_date_local="2023-01-01T10:00:
|
| 49 |
timezone="Europe/London",
|
| 50 |
achievement_count=2,
|
| 51 |
kudos_count=5,
|
|
@@ -62,6 +64,11 @@ async def test_get_activities(service, mock_api):
|
|
| 62 |
has_heartrate=True,
|
| 63 |
average_heartrate=140,
|
| 64 |
max_heartrate=160,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
)
|
| 66 |
mock_api.get_activities.return_value = [mock_activity]
|
| 67 |
|
|
@@ -88,8 +95,8 @@ async def test_get_activity(service, mock_api):
|
|
| 88 |
total_elevation_gain=50,
|
| 89 |
type="Run",
|
| 90 |
sport_type="Run",
|
| 91 |
-
start_date="2023-01-01T10:00:
|
| 92 |
-
start_date_local="2023-01-01T10:00:
|
| 93 |
timezone="Europe/London",
|
| 94 |
achievement_count=2,
|
| 95 |
kudos_count=5,
|
|
@@ -108,6 +115,19 @@ async def test_get_activity(service, mock_api):
|
|
| 108 |
max_heartrate=160,
|
| 109 |
athlete={"id": 123},
|
| 110 |
description="Test description",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
)
|
| 112 |
mock_api.get_activity.return_value = mock_activity
|
| 113 |
|
|
@@ -131,26 +151,36 @@ async def test_get_activity_segments(service, mock_api):
|
|
| 131 |
name="Test Segment",
|
| 132 |
elapsed_time=180,
|
| 133 |
moving_time=180,
|
| 134 |
-
start_date="2023-01-01T10:05:
|
| 135 |
-
start_date_local="2023-01-01T10:05:
|
| 136 |
distance=1000,
|
| 137 |
athlete={"id": 123},
|
| 138 |
-
segment=
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
)
|
| 155 |
mock_api.get_activity_segments.return_value = [mock_segment]
|
| 156 |
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
from unittest.mock import AsyncMock, patch
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
from strava_mcp.config import StravaSettings
|
| 7 |
+
from strava_mcp.models import Activity, DetailedActivity, Segment, SegmentEffort
|
| 8 |
from strava_mcp.service import StravaService
|
| 9 |
|
| 10 |
|
|
|
|
| 14 |
client_id="test_client_id",
|
| 15 |
client_secret="test_client_secret",
|
| 16 |
refresh_token="test_refresh_token",
|
| 17 |
+
base_url="https://www.strava.com/api/v3",
|
| 18 |
)
|
| 19 |
|
| 20 |
|
|
|
|
| 46 |
total_elevation_gain=50,
|
| 47 |
type="Run",
|
| 48 |
sport_type="Run",
|
| 49 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 50 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 51 |
timezone="Europe/London",
|
| 52 |
achievement_count=2,
|
| 53 |
kudos_count=5,
|
|
|
|
| 64 |
has_heartrate=True,
|
| 65 |
average_heartrate=140,
|
| 66 |
max_heartrate=160,
|
| 67 |
+
# Add required fields with default values
|
| 68 |
+
map=None,
|
| 69 |
+
workout_type=None,
|
| 70 |
+
elev_high=None,
|
| 71 |
+
elev_low=None,
|
| 72 |
)
|
| 73 |
mock_api.get_activities.return_value = [mock_activity]
|
| 74 |
|
|
|
|
| 95 |
total_elevation_gain=50,
|
| 96 |
type="Run",
|
| 97 |
sport_type="Run",
|
| 98 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 99 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 100 |
timezone="Europe/London",
|
| 101 |
achievement_count=2,
|
| 102 |
kudos_count=5,
|
|
|
|
| 115 |
max_heartrate=160,
|
| 116 |
athlete={"id": 123},
|
| 117 |
description="Test description",
|
| 118 |
+
# Add required fields with default values
|
| 119 |
+
map=None,
|
| 120 |
+
workout_type=None,
|
| 121 |
+
elev_high=None,
|
| 122 |
+
elev_low=None,
|
| 123 |
+
calories=None,
|
| 124 |
+
segment_efforts=None,
|
| 125 |
+
splits_metric=None,
|
| 126 |
+
splits_standard=None,
|
| 127 |
+
best_efforts=None,
|
| 128 |
+
photos=None,
|
| 129 |
+
gear=None,
|
| 130 |
+
device_name=None,
|
| 131 |
)
|
| 132 |
mock_api.get_activity.return_value = mock_activity
|
| 133 |
|
|
|
|
| 151 |
name="Test Segment",
|
| 152 |
elapsed_time=180,
|
| 153 |
moving_time=180,
|
| 154 |
+
start_date=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 155 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 156 |
distance=1000,
|
| 157 |
athlete={"id": 123},
|
| 158 |
+
segment=Segment(
|
| 159 |
+
id=12345,
|
| 160 |
+
name="Test Segment",
|
| 161 |
+
activity_type="Run",
|
| 162 |
+
distance=1000,
|
| 163 |
+
average_grade=5.0,
|
| 164 |
+
maximum_grade=10.0,
|
| 165 |
+
elevation_high=200,
|
| 166 |
+
elevation_low=150,
|
| 167 |
+
total_elevation_gain=50,
|
| 168 |
+
start_latlng=[51.5, -0.1],
|
| 169 |
+
end_latlng=[51.5, -0.2],
|
| 170 |
+
climb_category=0,
|
| 171 |
+
private=False,
|
| 172 |
+
starred=False,
|
| 173 |
+
city=None,
|
| 174 |
+
state=None,
|
| 175 |
+
country=None,
|
| 176 |
+
),
|
| 177 |
+
# Add required fields with default values
|
| 178 |
+
average_watts=None,
|
| 179 |
+
device_watts=None,
|
| 180 |
+
average_heartrate=None,
|
| 181 |
+
max_heartrate=None,
|
| 182 |
+
pr_rank=None,
|
| 183 |
+
achievements=None,
|
| 184 |
)
|
| 185 |
mock_api.get_activity_segments.return_value = [mock_segment]
|
| 186 |
|