Files
intervalsicu-mcp/tests/test_athlete.py
T
Chris Farhood 9a66183d39 feat(writes): add update_wellness_bulk and update_sport_settings
update_wellness_bulk writes many days in one PUT to /wellness-bulk; the
snake_case->camelCase mapping is extracted into a shared _wellness_payload helper
so single and bulk can't drift, and the whole batch is rejected if any date is
invalid (no partial writes).

update_sport_settings changes FTP/LTHR/pace/zones with a dual guardrail: a warning
docstring, a native ctx.elicit() confirmation on capable clients, and a hard
confirm=True fallback that refuses the write (returning the old->new diff) on
clients without elicitation. It read-modify-writes the full record and passes the
spec-required recalcHrZones query param. Widened the HTTP client's data type to
accept the bulk array.

Implements #5.

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

310 lines
11 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
def _patch_seq(monkeypatch, results):
"""Patch make_intervals_request to return queued results, one per call."""
calls: list[dict] = []
seq = iter(results)
async def fake(**kwargs):
calls.append(kwargs)
return next(seq)
monkeypatch.setattr(athlete, "make_intervals_request", fake)
return calls
class _StubCtx:
"""Minimal stand-in for FastMCP Context.elicit used by the guardrail tests."""
def __init__(self, action="accept", confirm=True, raise_exc=False):
self._action = action
self._confirm = confirm
self._raise = raise_exc
self.elicit_calls = 0
async def elicit(self, message, schema): # noqa: ARG002 - signature parity
self.elicit_calls += 1
if self._raise:
raise RuntimeError("client does not support elicitation")
data = type("Data", (), {"confirm": self._confirm})()
return type("Result", (), {"action": self._action, "data": data})()
# --------------------------------------------------------------------------- #
# 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())
# --------------------------------------------------------------------------- #
# update_sport_settings (dual-guardrail write)
# --------------------------------------------------------------------------- #
CURRENT_SS = [{"id": 100, "types": ["Ride"], "ftp": 280, "lthr": 165}]
def test_update_sport_settings_no_fields(monkeypatch):
calls = _patch_seq(monkeypatch, [])
out = asyncio.run(athlete.update_sport_settings(settings_id=100))
assert "No settings provided" in out
assert calls == [] # returns before any fetch
def test_update_sport_settings_refuses_without_confirm_or_ctx(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS]) # only the GET happens
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300))
assert "⚠️ This will change your Ride thresholds" in out
assert "ftp: 280 -> 300" in out
assert "re-run with confirm=true" in out
assert len(calls) == 1 and calls[0]["url"] == "/athlete/i1/sport-settings" # no PUT
def test_update_sport_settings_confirm_true_writes(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS, {"id": 100, "types": ["Ride"], "ftp": 300}])
out = asyncio.run(
athlete.update_sport_settings(settings_id=100, ftp=300, recalc_hr_zones=True, confirm=True)
)
put = calls[1]
assert put["method"] == "PUT"
assert put["url"] == "/athlete/i1/sport-settings/100"
assert put["params"] == {"recalcHrZones": True}
assert put["data"]["ftp"] == 300 # merged into the full record
assert "Updated Ride settings" in out
def test_update_sport_settings_elicit_accept_writes(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS, {"id": 100, "types": ["Ride"], "ftp": 300}])
ctx = _StubCtx(action="accept", confirm=True)
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert ctx.elicit_calls == 1
assert len(calls) == 2 and calls[1]["method"] == "PUT"
assert "Updated Ride settings" in out
def test_update_sport_settings_elicit_decline(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="decline")
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "you declined" in out
assert len(calls) == 1 # no PUT
def test_update_sport_settings_elicit_cancel(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="cancel")
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "you declined" in out
def test_update_sport_settings_elicit_unsupported_falls_back(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(raise_exc=True) # client without elicitation capability
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "re-run with confirm=true" in out
assert len(calls) == 1 # refused, no PUT
def test_update_sport_settings_unknown_id(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS])
out = asyncio.run(athlete.update_sport_settings(settings_id=999, ftp=300, confirm=True))
assert "No sport settings found with ID 999" in out
def test_update_sport_settings_no_op(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=280, confirm=True))
assert "No changes" in out
def test_update_sport_settings_fetch_error(monkeypatch):
_patch_seq(monkeypatch, [{"error": True, "message": "down"}])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
assert "Error fetching current sport settings: down" in out
def test_update_sport_settings_put_error(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS, {"error": True, "message": "rejected"}])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
assert "Error updating sport settings: rejected" in out