Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch
build-image / build (push) Failing after 18s
build-image / build (push) Failing after 18s
- Bump mcp[cli] 1.22 -> 1.28.1 (negotiates MCP protocol 2025-11-25, matching current Claude clients; the old 2025-06-18 server never got a tools/list on the connector surface). - Bake transport config into code: stateless_http + json_response for HTTP (single JSON body instead of a 34KB SSE stream, which the connector pipeline handles far more reliably). - Bake Authentik OAuth (AuthSettings + JWT TokenVerifier) into intervals_mcp_server.auth, configured from MCP_ISSUER/MCP_RESOURCE/MCP_JWKS_URI/MCP_CLIENT_ID — removes the runtime FastMCP.__init__ monkeypatch from the k8s deployment command. - Accept token audience with/without trailing slash (RFC 8707 clients use the slash-normalised resource metadata value). - Dockerfile CMD runs the module (transport via MCP_TRANSPORT); add .gitea CI to build+push the image to git.farh.net/farhoodlabs/intervalsicu-mcp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"id": "2025-05-24",
|
||||
"ctl": 70.87253,
|
||||
"atl": 91.97159,
|
||||
"rampRate": 6.997368,
|
||||
"ctlLoad": 299,
|
||||
"atlLoad": 299,
|
||||
"sportInfo": [
|
||||
{
|
||||
"type": "Ride",
|
||||
"eftp": 282.85992
|
||||
},
|
||||
{
|
||||
"type": "Run",
|
||||
"eftp": null
|
||||
}
|
||||
],
|
||||
"updated": "2025-05-25T08:10:00.827+00:00",
|
||||
"weight": 78,
|
||||
"restingHR": 50,
|
||||
"hrv": null,
|
||||
"hrvSDNN": null,
|
||||
"menstrualPhase": null,
|
||||
"menstrualPhasePredicted": null,
|
||||
"kcalConsumed": null,
|
||||
"carbohydrates": null,
|
||||
"protein": null,
|
||||
"fatTotal": null,
|
||||
"sleepSecs": null,
|
||||
"sleepScore": null,
|
||||
"sleepQuality": null,
|
||||
"avgSleepingHR": null,
|
||||
"soreness": null,
|
||||
"fatigue": null,
|
||||
"stress": null,
|
||||
"mood": null,
|
||||
"motivation": null,
|
||||
"injury": null,
|
||||
"spO2": null,
|
||||
"systolic": null,
|
||||
"diastolic": null,
|
||||
"hydration": null,
|
||||
"hydrationVolume": null,
|
||||
"readiness": null,
|
||||
"baevskySI": null,
|
||||
"bloodGlucose": null,
|
||||
"lactate": null,
|
||||
"bodyFat": null,
|
||||
"abdomen": null,
|
||||
"vo2max": null,
|
||||
"comments": null,
|
||||
"steps": 2303,
|
||||
"respiration": null,
|
||||
"locked": null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
Wellness Data:
|
||||
Date: 2025-05-24
|
||||
|
||||
Training Metrics:
|
||||
- Fitness (CTL): 70.87253
|
||||
- Fatigue (ATL): 91.97159
|
||||
- Ramp Rate: 6.997368
|
||||
- CTL Load: 299
|
||||
- ATL Load: 299
|
||||
|
||||
Sport-Specific Info:
|
||||
- Ride: eFTP = 282.85992
|
||||
|
||||
Vital Signs:
|
||||
- Weight: 78 kg
|
||||
- Resting HR: 50 bpm
|
||||
|
||||
Activity:
|
||||
- Steps: 2303
|
||||
|
||||
Status: Unlocked
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Sample data for testing Intervals.icu MCP server functions.
|
||||
|
||||
This module contains test data structures used across the test suite.
|
||||
"""
|
||||
|
||||
INTERVALS_DATA = {
|
||||
"id": "i1",
|
||||
"analyzed": True,
|
||||
"icu_intervals": [
|
||||
{
|
||||
"type": "work",
|
||||
"label": "Rep 1",
|
||||
"elapsed_time": 60,
|
||||
"moving_time": 60,
|
||||
"distance": 100,
|
||||
"average_watts": 200,
|
||||
"max_watts": 300,
|
||||
"average_watts_kg": 3.0,
|
||||
"max_watts_kg": 5.0,
|
||||
"weighted_average_watts": 220,
|
||||
"intensity": 0.8,
|
||||
"training_load": 10,
|
||||
"average_heartrate": 150,
|
||||
"max_heartrate": 160,
|
||||
"average_cadence": 90,
|
||||
"max_cadence": 100,
|
||||
"average_speed": 6,
|
||||
"max_speed": 8,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
POWER_CURVES_DATA = {
|
||||
"list": [
|
||||
{
|
||||
"id": "s0",
|
||||
"label": "This season",
|
||||
"start_date_local": "2025-09-29T00:00:00",
|
||||
"end_date_local": "2026-03-14T00:00:00",
|
||||
"days": 167,
|
||||
"weight": 75.0,
|
||||
"secs": [1, 2, 3, 4, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 3600],
|
||||
"values": [900, 850, 820, 800, 780, 650, 550, 450, 380, 320, 280, 260, 245, 210],
|
||||
"activity_id": [
|
||||
"i100", "i100", "i100", "i100", "i100",
|
||||
"i101", "i101", "i101", "i102",
|
||||
"i103", "i104", "i105", "i106", "i107",
|
||||
],
|
||||
"watts_per_kg": [
|
||||
12.0, 11.33, 10.93, 10.67, 10.4,
|
||||
8.67, 7.33, 6.0, 5.07,
|
||||
4.27, 3.73, 3.47, 3.27, 2.8,
|
||||
],
|
||||
"wkg_activity_id": [
|
||||
"i100", "i100", "i100", "i100", "i100",
|
||||
"i101", "i101", "i101", "i102",
|
||||
"i103", "i104", "i105", "i106", "i107",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "s1",
|
||||
"label": "Last season",
|
||||
"start_date_local": "2024-09-29T00:00:00",
|
||||
"end_date_local": "2025-09-28T00:00:00",
|
||||
"days": 365,
|
||||
"weight": 76.0,
|
||||
"secs": [1, 2, 3, 4, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 3600],
|
||||
"values": [870, 830, 800, 770, 750, 630, 520, 430, 360, 300, 265, 250, 235, 200],
|
||||
"activity_id": [
|
||||
"i200", "i200", "i200", "i200", "i200",
|
||||
"i201", "i201", "i201", "i202",
|
||||
"i203", "i204", "i205", "i206", "i207",
|
||||
],
|
||||
"watts_per_kg": [
|
||||
11.45, 10.92, 10.53, 10.13, 9.87,
|
||||
8.29, 6.84, 5.66, 4.74,
|
||||
3.95, 3.49, 3.29, 3.09, 2.63,
|
||||
],
|
||||
"wkg_activity_id": [
|
||||
"i200", "i200", "i200", "i200", "i200",
|
||||
"i201", "i201", "i201", "i202",
|
||||
"i203", "i204", "i205", "i206", "i207",
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Unit tests for formatting utilities in intervals_mcp_server.utils.formatting.
|
||||
|
||||
These tests verify that the formatting functions produce expected output strings for activities, workouts, wellness entries, events, and intervals.
|
||||
"""
|
||||
|
||||
import json
|
||||
from intervals_mcp_server.utils.formatting import (
|
||||
format_activity_summary,
|
||||
format_workout,
|
||||
format_wellness_entry,
|
||||
format_event_summary,
|
||||
format_event_details,
|
||||
format_intervals,
|
||||
format_power_curves,
|
||||
)
|
||||
from tests.sample_data import INTERVALS_DATA
|
||||
|
||||
|
||||
def test_format_activity_summary():
|
||||
"""
|
||||
Test that format_activity_summary returns a string containing the activity name and ID.
|
||||
"""
|
||||
data = {
|
||||
"name": "Morning Ride",
|
||||
"id": 1,
|
||||
"type": "Ride",
|
||||
"startTime": "2024-01-01T08:00:00Z",
|
||||
"distance": 1000,
|
||||
"duration": 3600,
|
||||
}
|
||||
result = format_activity_summary(data)
|
||||
assert "Activity: Morning Ride" in result
|
||||
assert "ID: 1" in result
|
||||
|
||||
|
||||
def test_format_workout():
|
||||
"""
|
||||
Test that format_workout returns a string containing the workout name and interval count.
|
||||
"""
|
||||
workout = {
|
||||
"name": "Workout1",
|
||||
"description": "desc",
|
||||
"sport": "Ride",
|
||||
"duration": 3600,
|
||||
"tss": 50,
|
||||
"intervals": [1, 2, 3],
|
||||
}
|
||||
result = format_workout(workout)
|
||||
assert "Workout: Workout1" in result
|
||||
assert "Intervals: 3" in result
|
||||
|
||||
|
||||
def test_format_wellness_entry():
|
||||
"""
|
||||
Test that format_wellness_entry returns a string containing the date and fitness (CTL).
|
||||
"""
|
||||
with open("tests/ressources/wellness_entry.json", "r", encoding="utf-8") as f:
|
||||
entry = json.load(f)
|
||||
result = format_wellness_entry(entry)
|
||||
|
||||
with open("tests/ressources/wellness_entry_formatted.txt", "r", encoding="utf-8") as f:
|
||||
expected_result = f.read()
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_format_wellness_entry_include_all_fields():
|
||||
"""
|
||||
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
|
||||
"""
|
||||
entry = {
|
||||
"id": "2024-06-01",
|
||||
"ctl": 80,
|
||||
"weight": 75,
|
||||
"customField1": "hello",
|
||||
"customField2": 42,
|
||||
"updated": "2024-06-01T10:00:00Z",
|
||||
}
|
||||
result = format_wellness_entry(entry, include_all_fields=True)
|
||||
assert "Date: 2024-06-01" in result
|
||||
assert "Fitness (CTL): 80" in result
|
||||
assert "Weight: 75 kg" in result
|
||||
assert "Other Fields:" in result
|
||||
assert "customField1: hello" in result
|
||||
assert "customField2: 42" in result
|
||||
# "updated" is a known built-in field, should not appear in Other Fields
|
||||
assert "updated:" not in result
|
||||
|
||||
|
||||
def test_format_wellness_entry_no_extra_fields_by_default():
|
||||
"""
|
||||
Test that format_wellness_entry without include_all_fields does not include additional fields.
|
||||
"""
|
||||
entry = {
|
||||
"id": "2024-06-01",
|
||||
"ctl": 80,
|
||||
"customField1": "hello",
|
||||
}
|
||||
result = format_wellness_entry(entry)
|
||||
assert "Other Fields:" not in result
|
||||
assert "customField1" not in result
|
||||
|
||||
|
||||
def test_format_wellness_entry_macros_populated():
|
||||
"""
|
||||
Test that format_wellness_entry renders native nutrition macros
|
||||
(carbohydrates, protein, fatTotal) in grams when present.
|
||||
"""
|
||||
entry = {
|
||||
"id": "2026-04-08",
|
||||
"carbohydrates": 310,
|
||||
"protein": 145,
|
||||
"fatTotal": 72,
|
||||
}
|
||||
result = format_wellness_entry(entry)
|
||||
assert "Nutrition & Hydration:" in result
|
||||
assert "- Carbohydrates: 310 g" in result
|
||||
assert "- Protein: 145 g" in result
|
||||
assert "- Fat: 72 g" in result
|
||||
|
||||
|
||||
def test_format_wellness_entry_macros_null_hidden():
|
||||
"""
|
||||
Test that format_wellness_entry hides macro lines when the fields are null,
|
||||
preserving backward compatibility with older wellness records.
|
||||
"""
|
||||
entry = {
|
||||
"id": "2026-04-08",
|
||||
"ctl": 80,
|
||||
"carbohydrates": None,
|
||||
"protein": None,
|
||||
"fatTotal": None,
|
||||
}
|
||||
result = format_wellness_entry(entry)
|
||||
assert "Carbohydrates" not in result
|
||||
assert "Protein" not in result
|
||||
# "Fat" could legitimately appear inside e.g. "Body Fat" elsewhere, so
|
||||
# anchor the negative assertion on the line-prefix form we would emit.
|
||||
assert "- Fat:" not in result
|
||||
|
||||
|
||||
def test_format_event_summary():
|
||||
"""
|
||||
Test that format_event_summary returns a string containing the event date and type.
|
||||
"""
|
||||
event = {
|
||||
"start_date_local": "2024-01-01",
|
||||
"id": "e1",
|
||||
"name": "Event1",
|
||||
"description": "desc",
|
||||
"race": True,
|
||||
}
|
||||
summary = format_event_summary(event)
|
||||
assert "Date: 2024-01-01" in summary
|
||||
assert "Type: Race" in summary
|
||||
|
||||
|
||||
def test_format_event_details():
|
||||
"""
|
||||
Test that format_event_details returns a string containing event and workout details.
|
||||
"""
|
||||
event = {
|
||||
"id": "e1",
|
||||
"date": "2024-01-01",
|
||||
"name": "Event1",
|
||||
"description": "desc",
|
||||
"workout": {
|
||||
"id": "w1",
|
||||
"sport": "Ride",
|
||||
"duration": 3600,
|
||||
"tss": 50,
|
||||
"intervals": [1, 2],
|
||||
},
|
||||
"race": True,
|
||||
"priority": "A",
|
||||
"result": "1st",
|
||||
"calendar": {"name": "Main"},
|
||||
}
|
||||
details = format_event_details(event)
|
||||
assert "Event Details:" in details
|
||||
assert "Workout Information:" in details
|
||||
|
||||
|
||||
def test_format_intervals():
|
||||
"""
|
||||
Test that format_intervals returns a string containing interval analysis and the interval label.
|
||||
"""
|
||||
result = format_intervals(INTERVALS_DATA)
|
||||
assert "Intervals Analysis:" in result
|
||||
assert "Rep 1" in result
|
||||
|
||||
|
||||
def test_format_power_curves():
|
||||
"""
|
||||
Test that format_power_curves returns a concise string with curve labels,
|
||||
power values, W/kg values, and activity IDs.
|
||||
"""
|
||||
curves = [
|
||||
{
|
||||
"id": "s0",
|
||||
"label": "This season",
|
||||
"start": "2025-09-29T00:00:00",
|
||||
"end": "2026-03-14T00:00:00",
|
||||
"data_points": [
|
||||
{"secs": 5, "watts": 780, "activity_id": "i100", "watts_per_kg": 10.4, "wkg_activity_id": "i100"},
|
||||
{"secs": 60, "watts": 380, "activity_id": "i102", "watts_per_kg": 5.07, "wkg_activity_id": "i102"},
|
||||
{"secs": 3600, "watts": 210, "activity_id": "i107", "watts_per_kg": 2.8, "wkg_activity_id": "i107"},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = format_power_curves(curves, "Ride", include_normalised=True)
|
||||
assert "Power Curves (Ride):" in result
|
||||
assert "This season" in result
|
||||
assert "5s: 780W" in result
|
||||
assert "10.40W/kg" in result
|
||||
assert "1m: 380W" in result
|
||||
assert "1h: 210W" in result
|
||||
assert "i100" in result
|
||||
assert "i107" in result
|
||||
|
||||
|
||||
def test_format_power_curves_without_normalised():
|
||||
"""
|
||||
Test that format_power_curves without normalised data does not include W/kg.
|
||||
"""
|
||||
curves = [
|
||||
{
|
||||
"id": "s0",
|
||||
"label": "This season",
|
||||
"start": "",
|
||||
"end": "",
|
||||
"data_points": [
|
||||
{"secs": 5, "watts": 780, "activity_id": "i100"},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = format_power_curves(curves, "Ride", include_normalised=False)
|
||||
assert "780W" in result
|
||||
assert "W/kg" not in result
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Unit tests for the make_intervals_request function in intervals_mcp_server.server.
|
||||
|
||||
These tests focus on error handling, particularly the scenario where the API returns invalid JSON.
|
||||
Mock classes are used to simulate httpx responses and client behavior.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
from json import JSONDecodeError
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
|
||||
os.environ.setdefault("API_KEY", "test")
|
||||
os.environ.setdefault("ATHLETE_ID", "i1")
|
||||
|
||||
from intervals_mcp_server import server # pylint: disable=wrong-import-position
|
||||
from intervals_mcp_server.api import client as api_client # pylint: disable=wrong-import-position
|
||||
from intervals_mcp_server.config import Config # pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
class MockBadJSONResponse:
|
||||
"""
|
||||
Simulates an httpx response object that returns invalid JSON content.
|
||||
Used to test error handling for JSONDecodeError in make_intervals_request.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.content = b"bad"
|
||||
self.status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
"""Mock raise_for_status that does nothing."""
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
"""Raise JSONDecodeError to simulate invalid JSON."""
|
||||
raise JSONDecodeError("Expecting value", "bad", 0)
|
||||
|
||||
|
||||
class MockAsyncClient:
|
||||
"""
|
||||
Simulates an httpx.AsyncClient for use in monkeypatching.
|
||||
Always returns a MockBadJSONResponse from get().
|
||||
"""
|
||||
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
# Accept any arguments to match httpx.AsyncClient's interface
|
||||
self.is_closed = False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
pass
|
||||
|
||||
async def get(self, _url, **_kwargs):
|
||||
"""Mock get method that returns MockBadJSONResponse."""
|
||||
return MockBadJSONResponse()
|
||||
|
||||
async def request(self, *_args, **_kwargs):
|
||||
"""Mock request method that returns MockBadJSONResponse."""
|
||||
return MockBadJSONResponse()
|
||||
|
||||
async def aclose(self):
|
||||
"""Simulate closing the AsyncClient."""
|
||||
self.is_closed = True
|
||||
|
||||
|
||||
def test_make_intervals_request_bad_json(monkeypatch, caplog):
|
||||
"""
|
||||
Test that make_intervals_request returns an error dict when the response contains invalid JSON.
|
||||
Ensures proper logging and error message content.
|
||||
"""
|
||||
monkeypatch.setenv("API_KEY", "test")
|
||||
monkeypatch.setenv("ATHLETE_ID", "i1")
|
||||
# Reset the singleton so config picks up the monkeypatched env vars
|
||||
monkeypatch.setattr("intervals_mcp_server.config._config_instance", None)
|
||||
monkeypatch.setattr(server, "httpx_client", MockAsyncClient())
|
||||
monkeypatch.setattr(
|
||||
api_client,
|
||||
"get_config",
|
||||
lambda: Config(
|
||||
api_key="test",
|
||||
athlete_id="i1",
|
||||
intervals_api_base_url="https://intervals.icu/api/v1",
|
||||
user_agent="test-agent",
|
||||
),
|
||||
)
|
||||
|
||||
# Ensure the config singleton has an API key, regardless of test execution order
|
||||
from intervals_mcp_server.config import get_config # pylint: disable=import-outside-toplevel
|
||||
monkeypatch.setattr(get_config(), "api_key", "test")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
result = asyncio.run(server.make_intervals_request("/bad"))
|
||||
|
||||
assert result["error"] is True
|
||||
assert "Invalid JSON in response" in result["message"]
|
||||
@@ -0,0 +1,951 @@
|
||||
"""
|
||||
Unit tests for the main MCP server tool functions in intervals_mcp_server.server.
|
||||
|
||||
These tests use monkeypatching to mock API responses and verify the formatting and output of each tool function:
|
||||
- get_activities
|
||||
- get_activity_details
|
||||
- get_activity_intervals
|
||||
- get_activity_streams
|
||||
- get_activity_messages
|
||||
- add_activity_message
|
||||
- get_events
|
||||
- get_event_by_id
|
||||
- add_or_update_event
|
||||
- get_wellness_data
|
||||
|
||||
The tests ensure that the server's public API returns expected strings and handles data correctly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
|
||||
os.environ.setdefault("API_KEY", "test")
|
||||
os.environ.setdefault("ATHLETE_ID", "i1")
|
||||
|
||||
from intervals_mcp_server.server import ( # pylint: disable=wrong-import-position
|
||||
add_activity_message,
|
||||
get_activities,
|
||||
get_activity_details,
|
||||
get_activity_intervals,
|
||||
get_activity_messages,
|
||||
get_activity_streams,
|
||||
add_or_update_event,
|
||||
get_athlete_power_curves,
|
||||
get_event_by_id,
|
||||
get_events,
|
||||
get_gear_list,
|
||||
get_wellness_data,
|
||||
get_custom_items,
|
||||
get_custom_item_by_id,
|
||||
create_custom_item,
|
||||
update_custom_item,
|
||||
delete_custom_item,
|
||||
)
|
||||
from intervals_mcp_server.tools import gear as gear_module # pylint: disable=wrong-import-position
|
||||
from tests.sample_data import INTERVALS_DATA, POWER_CURVES_DATA # pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
def _reset_gear_cache():
|
||||
"""Helper to clear the module-level gear cache between tests."""
|
||||
gear_module._GEAR_RAW_CACHE.clear() # pylint: disable=protected-access
|
||||
|
||||
|
||||
def test_get_activities(monkeypatch):
|
||||
"""
|
||||
Test get_activities returns a formatted string containing activity details when given a sample activity.
|
||||
"""
|
||||
sample = {
|
||||
"name": "Morning Ride",
|
||||
"id": 123,
|
||||
"type": "Ride",
|
||||
"startTime": "2024-01-01T08:00:00Z",
|
||||
"distance": 1000,
|
||||
"duration": 3600,
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return [sample]
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_activities(athlete_id="1", limit=1, include_unnamed=True))
|
||||
assert "Morning Ride" in result
|
||||
assert "Activities:" in result
|
||||
|
||||
|
||||
def test_get_activity_details(monkeypatch):
|
||||
"""
|
||||
Test get_activity_details returns a formatted string with the activity name and details.
|
||||
"""
|
||||
sample = {
|
||||
"name": "Morning Ride",
|
||||
"id": 123,
|
||||
"type": "Ride",
|
||||
"startTime": "2024-01-01T08:00:00Z",
|
||||
"distance": 1000,
|
||||
"duration": 3600,
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return sample
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_activity_details(123))
|
||||
assert "Activity: Morning Ride" in result
|
||||
|
||||
|
||||
def test_get_events(monkeypatch):
|
||||
"""
|
||||
Test get_events returns a formatted string containing event details when given a sample event.
|
||||
"""
|
||||
event = {
|
||||
"date": "2024-01-01",
|
||||
"id": "e1",
|
||||
"name": "Test Event",
|
||||
"description": "desc",
|
||||
"race": True,
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return [event]
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request)
|
||||
result = asyncio.run(get_events(athlete_id="1", start_date="2024-01-01", end_date="2024-01-02"))
|
||||
assert "Test Event" in result
|
||||
assert "Events:" in result
|
||||
|
||||
|
||||
def test_get_event_by_id(monkeypatch):
|
||||
"""
|
||||
Test get_event_by_id returns a formatted string with event details for a given event ID.
|
||||
"""
|
||||
event = {
|
||||
"id": "e1",
|
||||
"date": "2024-01-01",
|
||||
"name": "Test Event",
|
||||
"description": "desc",
|
||||
"race": True,
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return event
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request)
|
||||
result = asyncio.run(get_event_by_id("e1", athlete_id="1"))
|
||||
assert "Event Details:" in result
|
||||
assert "Test Event" in result
|
||||
|
||||
|
||||
def test_get_wellness_data(monkeypatch):
|
||||
"""
|
||||
Test get_wellness_data returns a formatted string containing wellness data for a given athlete.
|
||||
"""
|
||||
wellness = {
|
||||
"2024-01-01": {
|
||||
"id": "2024-01-01",
|
||||
"ctl": 75,
|
||||
"sleepSecs": 28800,
|
||||
}
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return wellness
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
|
||||
result = asyncio.run(get_wellness_data(athlete_id="1"))
|
||||
assert "Wellness Data:" in result
|
||||
assert "2024-01-01" in result
|
||||
|
||||
|
||||
def test_get_wellness_data_renders_macros(monkeypatch):
|
||||
"""
|
||||
Integration test: native nutrition macros (carbohydrates, protein,
|
||||
fatTotal) flow from the API response through get_wellness_data into the
|
||||
formatted output.
|
||||
"""
|
||||
wellness = [
|
||||
{
|
||||
"id": "2026-04-08",
|
||||
"carbohydrates": 310,
|
||||
"protein": 145,
|
||||
"fatTotal": 72,
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return wellness
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
|
||||
result = asyncio.run(get_wellness_data(athlete_id="1"))
|
||||
assert "Wellness Data:" in result
|
||||
assert "2026-04-08" in result
|
||||
assert "Nutrition & Hydration:" in result
|
||||
assert "- Carbohydrates: 310 g" in result
|
||||
assert "- Protein: 145 g" in result
|
||||
assert "- Fat: 72 g" in result
|
||||
|
||||
|
||||
def test_get_wellness_data_include_all_fields(monkeypatch):
|
||||
"""
|
||||
Test get_wellness_data with include_all_fields=True returns a formatted string including additional fields.
|
||||
"""
|
||||
wellness = [
|
||||
{
|
||||
"id": "2024-01-01",
|
||||
"ctl": 75,
|
||||
"sleepSecs": 28800,
|
||||
"customField": "custom_value",
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return wellness
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request)
|
||||
result = asyncio.run(get_wellness_data(athlete_id="1", include_all_fields=True))
|
||||
assert "Wellness Data:" in result
|
||||
assert "2024-01-01" in result
|
||||
assert "Fitness (CTL): 75" in result
|
||||
assert "Other Fields:" in result
|
||||
assert "customField: custom_value" in result
|
||||
|
||||
|
||||
def test_get_activity_intervals(monkeypatch):
|
||||
"""
|
||||
Test get_activity_intervals returns a formatted string with interval analysis for a given activity.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return INTERVALS_DATA
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_activity_intervals("123"))
|
||||
assert "Intervals Analysis:" in result
|
||||
assert "Rep 1" in result
|
||||
|
||||
|
||||
def test_get_activity_streams(monkeypatch):
|
||||
"""
|
||||
Test get_activity_streams returns a formatted string with stream data for a given activity.
|
||||
"""
|
||||
sample_streams = [
|
||||
{
|
||||
"type": "time",
|
||||
"name": "time",
|
||||
"data": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
"data2": [],
|
||||
"valueType": "time_units",
|
||||
"valueTypeIsArray": False,
|
||||
"anomalies": None,
|
||||
"custom": False,
|
||||
},
|
||||
{
|
||||
"type": "watts",
|
||||
"name": "watts",
|
||||
"data": [150, 155, 160, 165, 170, 175, 180, 185, 190, 195, 200],
|
||||
"data2": [],
|
||||
"valueType": "power_units",
|
||||
"valueTypeIsArray": False,
|
||||
"anomalies": None,
|
||||
"custom": False,
|
||||
},
|
||||
{
|
||||
"type": "heartrate",
|
||||
"name": "heartrate",
|
||||
"data": [120, 125, 130, 135, 140, 145, 150, 155, 160, 165, 170],
|
||||
"data2": [],
|
||||
"valueType": "hr_units",
|
||||
"valueTypeIsArray": False,
|
||||
"anomalies": None,
|
||||
"custom": False,
|
||||
},
|
||||
]
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return sample_streams
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_activity_streams("i107537962"))
|
||||
assert "Activity Streams" in result
|
||||
assert "time" in result
|
||||
assert "watts" in result
|
||||
assert "heartrate" in result
|
||||
assert "Data Points: 11" in result
|
||||
|
||||
|
||||
def test_add_or_update_event(monkeypatch):
|
||||
"""
|
||||
Test add_or_update_event successfully posts an event and returns the response data.
|
||||
"""
|
||||
expected_response = {
|
||||
"id": "e123",
|
||||
"start_date_local": "2024-01-15T00:00:00",
|
||||
"category": "WORKOUT",
|
||||
"name": "Test Workout",
|
||||
"type": "Ride",
|
||||
}
|
||||
|
||||
async def fake_post_request(*_args, **_kwargs):
|
||||
return expected_response
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_post_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.events.make_intervals_request", fake_post_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
add_or_update_event(
|
||||
athlete_id="i1", start_date="2024-01-15", name="Test Workout", workout_type="Ride"
|
||||
)
|
||||
)
|
||||
assert "Successfully created event id:" in result
|
||||
assert "e123" in result
|
||||
|
||||
|
||||
def test_get_activity_messages(monkeypatch):
|
||||
"""Test get_activity_messages returns formatted messages for an activity."""
|
||||
sample_messages = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Niko",
|
||||
"created": "2024-06-15T10:30:00Z",
|
||||
"type": "NOTE",
|
||||
"content": "Legs felt heavy today",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Coach",
|
||||
"created": "2024-06-15T11:00:00Z",
|
||||
"type": "TEXT",
|
||||
"content": "Good effort despite that!",
|
||||
},
|
||||
]
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return sample_messages
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_activity_messages(activity_id="i123"))
|
||||
assert "Legs felt heavy today" in result
|
||||
assert "Good effort despite that!" in result
|
||||
assert "Niko" in result
|
||||
assert "Coach" in result
|
||||
|
||||
|
||||
def test_get_activity_messages_error(monkeypatch):
|
||||
"""Test get_activity_messages handles API errors gracefully."""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return {"error": True, "message": "Activity not found"}
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_activity_messages(activity_id="i999"))
|
||||
assert "Error fetching activity messages" in result
|
||||
assert "Activity not found" in result
|
||||
|
||||
|
||||
def test_get_activity_messages_empty(monkeypatch):
|
||||
"""Test get_activity_messages returns appropriate message when no messages exist."""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_activity_messages(activity_id="i123"))
|
||||
assert "No messages found" in result
|
||||
|
||||
|
||||
def test_add_activity_message(monkeypatch):
|
||||
"""Test add_activity_message posts a message and returns confirmation."""
|
||||
|
||||
async def fake_request(*_args, **kwargs):
|
||||
assert kwargs.get("method") == "POST"
|
||||
assert kwargs.get("data") == {"content": "Great run!"}
|
||||
return {"id": 42, "new_chat": None}
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(add_activity_message(activity_id="i123", content="Great run!"))
|
||||
assert "Successfully added message" in result
|
||||
assert "42" in result
|
||||
|
||||
|
||||
def test_add_activity_message_missing_id(monkeypatch):
|
||||
"""Test add_activity_message warns when response has no ID."""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return {"new_chat": None}
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(add_activity_message(activity_id="i123", content="Hello"))
|
||||
assert "appears to have been added" in result
|
||||
assert "verify manually" in result
|
||||
|
||||
|
||||
def test_add_activity_message_unexpected_response(monkeypatch):
|
||||
"""Test add_activity_message handles unexpected non-dict response."""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(add_activity_message(activity_id="i123", content="Hello"))
|
||||
assert "Unexpected response" in result
|
||||
|
||||
|
||||
def test_add_activity_message_error(monkeypatch):
|
||||
"""Test add_activity_message handles API errors."""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return {"error": True, "message": "Not found"}
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(add_activity_message(activity_id="i999", content="Hello"))
|
||||
assert "Error adding message" in result
|
||||
|
||||
|
||||
def test_get_athlete_power_curves(monkeypatch):
|
||||
"""
|
||||
Test get_athlete_power_curves returns formatted power curve data with both seasons.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return POWER_CURVES_DATA
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
get_athlete_power_curves(
|
||||
activity_type="Ride",
|
||||
athlete_id="i1",
|
||||
)
|
||||
)
|
||||
assert "Power Curves (Ride):" in result
|
||||
assert "This season" in result
|
||||
assert "Last season" in result
|
||||
assert "5s:" in result
|
||||
assert "W/kg" in result
|
||||
assert "i100" in result
|
||||
|
||||
|
||||
def test_get_athlete_power_curves_custom_durations(monkeypatch):
|
||||
"""
|
||||
Test get_athlete_power_curves with custom durations returns only those durations.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return POWER_CURVES_DATA
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
get_athlete_power_curves(
|
||||
activity_type="Ride",
|
||||
durations=[5, 60],
|
||||
athlete_id="i1",
|
||||
)
|
||||
)
|
||||
assert "5s:" in result
|
||||
assert "1m:" in result
|
||||
# Should not contain durations we didn't request
|
||||
assert "15s:" not in result
|
||||
assert "10m:" not in result
|
||||
|
||||
|
||||
def test_get_athlete_power_curves_without_normalised(monkeypatch):
|
||||
"""
|
||||
Test get_athlete_power_curves without normalised data excludes W/kg values.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return POWER_CURVES_DATA
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
get_athlete_power_curves(
|
||||
activity_type="Ride",
|
||||
include_normalised=False,
|
||||
athlete_id="i1",
|
||||
)
|
||||
)
|
||||
assert "W/kg" not in result
|
||||
assert "780W" in result
|
||||
|
||||
|
||||
def test_get_athlete_power_curves_date_validation(monkeypatch):
|
||||
"""
|
||||
Test get_athlete_power_curves validates date parameters.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return POWER_CURVES_DATA
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
|
||||
)
|
||||
# Only start_date without end_date should fail
|
||||
result = asyncio.run(
|
||||
get_athlete_power_curves(
|
||||
activity_type="Ride",
|
||||
start_date="2026-01-01",
|
||||
athlete_id="i1",
|
||||
)
|
||||
)
|
||||
assert "Error" in result
|
||||
assert "start_date and end_date must be provided together" in result
|
||||
|
||||
|
||||
def test_get_athlete_power_curves_no_curves_selected(monkeypatch):
|
||||
"""
|
||||
Test get_athlete_power_curves returns error when no curves selected.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return POWER_CURVES_DATA
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
get_athlete_power_curves(
|
||||
activity_type="Ride",
|
||||
this_season=False,
|
||||
last_season=False,
|
||||
athlete_id="i1",
|
||||
)
|
||||
)
|
||||
assert "Error" in result
|
||||
assert "At least one curve must be selected" in result
|
||||
|
||||
|
||||
def test_get_custom_items(monkeypatch):
|
||||
"""
|
||||
Test get_custom_items returns a formatted string containing custom item details.
|
||||
"""
|
||||
custom_items = [
|
||||
{"id": 1, "name": "HR Zones", "type": "ZONES", "description": "Heart rate zones"},
|
||||
{"id": 2, "name": "Power Chart", "type": "FITNESS_CHART", "description": None},
|
||||
]
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return custom_items
|
||||
|
||||
# Patch in both api.client and tools modules to ensure it works
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_custom_items(athlete_id="1"))
|
||||
assert "Custom Items:" in result
|
||||
assert "HR Zones" in result
|
||||
assert "ZONES" in result
|
||||
assert "Power Chart" in result
|
||||
|
||||
|
||||
def test_get_custom_item_by_id(monkeypatch):
|
||||
"""
|
||||
Test get_custom_item_by_id returns formatted details of a single custom item.
|
||||
"""
|
||||
custom_item = {
|
||||
"id": 1,
|
||||
"name": "HR Zones",
|
||||
"type": "ZONES",
|
||||
"description": "Heart rate zones",
|
||||
"visibility": "PRIVATE",
|
||||
"index": 0,
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return custom_item
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(get_custom_item_by_id(item_id=1, athlete_id="1"))
|
||||
assert "Custom Item Details:" in result
|
||||
assert "HR Zones" in result
|
||||
assert "ZONES" in result
|
||||
assert "Heart rate zones" in result
|
||||
assert "PRIVATE" in result
|
||||
|
||||
|
||||
def test_create_custom_item(monkeypatch):
|
||||
"""
|
||||
Test create_custom_item returns a success message with formatted item details.
|
||||
"""
|
||||
created_item = {
|
||||
"id": 10,
|
||||
"name": "New Chart",
|
||||
"type": "FITNESS_CHART",
|
||||
"description": "A new fitness chart",
|
||||
"visibility": "PRIVATE",
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return created_item
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
create_custom_item(name="New Chart", item_type="FITNESS_CHART", athlete_id="1")
|
||||
)
|
||||
assert "Successfully created custom item:" in result
|
||||
assert "New Chart" in result
|
||||
assert "FITNESS_CHART" in result
|
||||
|
||||
|
||||
def test_create_custom_item_with_string_content(monkeypatch):
|
||||
"""
|
||||
Test create_custom_item correctly parses content when passed as a JSON string.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_request(*_args, **kwargs):
|
||||
captured["data"] = kwargs.get("data")
|
||||
return {
|
||||
"id": 11,
|
||||
"name": "Activity Field",
|
||||
"type": "ACTIVITY_FIELD",
|
||||
"content": {"expression": "icu_training_load"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
create_custom_item(
|
||||
name="Activity Field",
|
||||
item_type="ACTIVITY_FIELD",
|
||||
athlete_id="1",
|
||||
content='{"expression": "icu_training_load"}', # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
assert "Successfully created custom item:" in result
|
||||
# Verify the content was parsed from string to dict before being sent
|
||||
assert isinstance(captured["data"]["content"], dict)
|
||||
assert captured["data"]["content"]["expression"] == "icu_training_load"
|
||||
|
||||
|
||||
def test_update_custom_item(monkeypatch):
|
||||
"""
|
||||
Test update_custom_item returns a success message with formatted item details.
|
||||
"""
|
||||
updated_item = {
|
||||
"id": 1,
|
||||
"name": "Updated Chart",
|
||||
"type": "FITNESS_CHART",
|
||||
"description": "Updated description",
|
||||
"visibility": "PUBLIC",
|
||||
}
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return updated_item
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
update_custom_item(item_id=1, name="Updated Chart", athlete_id="1")
|
||||
)
|
||||
assert "Successfully updated custom item:" in result
|
||||
assert "Updated Chart" in result
|
||||
assert "PUBLIC" in result
|
||||
|
||||
|
||||
def test_delete_custom_item(monkeypatch):
|
||||
"""
|
||||
Test delete_custom_item returns the API response.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(delete_custom_item(item_id=1, athlete_id="1"))
|
||||
assert "Successfully deleted" in result
|
||||
|
||||
|
||||
def test_create_custom_item_with_invalid_json_content(monkeypatch):
|
||||
"""
|
||||
Test create_custom_item returns an error message when content is an invalid JSON string.
|
||||
"""
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request
|
||||
)
|
||||
result = asyncio.run(
|
||||
create_custom_item(
|
||||
name="Bad Item",
|
||||
item_type="FITNESS_CHART",
|
||||
athlete_id="1",
|
||||
content="not valid json", # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
assert "Error: content must be valid JSON when passed as a string." in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gear tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_gear_list(monkeypatch):
|
||||
"""
|
||||
Test get_gear_list returns a formatted catalog with id, type, name and stats.
|
||||
"""
|
||||
_reset_gear_cache()
|
||||
|
||||
sample_gear = [
|
||||
{
|
||||
"id": "b1",
|
||||
"type": "Bike",
|
||||
"name": "Litening Air",
|
||||
"default_for_type": "Ride",
|
||||
"activities": 100,
|
||||
"distance": 4_155_700,
|
||||
"retired": False,
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"type": "Bike",
|
||||
"name": "Retired bike",
|
||||
"activities": 50,
|
||||
"distance": 2_000_000,
|
||||
"retired": True,
|
||||
},
|
||||
]
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return sample_gear
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
|
||||
)
|
||||
|
||||
result = asyncio.run(get_gear_list(athlete_id="i1"))
|
||||
|
||||
assert "Gear catalog for athlete i1:" in result
|
||||
assert "Litening Air" in result
|
||||
assert "b1" in result
|
||||
assert "Retired bike" in result
|
||||
assert "yes" in result # retired flag rendered
|
||||
assert "Ride" in result # default_for_type rendered
|
||||
|
||||
|
||||
def test_get_gear_list_empty(monkeypatch):
|
||||
"""
|
||||
Test get_gear_list returns an informative message when no gear is configured.
|
||||
"""
|
||||
_reset_gear_cache()
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
|
||||
)
|
||||
|
||||
result = asyncio.run(get_gear_list(athlete_id="i1"))
|
||||
assert "No gear found" in result
|
||||
|
||||
|
||||
def test_get_gear_list_cache_and_refresh(monkeypatch):
|
||||
"""
|
||||
Test that get_gear_list caches the catalog and that refresh=True busts the cache.
|
||||
"""
|
||||
_reset_gear_cache()
|
||||
|
||||
call_count = {"n": 0}
|
||||
sample_gear = [
|
||||
{
|
||||
"id": "b1",
|
||||
"type": "Bike",
|
||||
"name": "Litening Air",
|
||||
"activities": 100,
|
||||
"distance": 4_155_700,
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
call_count["n"] += 1
|
||||
return sample_gear
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
|
||||
)
|
||||
|
||||
# First call: cache cold, one API hit expected.
|
||||
asyncio.run(get_gear_list(athlete_id="i1"))
|
||||
assert call_count["n"] == 1
|
||||
|
||||
# Second call: cache warm, no additional API hit.
|
||||
asyncio.run(get_gear_list(athlete_id="i1"))
|
||||
assert call_count["n"] == 1
|
||||
|
||||
# refresh=True busts the cache and triggers a fresh fetch.
|
||||
asyncio.run(get_gear_list(athlete_id="i1", refresh=True))
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
def test_get_activity_details_resolves_gear_name(monkeypatch):
|
||||
"""
|
||||
Test get_activity_details injects the resolved gear name into the formatted output
|
||||
when the activity payload contains a gear_id.
|
||||
"""
|
||||
_reset_gear_cache()
|
||||
|
||||
activity = {
|
||||
"name": "Morning Ride",
|
||||
"id": 123,
|
||||
"type": "Ride",
|
||||
"startTime": "2024-01-01T08:00:00Z",
|
||||
"distance": 1000,
|
||||
"duration": 3600,
|
||||
"gear_id": "b1",
|
||||
}
|
||||
gear_catalog = [{"id": "b1", "type": "Bike", "name": "Litening Air"}]
|
||||
|
||||
async def fake_request(url=None, **_kwargs):
|
||||
# The activity endpoint and the gear endpoint share the same fake
|
||||
# request; route by URL pattern.
|
||||
if url and "/gear" in url:
|
||||
return gear_catalog
|
||||
return activity
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
|
||||
)
|
||||
# get_activity_details does not accept athlete_id; gear resolution falls
|
||||
# back to the configured ATHLETE_ID, which is unset under CI. Provide one.
|
||||
monkeypatch.setattr(gear_module.config, "athlete_id", "1")
|
||||
|
||||
result = asyncio.run(get_activity_details(123))
|
||||
assert "Activity: Morning Ride" in result
|
||||
assert "Gear:" in result
|
||||
assert "Name: Litening Air" in result
|
||||
assert "ID: b1" in result
|
||||
|
||||
|
||||
def test_get_activities_resolves_gear_name(monkeypatch):
|
||||
"""
|
||||
Test get_activities injects resolved gear names for each activity in the list.
|
||||
"""
|
||||
_reset_gear_cache()
|
||||
|
||||
activities = [
|
||||
{
|
||||
"name": "Ride 1",
|
||||
"id": 1,
|
||||
"type": "Ride",
|
||||
"startTime": "2024-01-01T08:00:00Z",
|
||||
"distance": 1000,
|
||||
"duration": 3600,
|
||||
"gear_id": "b1",
|
||||
},
|
||||
{
|
||||
"name": "Ride 2",
|
||||
"id": 2,
|
||||
"type": "Ride",
|
||||
"startTime": "2024-01-02T08:00:00Z",
|
||||
"distance": 2000,
|
||||
"duration": 5400,
|
||||
"gear_id": "b2",
|
||||
},
|
||||
]
|
||||
gear_catalog = [
|
||||
{"id": "b1", "type": "Bike", "name": "Litening Air"},
|
||||
{"id": "b2", "type": "Bike", "name": "S-Works Tarmac SL8"},
|
||||
]
|
||||
|
||||
async def fake_request(url=None, **_kwargs):
|
||||
if url and "/gear" in url:
|
||||
return gear_catalog
|
||||
return activities
|
||||
|
||||
monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.activities.make_intervals_request", fake_request
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"intervals_mcp_server.tools.gear.make_intervals_request", fake_request
|
||||
)
|
||||
|
||||
result = asyncio.run(get_activities(athlete_id="1", limit=2, include_unnamed=True))
|
||||
assert "Ride 1" in result
|
||||
assert "Ride 2" in result
|
||||
assert "Name: Litening Air" in result
|
||||
assert "Name: S-Works Tarmac SL8" in result
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Unit tests for resolve_activity_type in intervals_mcp_server.utils.validation.
|
||||
"""
|
||||
|
||||
from intervals_mcp_server.utils.validation import resolve_activity_type
|
||||
|
||||
|
||||
def test_explicit_activity_type_returned_as_is():
|
||||
"""Explicit activity_type is returned unchanged."""
|
||||
assert resolve_activity_type(None, "VirtualRide") == "VirtualRide"
|
||||
assert resolve_activity_type("morning swim", "Run") == "Run"
|
||||
|
||||
|
||||
def test_keyword_ride():
|
||||
"""Cycling keywords resolve to Ride."""
|
||||
for name in ["Morning Ride", "cycling session", "bike workout", "cycle"]:
|
||||
assert resolve_activity_type(name) == "Ride"
|
||||
|
||||
|
||||
def test_keyword_run():
|
||||
"""Running keywords resolve to Run."""
|
||||
for name in ["Easy Run", "jogging", "morning jog", "running"]:
|
||||
assert resolve_activity_type(name) == "Run"
|
||||
|
||||
|
||||
def test_keyword_swim():
|
||||
"""Swimming keywords resolve to Swim."""
|
||||
for name in ["Pool Swim", "swimming drills", "swim"]:
|
||||
assert resolve_activity_type(name) == "Swim"
|
||||
|
||||
|
||||
def test_keyword_walk():
|
||||
"""Walking keywords resolve to Walk."""
|
||||
for name in ["Evening Walk", "hiking trip", "hike", "walking"]:
|
||||
assert resolve_activity_type(name) == "Walk"
|
||||
|
||||
|
||||
def test_keyword_row():
|
||||
"""Rowing keywords resolve to Row."""
|
||||
for name in ["Rowing session", "morning row"]:
|
||||
assert resolve_activity_type(name) == "Row"
|
||||
|
||||
|
||||
def test_default_ride_when_no_match():
|
||||
"""Defaults to Ride when no keyword matches."""
|
||||
assert resolve_activity_type("stretching") == "Ride"
|
||||
assert resolve_activity_type(None) == "Ride"
|
||||
assert resolve_activity_type("") == "Ride"
|
||||
|
||||
|
||||
def test_case_insensitive():
|
||||
"""Keyword matching is case-insensitive."""
|
||||
assert resolve_activity_type("MORNING RUN") == "Run"
|
||||
assert resolve_activity_type("SWIM") == "Swim"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Unit tests for the Value dataclass in intervals_mcp_server.utils.types.
|
||||
|
||||
These tests verify that the Value dataclass correctly handles:
|
||||
- String formatting for percent FTP units
|
||||
- Ramp intervals (start/end values)
|
||||
- Deserialisation of pace/swim-pace unit strings returned by the Intervals.icu API
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from intervals_mcp_server.utils.types import Value, ValueUnits
|
||||
|
||||
|
||||
def test_str_percent_ftp():
|
||||
"""Test formatting percentage FTP values."""
|
||||
val = Value(value=95.0, units=ValueUnits.PERCENT_FTP)
|
||||
assert str(val) == "95% ftp"
|
||||
|
||||
|
||||
def test_str_ramp_percent_ftp():
|
||||
"""Test formatting ramp intervals with percentage FTP."""
|
||||
val = Value(start=65, end=85, units=ValueUnits.PERCENT_FTP)
|
||||
assert str(val) == "65%-85% ftp"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit_str,expected_enum", [
|
||||
("MINS_KM", ValueUnits.MINS_KM),
|
||||
("MINS_MILE", ValueUnits.MINS_MILE),
|
||||
("SECS_100M", ValueUnits.SECS_100M),
|
||||
("SECS_500M", ValueUnits.SECS_500M),
|
||||
])
|
||||
def test_pace_units_deserialise_from_api_string(unit_str, expected_enum):
|
||||
"""Pace/swim-pace unit strings returned by the Intervals.icu API must round-trip
|
||||
through Value.from_dict without raising ValueError. This test would fail if any
|
||||
of these unit strings were missing from the ValueUnits enum."""
|
||||
val = Value.from_dict({"value": 5.0, "units": unit_str})
|
||||
assert val.units == expected_enum
|
||||
Reference in New Issue
Block a user