Files
intervalsicu-mcp/tests/test_athlete.py
T
Chris Farhood 28119f1761 feat(athlete): add profile, sport-settings, and summary read tools
Closes the biggest coaching-context gap: expose the athlete's identity/physiology
(get_athlete_profile), per-sport FTP/zones/thresholds (get_sport_settings, with an
optional sport filter and the settings id needed for future writes), and a
training-load summary over a range (get_athlete_summary). New tools/athlete.py plus
formatters in utils/formatting.py; registered in server.py and tools/__init__.py.

Implements #2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 15:44:05 -04:00

189 lines
6.4 KiB
Python

"""
Tests for intervals_mcp_server.tools.athlete.
Covers the athlete-context read tools (get_athlete_profile, get_sport_settings,
get_athlete_summary): request shape, sport filtering, formatting of realistic
fixtures, and the empty / error / credential branches. Default caller credentials
come from the autouse fixture in conftest (athlete ``i1``).
"""
import asyncio
from intervals_mcp_server import credentials
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools import athlete
PROFILE = {
"id": "i1",
"name": "Test Athlete",
"sex": "M",
"weight": 72.5,
"icu_resting_hr": 48,
"timezone": "Europe/Madrid",
"measurement_preference": "meters",
"city": "Girona",
"country": "Spain",
"icu_coach": True,
"icu_type_settings": [{"id": 1}, {"id": 2}],
}
SPORT_SETTINGS = [
{
"id": 100,
"types": ["Ride", "VirtualRide"],
"ftp": 280,
"indoor_ftp": 275,
"w_prime": 22000,
"power_zones": [55, 75, 90, 105, 120],
"power_zone_names": ["Z1", "Z2", "Z3", "Z4", "Z5"],
"lthr": 165,
"max_hr": 190,
"hr_zones": [120, 145, 160, 175],
"threshold_pace": 4.2,
"pace_units": "MINS_KM",
"pace_zones": [3.5, 4.0, 4.5],
"warmup_time": 600,
"cooldown_time": 300,
},
{"id": 101, "types": ["Run"], "threshold_pace": 3.8, "pace_units": "MINS_KM"},
]
SUMMARY = [
{
"date": "2026-07-20",
"count": 12,
"moving_time": 43200,
"distance": 320000,
"training_load": 640,
"fitness": 78.5,
"fatigue": 71.0,
"form": 7.5,
"eftp": 285,
"byCategory": [{"category": "Ride", "count": 8, "training_load": 500, "moving_time": 32400}],
}
]
def _patch_request(monkeypatch, result):
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return result
monkeypatch.setattr(athlete, "make_intervals_request", fake)
return calls
# --------------------------------------------------------------------------- #
# get_athlete_profile
# --------------------------------------------------------------------------- #
def test_get_athlete_profile_success(monkeypatch):
calls = _patch_request(monkeypatch, PROFILE)
out = asyncio.run(athlete.get_athlete_profile())
assert calls[0]["url"] == "/athlete/i1"
assert "Name: Test Athlete" in out
assert "Weight: 72.5 kg" in out
assert "Resting HR: 48 bpm" in out
assert "Location: Girona, Spain" in out
assert "Role: Coach" in out
assert "2 sport(s) configured" in out
def test_get_athlete_profile_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "nope"})
assert "Error fetching athlete profile: nope" in asyncio.run(athlete.get_athlete_profile())
def test_get_athlete_profile_non_dict(monkeypatch):
_patch_request(monkeypatch, [])
assert "No athlete profile found" in asyncio.run(athlete.get_athlete_profile())
def test_get_athlete_profile_credential_error(monkeypatch):
async def _deny():
raise CredentialError("not approved")
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
assert "not approved" in asyncio.run(athlete.get_athlete_profile())
# --------------------------------------------------------------------------- #
# get_sport_settings
# --------------------------------------------------------------------------- #
def test_get_sport_settings_all(monkeypatch):
calls = _patch_request(monkeypatch, SPORT_SETTINGS)
out = asyncio.run(athlete.get_sport_settings())
assert calls[0]["url"] == "/athlete/i1/sport-settings"
assert "Sport Settings — Ride, VirtualRide" in out
assert "Settings ID: 100" in out
assert "FTP: 280W" in out
assert "Z1: 55, Z2: 75" in out # power zones paired with names
assert "LTHR: 165 bpm" in out
assert "Threshold: 4.2 MINS_KM" in out
assert "Settings ID: 101" in out # second record rendered too
def test_get_sport_settings_filter_hit(monkeypatch):
_patch_request(monkeypatch, SPORT_SETTINGS)
out = asyncio.run(athlete.get_sport_settings(sport="run")) # case-insensitive
assert "Settings ID: 101" in out
assert "Settings ID: 100" not in out
def test_get_sport_settings_filter_miss(monkeypatch):
_patch_request(monkeypatch, SPORT_SETTINGS)
out = asyncio.run(athlete.get_sport_settings(sport="Swim"))
assert "No sport settings found for sport 'Swim'" in out
def test_get_sport_settings_empty(monkeypatch):
_patch_request(monkeypatch, [])
assert "No sport settings found" in asyncio.run(athlete.get_sport_settings())
def test_get_sport_settings_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "boom"})
assert "Error fetching sport settings: boom" in asyncio.run(athlete.get_sport_settings())
# --------------------------------------------------------------------------- #
# get_athlete_summary
# --------------------------------------------------------------------------- #
def test_get_athlete_summary_success(monkeypatch):
calls = _patch_request(monkeypatch, SUMMARY)
out = asyncio.run(athlete.get_athlete_summary(start_date="2026-06-20", end_date="2026-07-20"))
call = calls[0]
assert call["url"] == "/athlete/i1/athlete-summary"
assert call["params"]["start"] == "2026-06-20"
assert call["params"]["end"] == "2026-07-20"
assert "tags" not in call["params"]
assert "Fitness (CTL): 78.5" in out
assert "Form (TSB): 7.5" in out
assert "By category:" in out
assert "Ride: 8 activities" in out
def test_get_athlete_summary_tags_split(monkeypatch):
calls = _patch_request(monkeypatch, SUMMARY)
asyncio.run(athlete.get_athlete_summary(tags="race, key-workout"))
assert calls[0]["params"]["tags"] == ["race", "key-workout"]
def test_get_athlete_summary_defaults_dates(monkeypatch):
calls = _patch_request(monkeypatch, SUMMARY)
asyncio.run(athlete.get_athlete_summary())
# resolve_date_params fills both ends with YYYY-MM-DD
assert len(calls[0]["params"]["start"]) == 10
assert len(calls[0]["params"]["end"]) == 10
def test_get_athlete_summary_empty(monkeypatch):
_patch_request(monkeypatch, [])
assert "No summary data found" in asyncio.run(athlete.get_athlete_summary())
def test_get_athlete_summary_error(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "bad"})
assert "Error fetching athlete summary: bad" in asyncio.run(athlete.get_athlete_summary())