Files
intervalsicu-mcp/tests/test_wellness.py
T
Chris Farhood e5b9606bb8 fix(wellness): reject unrecognized bulk fields instead of silently dropping
update_wellness_bulk kept only recognized snake_case keys and discarded the rest
(e.g. API-style camelCase like restingHR) while reporting success — silent data
loss across up to 92 days. Entries with unknown fields now reject the whole
batch with a message naming the bad and valid field names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:27:36 -04:00

236 lines
8.6 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."
def test_update_wellness_echo_without_date_shows_written_date(monkeypatch):
# If the API echo omits id/date, the confirmation body must not read "Date: N/A".
_patch_request(monkeypatch, {"weight": 80})
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_rejects_unknown_keys(monkeypatch):
# camelCase/API-style names must be rejected, not silently dropped: the value
# the caller asked to record would otherwise be lost behind a success message.
calls = _patch_request(monkeypatch, [{}])
out = asyncio.run(
wellness.update_wellness_bulk(
[{"date": "2026-07-18", "weight": 80, "restingHR": 50}]
)
)
assert "unrecognized field(s): restingHR" in out
assert "resting_hr" in out # the error names the valid fields
assert calls == [] # whole batch rejected, nothing written
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