9a66183d39
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
310 lines
11 KiB
Python
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
|