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,
|
||||
params: dict[str, Any] | None = None,
|
||||
method: str = "GET",
|
||||
data: dict[str, Any] | None = None,
|
||||
data: dict[str, Any] | list[Any] | None = None,
|
||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""
|
||||
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
|
||||
get_wellness_data,
|
||||
update_wellness,
|
||||
update_wellness_bulk,
|
||||
)
|
||||
from intervals_mcp_server.tools.athlete import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||
get_athlete_profile,
|
||||
get_athlete_summary,
|
||||
get_sport_settings,
|
||||
update_sport_settings,
|
||||
)
|
||||
from intervals_mcp_server.tools.workouts import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||
get_workout,
|
||||
@@ -133,9 +135,11 @@ __all__ = [
|
||||
"add_or_update_event",
|
||||
"get_wellness_data",
|
||||
"update_wellness",
|
||||
"update_wellness_bulk",
|
||||
"get_athlete_profile",
|
||||
"get_sport_settings",
|
||||
"get_athlete_summary",
|
||||
"update_sport_settings",
|
||||
"get_workouts",
|
||||
"get_workout",
|
||||
"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
|
||||
get_wellness_data,
|
||||
update_wellness,
|
||||
update_wellness_bulk,
|
||||
)
|
||||
from intervals_mcp_server.tools.athlete import ( # noqa: F401
|
||||
get_athlete_profile,
|
||||
get_athlete_summary,
|
||||
get_sport_settings,
|
||||
update_sport_settings,
|
||||
)
|
||||
from intervals_mcp_server.tools.workouts import get_workout, get_workouts # noqa: F401
|
||||
|
||||
@@ -86,9 +88,11 @@ __all__ = [
|
||||
"get_gear_list",
|
||||
"get_wellness_data",
|
||||
"update_wellness",
|
||||
"update_wellness_bulk",
|
||||
"get_athlete_profile",
|
||||
"get_sport_settings",
|
||||
"get_athlete_summary",
|
||||
"update_sport_settings",
|
||||
"get_workouts",
|
||||
"get_workout",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,9 @@ coach needs to reason about intensity and readiness.
|
||||
|
||||
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.api.client import make_intervals_request
|
||||
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
|
||||
|
||||
|
||||
class _ConfirmThresholdChange(BaseModel):
|
||||
"""Elicitation schema: the user confirms (or not) a threshold change."""
|
||||
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_athlete_profile() -> str:
|
||||
"""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"
|
||||
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
|
||||
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()
|
||||
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:
|
||||
return f"Error: {exc}"
|
||||
|
||||
# Sleep is passed in hours but stored as seconds; -1 is the clear sentinel and
|
||||
# must pass through unscaled.
|
||||
sleep_secs: int | None = None
|
||||
if sleep_hours is not None:
|
||||
sleep_secs = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
|
||||
|
||||
# Map snake_case tool params to the Intervals.icu camelCase wellness fields.
|
||||
field_map: list[tuple[str, Any]] = [
|
||||
("weight", weight),
|
||||
("restingHR", resting_hr),
|
||||
("hrv", hrv),
|
||||
("sleepSecs", sleep_secs),
|
||||
("sleepQuality", sleep_quality),
|
||||
("kcalConsumed", calories_consumed),
|
||||
("carbohydrates", carbohydrates),
|
||||
("protein", protein),
|
||||
("fatTotal", fat),
|
||||
("hydrationVolume", hydration_volume),
|
||||
("hydration", hydration_score),
|
||||
("soreness", soreness),
|
||||
("fatigue", fatigue),
|
||||
("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}
|
||||
payload = _wellness_payload(
|
||||
{
|
||||
"weight": weight,
|
||||
"resting_hr": resting_hr,
|
||||
"hrv": hrv,
|
||||
"sleep_hours": sleep_hours,
|
||||
"sleep_quality": sleep_quality,
|
||||
"calories_consumed": calories_consumed,
|
||||
"carbohydrates": carbohydrates,
|
||||
"protein": protein,
|
||||
"fat": fat,
|
||||
"hydration_volume": hydration_volume,
|
||||
"hydration_score": hydration_score,
|
||||
"soreness": soreness,
|
||||
"fatigue": fatigue,
|
||||
"stress": stress,
|
||||
"mood": mood,
|
||||
"motivation": motivation,
|
||||
"injury": injury,
|
||||
"comments": comments,
|
||||
"locked": locked,
|
||||
}
|
||||
)
|
||||
|
||||
if not payload:
|
||||
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
|
||||
return f"Updated wellness for {date}:\n\n" + format_wellness_entry(result)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -186,3 +216,94 @@ def test_get_athlete_summary_empty(monkeypatch):
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
assert "Date: 2025-05-24" 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