f2159d3ca5
Add an update_wellness MCP tool that writes nutrition macros, hydration, vitals,
sleep, and subjective ratings to Intervals.icu via PUT /athlete/{id}/wellness/{date}.
Only provided fields are sent; pass -1 to clear a numeric field and locked=True to
stop device/app syncs from overwriting the values. Sleep is taken in hours and
stored as seconds (with -1 passing through as the clear sentinel).
Also surface computed Form (TSB = CTL - ATL) in wellness output, and prefer an
explicit `date` field over the record `id` for the Date label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""
|
|
Tests for intervals_mcp_server.tools.wellness.
|
|
|
|
Covers the wellness write tool (``update_wellness``): field mapping to the
|
|
Intervals.icu camelCase schema, the sleep hours->seconds conversion and the
|
|
``-1`` clear sentinel, request shape (PUT + path), the empty-payload guard, and
|
|
the error / credential branches. The 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 wellness
|
|
|
|
|
|
def _patch_request(monkeypatch, result):
|
|
"""Patch make_intervals_request; capture the call kwargs, return ``result``."""
|
|
calls: list[dict] = []
|
|
|
|
async def fake(**kwargs):
|
|
calls.append(kwargs)
|
|
return result
|
|
|
|
monkeypatch.setattr(wellness, "make_intervals_request", fake)
|
|
return calls
|
|
|
|
|
|
def test_update_wellness_maps_fields_and_puts(monkeypatch):
|
|
calls = _patch_request(monkeypatch, {"id": "2025-05-24", "weight": 78})
|
|
out = asyncio.run(
|
|
wellness.update_wellness(
|
|
date="2025-05-24",
|
|
weight=78,
|
|
resting_hr=50,
|
|
hrv=65.5,
|
|
sleep_hours=8,
|
|
calories_consumed=2200,
|
|
carbohydrates=300,
|
|
protein=140,
|
|
fat=70,
|
|
hydration_volume=2.5,
|
|
comments="felt good",
|
|
)
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
call = calls[0]
|
|
assert call["method"] == "PUT"
|
|
assert call["url"] == "/athlete/i1/wellness/2025-05-24"
|
|
assert call["api_key"] == "testkey"
|
|
|
|
payload = call["data"]
|
|
assert payload["weight"] == 78
|
|
assert payload["restingHR"] == 50
|
|
assert payload["hrv"] == 65.5
|
|
assert payload["sleepSecs"] == 8 * 3600 # hours -> seconds
|
|
assert payload["kcalConsumed"] == 2200
|
|
assert payload["carbohydrates"] == 300
|
|
assert payload["protein"] == 140
|
|
assert payload["fatTotal"] == 70
|
|
assert payload["hydrationVolume"] == 2.5
|
|
assert payload["comments"] == "felt good"
|
|
# Omitted fields must not be sent.
|
|
assert "mood" not in payload
|
|
assert "locked" not in payload
|
|
|
|
assert "Updated wellness for 2025-05-24" in out
|
|
|
|
|
|
def test_update_wellness_defaults_date_to_today(monkeypatch):
|
|
calls = _patch_request(monkeypatch, {"id": "today"})
|
|
asyncio.run(wellness.update_wellness(weight=80))
|
|
# URL date segment defaults to today's date (YYYY-MM-DD, 10 chars).
|
|
date_seg = calls[0]["url"].rsplit("/", 1)[1]
|
|
assert len(date_seg) == 10 and date_seg.count("-") == 2
|
|
|
|
|
|
def test_update_wellness_no_fields_returns_message(monkeypatch):
|
|
calls = _patch_request(monkeypatch, {})
|
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24"))
|
|
assert "No wellness fields provided" in out
|
|
assert calls == [] # no request made
|
|
|
|
|
|
def test_update_wellness_clear_with_negative_one(monkeypatch):
|
|
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
|
|
asyncio.run(wellness.update_wellness(date="2025-05-24", weight=-1, sleep_hours=-1))
|
|
payload = calls[0]["data"]
|
|
assert payload["weight"] == -1
|
|
# -1 is the clear sentinel and must NOT be scaled to -3600.
|
|
assert payload["sleepSecs"] == -1
|
|
|
|
|
|
def test_update_wellness_locked_false_is_sent(monkeypatch):
|
|
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
|
|
asyncio.run(wellness.update_wellness(date="2025-05-24", locked=False))
|
|
assert calls[0]["data"]["locked"] is False
|
|
|
|
|
|
def test_update_wellness_error_path(monkeypatch):
|
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
|
assert "Error updating wellness data: boom" in out
|
|
|
|
|
|
def test_update_wellness_invalid_date(monkeypatch):
|
|
calls = _patch_request(monkeypatch, {})
|
|
out = asyncio.run(wellness.update_wellness(date="not-a-date", weight=80))
|
|
assert out.startswith("Error:")
|
|
assert calls == []
|
|
|
|
|
|
def test_update_wellness_credential_error(monkeypatch):
|
|
async def _deny():
|
|
raise CredentialError("not approved")
|
|
|
|
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
|
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
|
assert "not approved" in out
|
|
|
|
|
|
def test_update_wellness_non_dict_result(monkeypatch):
|
|
# If the API returns a list (unexpected), we still confirm the write.
|
|
_patch_request(monkeypatch, [])
|
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
|
assert out == "Updated wellness for 2025-05-24."
|