e5b9606bb8
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
236 lines
8.6 KiB
Python
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
|