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
This commit is contained in:
@@ -154,7 +154,7 @@ async def make_intervals_request(
|
|||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
data: dict[str, Any] | None = None,
|
data: dict[str, Any] | list[Any] | None = None,
|
||||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Make a request to the Intervals.icu API with proper error handling.
|
Make a request to the Intervals.icu API with proper error handling.
|
||||||
|
|||||||
@@ -92,11 +92,13 @@ from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wro
|
|||||||
from intervals_mcp_server.tools.wellness import ( # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.wellness import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
get_wellness_data,
|
get_wellness_data,
|
||||||
update_wellness,
|
update_wellness,
|
||||||
|
update_wellness_bulk,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.tools.athlete import ( # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.athlete import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
get_athlete_profile,
|
get_athlete_profile,
|
||||||
get_athlete_summary,
|
get_athlete_summary,
|
||||||
get_sport_settings,
|
get_sport_settings,
|
||||||
|
update_sport_settings,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.tools.workouts import ( # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.workouts import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
get_workout,
|
get_workout,
|
||||||
@@ -133,9 +135,11 @@ __all__ = [
|
|||||||
"add_or_update_event",
|
"add_or_update_event",
|
||||||
"get_wellness_data",
|
"get_wellness_data",
|
||||||
"update_wellness",
|
"update_wellness",
|
||||||
|
"update_wellness_bulk",
|
||||||
"get_athlete_profile",
|
"get_athlete_profile",
|
||||||
"get_sport_settings",
|
"get_sport_settings",
|
||||||
"get_athlete_summary",
|
"get_athlete_summary",
|
||||||
|
"update_sport_settings",
|
||||||
"get_workouts",
|
"get_workouts",
|
||||||
"get_workout",
|
"get_workout",
|
||||||
"get_gear_list",
|
"get_gear_list",
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
|
|||||||
from intervals_mcp_server.tools.wellness import ( # noqa: F401
|
from intervals_mcp_server.tools.wellness import ( # noqa: F401
|
||||||
get_wellness_data,
|
get_wellness_data,
|
||||||
update_wellness,
|
update_wellness,
|
||||||
|
update_wellness_bulk,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.tools.athlete import ( # noqa: F401
|
from intervals_mcp_server.tools.athlete import ( # noqa: F401
|
||||||
get_athlete_profile,
|
get_athlete_profile,
|
||||||
get_athlete_summary,
|
get_athlete_summary,
|
||||||
get_sport_settings,
|
get_sport_settings,
|
||||||
|
update_sport_settings,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.tools.workouts import get_workout, get_workouts # noqa: F401
|
from intervals_mcp_server.tools.workouts import get_workout, get_workouts # noqa: F401
|
||||||
|
|
||||||
@@ -86,9 +88,11 @@ __all__ = [
|
|||||||
"get_gear_list",
|
"get_gear_list",
|
||||||
"get_wellness_data",
|
"get_wellness_data",
|
||||||
"update_wellness",
|
"update_wellness",
|
||||||
|
"update_wellness_bulk",
|
||||||
"get_athlete_profile",
|
"get_athlete_profile",
|
||||||
"get_sport_settings",
|
"get_sport_settings",
|
||||||
"get_athlete_summary",
|
"get_athlete_summary",
|
||||||
|
"update_sport_settings",
|
||||||
"get_workouts",
|
"get_workouts",
|
||||||
"get_workout",
|
"get_workout",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ coach needs to reason about intensity and readiness.
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context # pylint: disable=import-error
|
||||||
|
from pydantic import BaseModel # pylint: disable=import-error
|
||||||
|
|
||||||
from intervals_mcp_server import credentials
|
from intervals_mcp_server import credentials
|
||||||
from intervals_mcp_server.api.client import make_intervals_request
|
from intervals_mcp_server.api.client import make_intervals_request
|
||||||
from intervals_mcp_server.credentials import CredentialError
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
@@ -22,6 +25,12 @@ from intervals_mcp_server.utils.validation import resolve_date_params
|
|||||||
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
class _ConfirmThresholdChange(BaseModel):
|
||||||
|
"""Elicitation schema: the user confirms (or not) a threshold change."""
|
||||||
|
|
||||||
|
confirm: bool = False
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_athlete_profile() -> str:
|
async def get_athlete_profile() -> str:
|
||||||
"""Get the signed-in athlete's profile from Intervals.icu.
|
"""Get the signed-in athlete's profile from Intervals.icu.
|
||||||
@@ -127,3 +136,115 @@ async def get_athlete_summary(
|
|||||||
|
|
||||||
header = f"Athlete Summary ({start_date} to {end_date}):\n\n"
|
header = f"Athlete Summary ({start_date} to {end_date}):\n\n"
|
||||||
return header + "\n\n".join(format_athlete_summary(s) for s in summaries)
|
return header + "\n\n".join(format_athlete_summary(s) for s in summaries)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_sport_settings( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-return-statements
|
||||||
|
settings_id: int,
|
||||||
|
ftp: int | None = None,
|
||||||
|
indoor_ftp: int | None = None,
|
||||||
|
w_prime: int | None = None,
|
||||||
|
lthr: int | None = None,
|
||||||
|
max_hr: int | None = None,
|
||||||
|
threshold_pace: float | None = None,
|
||||||
|
recalc_hr_zones: bool = False,
|
||||||
|
confirm: bool = False,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""⚠️ Change the athlete's training thresholds (FTP, LTHR, pace) for one sport.
|
||||||
|
|
||||||
|
These values drive ALL future load, intensity and zone calculations across
|
||||||
|
Intervals.icu. Do NOT call this speculatively — show the athlete the exact
|
||||||
|
old→new values and get their explicit approval first.
|
||||||
|
|
||||||
|
This is a confirmed write. On clients that support MCP elicitation you will be
|
||||||
|
prompted to approve the change; otherwise you MUST pass ``confirm=True`` after the
|
||||||
|
athlete has agreed. Without confirmation the tool refuses and returns the diff.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings_id: The sport-settings record ID (from get_sport_settings).
|
||||||
|
ftp: New FTP in watts.
|
||||||
|
indoor_ftp: New indoor FTP in watts.
|
||||||
|
w_prime: New W' in joules.
|
||||||
|
lthr: New lactate-threshold HR in bpm.
|
||||||
|
max_hr: New max HR in bpm.
|
||||||
|
threshold_pace: New threshold pace (in the sport's pace units).
|
||||||
|
recalc_hr_zones: If True, ask Intervals.icu to recompute HR zones from the new LTHR/max HR.
|
||||||
|
confirm: Set True to confirm the change on clients without elicitation support.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
proposed = {
|
||||||
|
"ftp": ftp,
|
||||||
|
"indoor_ftp": indoor_ftp,
|
||||||
|
"w_prime": w_prime,
|
||||||
|
"lthr": lthr,
|
||||||
|
"max_hr": max_hr,
|
||||||
|
"threshold_pace": threshold_pace,
|
||||||
|
}
|
||||||
|
if all(v is None for v in proposed.values()):
|
||||||
|
return "No settings provided. Pass at least one threshold to change."
|
||||||
|
|
||||||
|
current_list = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id}/sport-settings", api_key=api_key
|
||||||
|
)
|
||||||
|
if isinstance(current_list, dict) and "error" in current_list:
|
||||||
|
return f"Error fetching current sport settings: {current_list.get('message')}"
|
||||||
|
records = [r for r in current_list if isinstance(r, dict)] if isinstance(current_list, list) else []
|
||||||
|
current = next((r for r in records if r.get("id") == settings_id), None)
|
||||||
|
if current is None:
|
||||||
|
return (
|
||||||
|
f"No sport settings found with ID {settings_id}. "
|
||||||
|
"Use get_sport_settings to list valid IDs."
|
||||||
|
)
|
||||||
|
|
||||||
|
changed: dict[str, Any] = {}
|
||||||
|
diff_lines: list[str] = []
|
||||||
|
for key, value in proposed.items():
|
||||||
|
if value is not None and current.get(key) != value:
|
||||||
|
changed[key] = value
|
||||||
|
diff_lines.append(f" {key}: {current.get(key)} -> {value}")
|
||||||
|
if not changed:
|
||||||
|
return "No changes — the provided values already match the current settings."
|
||||||
|
|
||||||
|
sport = ", ".join(str(t) for t in (current.get("types") or [])) or f"settings {settings_id}"
|
||||||
|
diff = "\n".join(diff_lines)
|
||||||
|
|
||||||
|
approved = False
|
||||||
|
if ctx is not None:
|
||||||
|
try:
|
||||||
|
elicited = await ctx.elicit(
|
||||||
|
message=f"Update {sport} thresholds?\n{diff}", schema=_ConfirmThresholdChange
|
||||||
|
)
|
||||||
|
action = getattr(elicited, "action", None)
|
||||||
|
if action in ("decline", "cancel"):
|
||||||
|
return "Sport settings unchanged — you declined."
|
||||||
|
data = getattr(elicited, "data", None)
|
||||||
|
approved = action == "accept" and bool(getattr(data, "confirm", False))
|
||||||
|
except Exception: # noqa: BLE001 - client without elicitation capability falls through
|
||||||
|
approved = False
|
||||||
|
|
||||||
|
if not approved and not confirm:
|
||||||
|
return (
|
||||||
|
f"⚠️ This will change your {sport} thresholds:\n{diff}\n\n"
|
||||||
|
"These drive ALL future load / intensity / zone calculations. "
|
||||||
|
"If the athlete confirms, re-run with confirm=true."
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = dict(current)
|
||||||
|
updated.update(changed)
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id}/sport-settings/{settings_id}",
|
||||||
|
api_key=api_key,
|
||||||
|
method="PUT",
|
||||||
|
params={"recalcHrZones": recalc_hr_zones},
|
||||||
|
data=updated,
|
||||||
|
)
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error updating sport settings: {result.get('message')}"
|
||||||
|
|
||||||
|
body = result if isinstance(result, dict) else updated
|
||||||
|
return f"Updated {sport} settings:\n\n" + format_sport_settings(body)
|
||||||
|
|||||||
@@ -16,6 +16,47 @@ from intervals_mcp_server.utils.validation import resolve_date_params, validate_
|
|||||||
# Import mcp instance from shared module for tool registration
|
# Import mcp instance from shared module for tool registration
|
||||||
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
||||||
|
|
||||||
|
# snake_case tool param -> Intervals.icu camelCase wellness field. `sleep_hours`
|
||||||
|
# is handled separately (converted to sleepSecs). Shared by the single-day and
|
||||||
|
# bulk write tools so their field mapping can never drift.
|
||||||
|
_WELLNESS_FIELD_MAP: list[tuple[str, str]] = [
|
||||||
|
("weight", "weight"),
|
||||||
|
("resting_hr", "restingHR"),
|
||||||
|
("hrv", "hrv"),
|
||||||
|
("sleep_quality", "sleepQuality"),
|
||||||
|
("calories_consumed", "kcalConsumed"),
|
||||||
|
("carbohydrates", "carbohydrates"),
|
||||||
|
("protein", "protein"),
|
||||||
|
("fat", "fatTotal"),
|
||||||
|
("hydration_volume", "hydrationVolume"),
|
||||||
|
("hydration_score", "hydration"),
|
||||||
|
("soreness", "soreness"),
|
||||||
|
("fatigue", "fatigue"),
|
||||||
|
("stress", "stress"),
|
||||||
|
("mood", "mood"),
|
||||||
|
("motivation", "motivation"),
|
||||||
|
("injury", "injury"),
|
||||||
|
("comments", "comments"),
|
||||||
|
("locked", "locked"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _wellness_payload(fields: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map snake_case wellness fields to the Intervals.icu camelCase payload.
|
||||||
|
|
||||||
|
Only non-None values are included. ``sleep_hours`` becomes ``sleepSecs`` (with
|
||||||
|
-1 passing through unscaled as the clear sentinel).
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for snake, camel in _WELLNESS_FIELD_MAP:
|
||||||
|
value = fields.get(snake)
|
||||||
|
if value is not None:
|
||||||
|
payload[camel] = value
|
||||||
|
sleep_hours = fields.get("sleep_hours")
|
||||||
|
if sleep_hours is not None:
|
||||||
|
payload["sleepSecs"] = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_wellness_data(
|
async def get_wellness_data(
|
||||||
@@ -136,35 +177,29 @@ async def update_wellness( # pylint: disable=too-many-arguments,too-many-positi
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return f"Error: {exc}"
|
return f"Error: {exc}"
|
||||||
|
|
||||||
# Sleep is passed in hours but stored as seconds; -1 is the clear sentinel and
|
payload = _wellness_payload(
|
||||||
# must pass through unscaled.
|
{
|
||||||
sleep_secs: int | None = None
|
"weight": weight,
|
||||||
if sleep_hours is not None:
|
"resting_hr": resting_hr,
|
||||||
sleep_secs = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
|
"hrv": hrv,
|
||||||
|
"sleep_hours": sleep_hours,
|
||||||
# Map snake_case tool params to the Intervals.icu camelCase wellness fields.
|
"sleep_quality": sleep_quality,
|
||||||
field_map: list[tuple[str, Any]] = [
|
"calories_consumed": calories_consumed,
|
||||||
("weight", weight),
|
"carbohydrates": carbohydrates,
|
||||||
("restingHR", resting_hr),
|
"protein": protein,
|
||||||
("hrv", hrv),
|
"fat": fat,
|
||||||
("sleepSecs", sleep_secs),
|
"hydration_volume": hydration_volume,
|
||||||
("sleepQuality", sleep_quality),
|
"hydration_score": hydration_score,
|
||||||
("kcalConsumed", calories_consumed),
|
"soreness": soreness,
|
||||||
("carbohydrates", carbohydrates),
|
"fatigue": fatigue,
|
||||||
("protein", protein),
|
"stress": stress,
|
||||||
("fatTotal", fat),
|
"mood": mood,
|
||||||
("hydrationVolume", hydration_volume),
|
"motivation": motivation,
|
||||||
("hydration", hydration_score),
|
"injury": injury,
|
||||||
("soreness", soreness),
|
"comments": comments,
|
||||||
("fatigue", fatigue),
|
"locked": locked,
|
||||||
("stress", stress),
|
}
|
||||||
("mood", mood),
|
)
|
||||||
("motivation", motivation),
|
|
||||||
("injury", injury),
|
|
||||||
("comments", comments),
|
|
||||||
("locked", locked),
|
|
||||||
]
|
|
||||||
payload: dict[str, Any] = {k: v for k, v in field_map if v is not None}
|
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
return "No wellness fields provided. Pass at least one field to update."
|
return "No wellness fields provided. Pass at least one field to update."
|
||||||
@@ -187,3 +222,61 @@ async def update_wellness( # pylint: disable=too-many-arguments,too-many-positi
|
|||||||
result["date"] = date
|
result["date"] = date
|
||||||
return f"Updated wellness for {date}:\n\n" + format_wellness_entry(result)
|
return f"Updated wellness for {date}:\n\n" + format_wellness_entry(result)
|
||||||
return f"Updated wellness for {date}."
|
return f"Updated wellness for {date}."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_wellness_bulk(entries: list[dict[str, Any]]) -> str:
|
||||||
|
"""Create or update multiple days of wellness data in a single call.
|
||||||
|
|
||||||
|
Writes to PUT /athlete/{id}/wellness-bulk. Each entry is a dict with a ``date``
|
||||||
|
(YYYY-MM-DD) plus any of the same fields as update_wellness: weight, resting_hr,
|
||||||
|
hrv, sleep_hours, sleep_quality, calories_consumed, carbohydrates, protein, fat,
|
||||||
|
hydration_volume, hydration_score, soreness, fatigue, stress, mood, motivation,
|
||||||
|
injury, comments, locked. Pass -1 to clear a numeric field. Every date is
|
||||||
|
validated up front — if any entry is invalid the whole batch is rejected, so
|
||||||
|
there are no partial writes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entries: List of per-day wellness dicts, each with a ``date`` and one or more fields.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return "No entries provided. Pass at least one day to update."
|
||||||
|
if len(entries) > 92:
|
||||||
|
return f"Too many entries ({len(entries)}). Limit a bulk update to 92 days."
|
||||||
|
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
summaries: list[str] = []
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return f"Error: entry {i} is not an object."
|
||||||
|
raw_date = entry.get("date")
|
||||||
|
if not raw_date:
|
||||||
|
return f"Error: entry {i} is missing a 'date'."
|
||||||
|
try:
|
||||||
|
date = validate_date(str(raw_date))
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error in entry {i}: {exc}"
|
||||||
|
|
||||||
|
payload = _wellness_payload(entry)
|
||||||
|
if not payload:
|
||||||
|
return f"Error: entry {i} ({date}) has no wellness fields to update."
|
||||||
|
payload["id"] = date
|
||||||
|
records.append(payload)
|
||||||
|
summaries.append(f"{date}: {', '.join(k for k in payload if k != 'id')}")
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id_to_use}/wellness-bulk",
|
||||||
|
api_key=api_key,
|
||||||
|
method="PUT",
|
||||||
|
data=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error updating wellness data: {result.get('message')}"
|
||||||
|
|
||||||
|
return f"Updated {len(records)} day(s):\n" + "\n".join(summaries)
|
||||||
|
|||||||
@@ -75,6 +75,36 @@ def _patch_request(monkeypatch, result):
|
|||||||
return calls
|
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
|
# get_athlete_profile
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -186,3 +216,94 @@ def test_get_athlete_summary_empty(monkeypatch):
|
|||||||
def test_get_athlete_summary_error(monkeypatch):
|
def test_get_athlete_summary_error(monkeypatch):
|
||||||
_patch_request(monkeypatch, {"error": True, "message": "bad"})
|
_patch_request(monkeypatch, {"error": True, "message": "bad"})
|
||||||
assert "Error fetching athlete summary: bad" in asyncio.run(athlete.get_athlete_summary())
|
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
|
||||||
|
|||||||
@@ -134,3 +134,88 @@ def test_update_wellness_echo_without_date_shows_written_date(monkeypatch):
|
|||||||
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||||
assert "Date: 2025-05-24" in out
|
assert "Date: 2025-05-24" in out
|
||||||
assert "Date: N/A" not in out
|
assert "Date: N/A" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# update_wellness_bulk
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_update_wellness_bulk_success(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{"id": "2026-07-18"}, {"id": "2026-07-19"}])
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness_bulk(
|
||||||
|
[
|
||||||
|
{"date": "2026-07-18", "weight": 80, "carbohydrates": 300},
|
||||||
|
{"date": "2026-07-19", "sleep_hours": 8},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
call = calls[0]
|
||||||
|
assert call["method"] == "PUT"
|
||||||
|
assert call["url"] == "/athlete/i1/wellness-bulk"
|
||||||
|
body = call["data"]
|
||||||
|
assert isinstance(body, list) and len(body) == 2
|
||||||
|
assert body[0]["id"] == "2026-07-18"
|
||||||
|
assert body[0]["weight"] == 80
|
||||||
|
assert body[0]["carbohydrates"] == 300 # camelCase mapping shared with update_wellness
|
||||||
|
assert body[1]["sleepSecs"] == 8 * 3600
|
||||||
|
assert "Updated 2 day(s)" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_mapping_matches_single(monkeypatch):
|
||||||
|
# The bulk payload for a day must equal the single-day payload for the same fields
|
||||||
|
# (plus the id) — proving the shared _wellness_payload helper prevents drift.
|
||||||
|
fields = {"weight": 78, "resting_hr": 50, "sleep_hours": 7.5, "fat": 60, "locked": True}
|
||||||
|
from intervals_mcp_server.tools.wellness import _wellness_payload
|
||||||
|
|
||||||
|
single = _wellness_payload(fields)
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18", **fields}]))
|
||||||
|
bulk_entry = {k: v for k, v in calls[0]["data"][0].items() if k != "id"}
|
||||||
|
assert bulk_entry == single
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_invalid_date_rejects_whole_batch(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness_bulk(
|
||||||
|
[{"date": "2026-07-18", "weight": 80}, {"date": "not-a-date", "weight": 81}]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "Error in entry 1" in out
|
||||||
|
assert calls == [] # no partial write
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_missing_date(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([{"weight": 80}]))
|
||||||
|
assert "entry 0 is missing a 'date'" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_entry_no_fields(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18"}]))
|
||||||
|
assert "has no wellness fields" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_empty(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([]))
|
||||||
|
assert "No entries provided" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_too_many(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness_bulk([{"date": "2026-01-01", "weight": 80}] * 93)
|
||||||
|
)
|
||||||
|
assert "Too many entries" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18", "weight": 80}]))
|
||||||
|
assert "Error updating wellness data: boom" in out
|
||||||
|
|||||||
Reference in New Issue
Block a user