From 9a66183d39dd0869de85b8055f40a1223d7c3850 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 20 Jul 2026 15:54:10 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN --- src/intervals_mcp_server/api/client.py | 2 +- src/intervals_mcp_server/server.py | 4 + src/intervals_mcp_server/tools/__init__.py | 4 + src/intervals_mcp_server/tools/athlete.py | 121 +++++++++++++++++ src/intervals_mcp_server/tools/wellness.py | 151 +++++++++++++++++---- tests/test_athlete.py | 121 +++++++++++++++++ tests/test_wellness.py | 85 ++++++++++++ 7 files changed, 458 insertions(+), 30 deletions(-) diff --git a/src/intervals_mcp_server/api/client.py b/src/intervals_mcp_server/api/client.py index 685d973..94334f2 100644 --- a/src/intervals_mcp_server/api/client.py +++ b/src/intervals_mcp_server/api/client.py @@ -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. diff --git a/src/intervals_mcp_server/server.py b/src/intervals_mcp_server/server.py index 7e3d9fb..21c3b92 100644 --- a/src/intervals_mcp_server/server.py +++ b/src/intervals_mcp_server/server.py @@ -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", diff --git a/src/intervals_mcp_server/tools/__init__.py b/src/intervals_mcp_server/tools/__init__.py index a010898..a01d0d7 100644 --- a/src/intervals_mcp_server/tools/__init__.py +++ b/src/intervals_mcp_server/tools/__init__.py @@ -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", ] diff --git a/src/intervals_mcp_server/tools/athlete.py b/src/intervals_mcp_server/tools/athlete.py index 96d51fc..fd20480 100644 --- a/src/intervals_mcp_server/tools/athlete.py +++ b/src/intervals_mcp_server/tools/athlete.py @@ -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) diff --git a/src/intervals_mcp_server/tools/wellness.py b/src/intervals_mcp_server/tools/wellness.py index bd2199f..e27c02b 100644 --- a/src/intervals_mcp_server/tools/wellness.py +++ b/src/intervals_mcp_server/tools/wellness.py @@ -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) diff --git a/tests/test_athlete.py b/tests/test_athlete.py index 5faa53f..132f141 100644 --- a/tests/test_athlete.py +++ b/tests/test_athlete.py @@ -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 diff --git a/tests/test_wellness.py b/tests/test_wellness.py index 398fb06..47abdbd 100644 --- a/tests/test_wellness.py +++ b/tests/test_wellness.py @@ -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