f067f9639a
All 20 tools now drop the athlete_id/api_key parameters and instead resolve the authenticated caller's stored, enabled credentials via credentials.resolve_caller_credentials() (get_access_token().subject -> store). Security: there is no tool parameter a caller can pass to supply a key, so a disabled/unapproved user cannot bypass the admin-approval gate — each tool returns a helpful "not approved / set up your credentials" message instead. Gear resolution now uses the caller's athlete id rather than an env var. Tests: conftest autouse fixture runs tool tests as an enabled user; a parametrized test asserts every tool refuses when unauthorized; existing tool tests updated (no more athlete_id/api_key kwargs). 221 passing at 91.5%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
4.9 KiB
Python
136 lines
4.9 KiB
Python
"""
|
|
Tests for intervals_mcp_server.tools.custom_items.
|
|
|
|
Focuses on the write-path logic: which fields end up in the POST/PUT payload,
|
|
JSON-string content parsing (and the invalid-JSON guard), method/URL selection,
|
|
and the error/empty branches.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from intervals_mcp_server.tools import custom_items
|
|
|
|
|
|
class _Recorder:
|
|
def __init__(self, handler):
|
|
self.calls: list[dict] = []
|
|
self._handler = handler
|
|
|
|
async def __call__(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
return self._handler(kwargs)
|
|
|
|
|
|
def _patch(monkeypatch, handler):
|
|
rec = _Recorder(handler)
|
|
monkeypatch.setattr(custom_items, "make_intervals_request", rec)
|
|
return rec
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# read
|
|
# --------------------------------------------------------------------------- #
|
|
def test_get_custom_items_lists(monkeypatch):
|
|
_patch(monkeypatch, lambda _k: [{"id": 1, "name": "Zones", "type": "ZONES", "description": "d"}])
|
|
out = _run(custom_items.get_custom_items())
|
|
assert "Custom Items:" in out
|
|
assert "ID: 1" in out and "Name: Zones" in out and "Type: ZONES" in out
|
|
|
|
|
|
def test_get_custom_items_error(monkeypatch):
|
|
_patch(monkeypatch, lambda _k: {"error": True, "message": "boom"})
|
|
assert "Error fetching custom items: boom" in _run(custom_items.get_custom_items())
|
|
|
|
|
|
def test_get_custom_item_by_id_not_found(monkeypatch):
|
|
_patch(monkeypatch, lambda _k: []) # falsy / not a dict
|
|
assert "No custom item found with ID 7" in _run(custom_items.get_custom_item_by_id(7))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# create
|
|
# --------------------------------------------------------------------------- #
|
|
def test_create_builds_full_payload(monkeypatch):
|
|
rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"})
|
|
out = _run(
|
|
custom_items.create_custom_item(
|
|
name="Chart", item_type="FITNESS_CHART",
|
|
description="desc", content={"a": 1}, visibility="PRIVATE",
|
|
)
|
|
)
|
|
call = rec.calls[0]
|
|
assert call["method"] == "POST"
|
|
assert call["url"] == "/athlete/i1/custom-item"
|
|
assert call["data"] == {
|
|
"name": "Chart", "type": "FITNESS_CHART", "description": "desc",
|
|
"content": {"a": 1}, "visibility": "PRIVATE",
|
|
}
|
|
assert "Successfully created custom item" in out
|
|
|
|
|
|
def test_create_parses_json_string_content(monkeypatch):
|
|
rec = _patch(monkeypatch, lambda _k: {"id": 1, "name": "X", "type": "ZONES"})
|
|
_run(
|
|
custom_items.create_custom_item(
|
|
name="X", item_type="ZONES", content='{"expression": "icu_training_load"}'
|
|
)
|
|
)
|
|
assert rec.calls[0]["data"]["content"] == {"expression": "icu_training_load"} # parsed to dict
|
|
|
|
|
|
def test_create_rejects_invalid_json_string(monkeypatch):
|
|
rec = _patch(monkeypatch, lambda _k: {"id": 1})
|
|
out = _run(
|
|
custom_items.create_custom_item(name="X", item_type="ZONES", content="{not json")
|
|
)
|
|
assert "content must be valid JSON" in out
|
|
assert rec.calls == [] # bailed before any request
|
|
|
|
|
|
def test_create_error_surfaced(monkeypatch):
|
|
_patch(monkeypatch, lambda _k: {"error": True, "message": "bad type"})
|
|
out = _run(custom_items.create_custom_item(name="X", item_type="NOPE"))
|
|
assert "Error creating custom item: bad type" in out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# update
|
|
# --------------------------------------------------------------------------- #
|
|
def test_update_sends_only_provided_fields_via_put(monkeypatch):
|
|
rec = _patch(monkeypatch, lambda _k: {"id": 5, "name": "New", "type": "ZONES"})
|
|
_run(custom_items.update_custom_item(item_id=5, name="New"))
|
|
call = rec.calls[0]
|
|
assert call["method"] == "PUT"
|
|
assert call["url"] == "/athlete/i1/custom-item/5"
|
|
assert call["data"] == {"name": "New"} # only the field that was set
|
|
|
|
|
|
def test_update_invalid_json_string(monkeypatch):
|
|
rec = _patch(monkeypatch, lambda _k: {"id": 5})
|
|
out = _run(custom_items.update_custom_item(item_id=5, content="{bad"))
|
|
assert "content must be valid JSON" in out
|
|
assert rec.calls == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# delete
|
|
# --------------------------------------------------------------------------- #
|
|
def test_delete_uses_delete_method(monkeypatch):
|
|
rec = _patch(monkeypatch, lambda _k: {})
|
|
out = _run(custom_items.delete_custom_item(item_id=3))
|
|
call = rec.calls[0]
|
|
assert call["method"] == "DELETE"
|
|
assert call["url"] == "/athlete/i1/custom-item/3"
|
|
assert "Successfully deleted custom item 3" in out
|
|
|
|
|
|
def test_delete_error(monkeypatch):
|
|
_patch(monkeypatch, lambda _k: {"error": True, "message": "locked"})
|
|
assert "Error deleting custom item: locked" in _run(custom_items.delete_custom_item(item_id=3))
|