test: raise coverage 64% -> 90% with behavior-focused tests + enforced gate
build-image / test (push) Failing after 53s
build-image / build (push) Has been skipped

New suites assert real behavior, not just that code runs:
- test_types: workout serialization round-trips (recursive steps, camelCase
  keys, enum conversion) + __str__ formatting.
- test_api_client: request construction (URL/method/auth/body) and the full
  HTTP status-code -> message mapping.
- test_auth: RS256 JWT verification — valid -> AccessToken; expired/wrong-aud/
  wrong-issuer/wrong-key/missing-claim -> None; audience slash variants.
- test_server_setup: transport selection + start_server dispatch.
- test_events / test_activities / test_custom_items: request payloads
  (create vs update, POST/PUT/DELETE), delete accounting, JSON-content parsing,
  and error/empty branches.

Enforce >=90 via pytest --cov-fail-under=90; CI test job now gates the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 18:39:20 -04:00
parent 1ec2a76d1e
commit 43bbbb6bbb
11 changed files with 1346 additions and 2 deletions
+184
View File
@@ -0,0 +1,184 @@
"""
Tests for intervals_mcp_server.tools.activities.
Covers the result-parsing helpers, the named-activity filtering + fetch-more
top-up logic, request params, and the per-tool error/empty/format branches.
Gear resolution is stubbed so these isolate the activity logic.
"""
import asyncio
import pytest
from intervals_mcp_server.tools import activities
from intervals_mcp_server.tools.activities import (
_filter_named_activities,
_format_activities_response,
_parse_activities_from_result,
)
async def _noop(*_args, **_kwargs):
return None
@pytest.fixture(autouse=True)
def _stub_gear(monkeypatch):
monkeypatch.setattr(activities, "resolve_gear_for_activities", _noop)
monkeypatch.setattr(activities, "resolve_gear_for_activity", _noop)
def _patch_request(monkeypatch, handler):
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return handler(len(calls), kwargs)
monkeypatch.setattr(activities, "make_intervals_request", fake)
return calls
# --------------------------------------------------------------------------- #
# Pure helpers
# --------------------------------------------------------------------------- #
def test_parse_from_list_keeps_only_dicts():
assert _parse_activities_from_result([{"a": 1}, "junk", {"b": 2}]) == [{"a": 1}, {"b": 2}]
def test_parse_from_container_dict():
result = {"activities": [{"name": "Ride"}], "meta": 1}
assert _parse_activities_from_result(result) == [{"name": "Ride"}]
def test_parse_single_activity_dict():
single = {"name": "Ride", "distance": 1000}
assert _parse_activities_from_result(single) == [single]
def test_parse_unrecognized_dict_returns_empty():
assert _parse_activities_from_result({"foo": "bar"}) == []
def test_filter_named_drops_unnamed_and_blank():
acts = [{"name": "Ride"}, {"name": "Unnamed"}, {"name": ""}, {"id": 1}]
assert _filter_named_activities(acts) == [{"name": "Ride"}]
def test_format_response_empty_named_hint():
out = _format_activities_response([], "i1", include_unnamed=False)
assert "include_unnamed=True" in out
# --------------------------------------------------------------------------- #
# get_activities
# --------------------------------------------------------------------------- #
def test_get_activities_error(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: {"error": True, "message": "rate limited"})
out = asyncio.run(activities.get_activities(athlete_id="i1"))
assert "Error fetching activities: rate limited" in out
def test_get_activities_requests_triple_limit_and_filters(monkeypatch):
def handler(n, _k):
if n == 1:
return [{"name": "Ride A", "id": 1, "distance": 1000}]
return [{"name": "Ride B", "id": 2, "distance": 2000}, {"name": "Unnamed", "id": 3}]
calls = _patch_request(monkeypatch, handler)
out = asyncio.run(
activities.get_activities(athlete_id="i1", start_date="2026-06-01", end_date="2026-06-30", limit=5)
)
assert calls[0]["params"]["limit"] == 15 # limit * 3 when filtering unnamed
assert len(calls) == 2 # topped up because named < limit
assert "Ride A" in out and "Ride B" in out
assert "Unnamed" not in out
def test_get_activities_include_unnamed_no_topup(monkeypatch):
calls = _patch_request(monkeypatch, lambda _n, _k: [{"name": "Unnamed", "id": 1, "distance": 5}])
out = asyncio.run(activities.get_activities(athlete_id="i1", include_unnamed=True, limit=10))
assert calls[0]["params"]["limit"] == 10 # no *3
assert len(calls) == 1 # no fetch-more
assert "Activities:" in out
# --------------------------------------------------------------------------- #
# get_activity_details
# --------------------------------------------------------------------------- #
def test_activity_details_renders_zones(monkeypatch):
_patch_request(
monkeypatch,
lambda _n, _k: {
"name": "Ride",
"id": 1,
"distance": 1000,
"zones": {
"power": [{"number": 1, "secondsInZone": 100}],
"hr": [{"number": 2, "secondsInZone": 200}],
},
},
)
out = asyncio.run(activities.get_activity_details("1"))
assert "Power Zones:" in out and "Zone 1: 100 seconds" in out
assert "Heart Rate Zones:" in out and "Zone 2: 200 seconds" in out
def test_activity_details_error(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: {"error": True, "message": "nope"})
assert "Error fetching activity details: nope" in asyncio.run(activities.get_activity_details("1"))
# --------------------------------------------------------------------------- #
# get_activity_intervals
# --------------------------------------------------------------------------- #
def test_activity_intervals_unrecognized_format(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: {"something": "else"})
out = asyncio.run(activities.get_activity_intervals("1"))
assert "unrecognized format" in out
# --------------------------------------------------------------------------- #
# get_activity_streams
# --------------------------------------------------------------------------- #
def test_streams_default_types_and_small_preview(monkeypatch):
calls = _patch_request(
monkeypatch,
lambda _n, _k: [{"type": "watts", "name": "Power", "data": [1, 2, 3], "valueType": "int"}],
)
out = asyncio.run(activities.get_activity_streams("1"))
assert "time,watts,heartrate" in calls[0]["params"]["types"] # default stream types
assert "Data Points: 3" in out
assert "Values: [1, 2, 3]" in out
def test_streams_large_preview_first_last_five(monkeypatch):
_patch_request(
monkeypatch,
lambda _n, _k: [{"type": "watts", "data": list(range(20)), "valueType": "int"}],
)
out = asyncio.run(activities.get_activity_streams("1", stream_types="watts"))
assert "First 5 values: [0, 1, 2, 3, 4]" in out
assert "Last 5 values: [15, 16, 17, 18, 19]" in out
def test_streams_empty(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: [])
assert "No stream data found" in asyncio.run(activities.get_activity_streams("1"))
# --------------------------------------------------------------------------- #
# messages
# --------------------------------------------------------------------------- #
def test_get_activity_messages_empty(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: [])
assert "No messages found" in asyncio.run(activities.get_activity_messages("1"))
def test_add_activity_message_posts_content(monkeypatch):
calls = _patch_request(monkeypatch, lambda _n, _k: {"id": 55})
out = asyncio.run(activities.add_activity_message("1", "great ride"))
call = calls[0]
assert call["method"] == "POST"
assert call["data"] == {"content": "great ride"}
assert "Successfully added message (ID: 55)" in out
+204
View File
@@ -0,0 +1,204 @@
"""
Tests for intervals_mcp_server.api.client.make_intervals_request and helpers.
This is the single function every API call flows through, so these cover the
parts that actually matter: request construction (URL/method/auth/body),
the HTTP status-code -> friendly-message mapping, and the failure branches
(missing key, request errors, invalid JSON).
"""
import asyncio
from http import HTTPStatus
import httpx
import pytest
from intervals_mcp_server.api import client as api_client
from intervals_mcp_server.config import Config
@pytest.fixture(autouse=True)
def _stub_config(monkeypatch):
"""Give the client a deterministic config with a real API key."""
monkeypatch.setattr(
api_client,
"get_config",
lambda: Config(
api_key="secret",
athlete_id="i1",
intervals_api_base_url="https://intervals.icu/api/v1",
user_agent="test-agent",
),
)
class _Resp:
def __init__(self, *, json_data=None, content=b"{}", raise_exc=None, text=""):
self._json = json_data if json_data is not None else {}
self.content = content
self._raise = raise_exc
self.text = text
def json(self):
return self._json
def raise_for_status(self):
if self._raise is not None:
raise self._raise
class _Client:
"""Records request kwargs and returns a canned response (or raises)."""
def __init__(self, response=None, exc=None):
self.is_closed = False
self._response = response
self._exc = exc
self.calls: list[dict] = []
async def request(self, **kwargs):
self.calls.append(kwargs)
if self._exc is not None:
raise self._exc
return self._response
async def aclose(self):
self.is_closed = True
def _inject(monkeypatch, client):
"""Route make_intervals_request through our fake client."""
import intervals_mcp_server.server as server # noqa: PLC0415
monkeypatch.setattr(server, "httpx_client", client, raising=False)
def _run(url, **kwargs):
return asyncio.run(api_client.make_intervals_request(url, **kwargs))
# --------------------------------------------------------------------------- #
# Request construction
# --------------------------------------------------------------------------- #
def test_get_request_builds_url_auth_and_returns_json(monkeypatch):
client = _Client(response=_Resp(json_data={"ok": 1}, content=b'{"ok":1}'))
_inject(monkeypatch, client)
result = _run("/athlete/i1/activities", params={"limit": 5})
assert result == {"ok": 1}
call = client.calls[0]
assert call["method"] == "GET"
assert call["url"] == "https://intervals.icu/api/v1/athlete/i1/activities"
assert call["params"] == {"limit": 5}
assert isinstance(call["auth"], httpx.BasicAuth) # HTTP Basic with the API key
def test_list_response_passthrough(monkeypatch):
client = _Client(response=_Resp(json_data=[{"a": 1}], content=b"[]"))
_inject(monkeypatch, client)
assert _run("/x") == [{"a": 1}]
def test_post_sends_json_body_and_content_type(monkeypatch):
client = _Client(response=_Resp(json_data={"created": True}, content=b"{}"))
_inject(monkeypatch, client)
_run("/athlete/i1/events", method="POST", data={"name": "Threshold"})
call = client.calls[0]
assert call["method"] == "POST"
# POST body is serialized JSON, not form params
assert call["content"] == '{"name": "Threshold"}'
assert call["headers"]["Content-Type"] == "application/json"
def test_empty_response_body_returns_empty_dict(monkeypatch):
client = _Client(response=_Resp(content=b"")) # no content -> {}
_inject(monkeypatch, client)
assert _run("/x") == {}
# --------------------------------------------------------------------------- #
# Failure branches
# --------------------------------------------------------------------------- #
def test_missing_api_key_short_circuits(monkeypatch):
monkeypatch.setattr(
api_client,
"get_config",
lambda: Config(api_key="", athlete_id="i1", intervals_api_base_url="https://x", user_agent="t"),
)
client = _Client(response=_Resp())
_inject(monkeypatch, client)
result = _run("/x")
assert result["error"] is True
assert "API key is required" in result["message"]
assert client.calls == [] # no HTTP call attempted
def test_request_error_is_wrapped(monkeypatch):
client = _Client(exc=httpx.RequestError("connection refused"))
_inject(monkeypatch, client)
result = _run("/x")
assert result["error"] is True
assert "Request error" in result["message"]
def test_http_status_error_is_mapped(monkeypatch):
req = httpx.Request("GET", "https://intervals.icu/api/v1/x")
resp = httpx.Response(status_code=404, request=req, text="nope")
err = httpx.HTTPStatusError("404", request=req, response=resp)
client = _Client(response=_Resp(raise_exc=err))
_inject(monkeypatch, client)
result = _run("/x")
assert result["error"] is True
assert result["status_code"] == 404
assert "doesn't exist" in result["message"] # friendly 404 message
def test_invalid_json_returns_error(monkeypatch):
class _BadJson(_Resp):
def json(self):
raise ValueError("bad json")
client = _Client(response=_BadJson(content=b"garbage"))
_inject(monkeypatch, client)
# JSONDecodeError is a subclass of ValueError; _parse_response catches it
from json import JSONDecodeError
class _BadJson2(_Resp):
def json(self):
raise JSONDecodeError("x", "garbage", 0)
client2 = _Client(response=_BadJson2(content=b"garbage"))
_inject(monkeypatch, client2)
result = _run("/x")
assert result["error"] is True
assert "Invalid JSON" in result["message"]
# --------------------------------------------------------------------------- #
# Status-code -> message mapping (pure logic)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"code,needle",
[
(HTTPStatus.UNAUTHORIZED, "check your API key"),
(HTTPStatus.FORBIDDEN, "permission"),
(HTTPStatus.NOT_FOUND, "doesn't exist"),
(HTTPStatus.UNPROCESSABLE_ENTITY, "couldn't process"),
(HTTPStatus.TOO_MANY_REQUESTS, "Too many requests"),
(HTTPStatus.INTERNAL_SERVER_ERROR, "internal error"),
(HTTPStatus.SERVICE_UNAVAILABLE, "maintenance"),
],
)
def test_get_error_message_known_codes(code, needle):
assert needle in api_client._get_error_message(int(code), "raw") # noqa: SLF001
def test_get_error_message_unknown_code_falls_back_to_text():
# a valid-but-unmapped status, and an out-of-range code, both return raw text
assert api_client._get_error_message(418, "teapot") == "teapot" # noqa: SLF001
assert api_client._get_error_message(999, "weird") == "weird" # noqa: SLF001
+155
View File
@@ -0,0 +1,155 @@
"""
Tests for intervals_mcp_server.auth — native OAuth token verification.
Covers the real security logic: a valid RS256 JWT yields an AccessToken with the
right claims; tampered / expired / wrong-audience / wrong-key tokens yield None;
the audience accepts both trailing-slash forms; and build_auth only enables auth
when the environment is configured.
"""
import asyncio
import time
import types
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from intervals_mcp_server import auth as auth_mod
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
ISSUER = "https://auth.example/application/o/x/"
RESOURCE = "https://res.example"
def _keypair():
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return priv, priv.public_key()
def _verifier(pub, audience):
v = AuthentikTokenVerifier("https://jwks.invalid", ISSUER, audience)
# avoid network: hand the verifier a fake JWKS client returning our public key
v._jwks = types.SimpleNamespace( # noqa: SLF001
get_signing_key_from_jwt=lambda _t: types.SimpleNamespace(key=pub)
)
return v
def _token(priv, **overrides):
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": RESOURCE,
"exp": now + 3600,
"iat": now,
"sub": "user-123",
"scope": "read write",
"azp": "client-abc",
}
claims.update(overrides)
return jwt.encode(claims, priv, algorithm="RS256")
# --------------------------------------------------------------------------- #
# verify_token
# --------------------------------------------------------------------------- #
def test_valid_token_returns_access_token_with_claims():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE, "client-abc"])
tok = _token(priv)
result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.subject == "user-123"
assert result.client_id == "client-abc"
assert result.scopes == ["read", "write"]
assert result.resource == RESOURCE
assert result.token == tok
def test_expired_token_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, exp=int(time.time()) - 10)
assert asyncio.run(v.verify_token(tok)) is None
def test_wrong_audience_rejected():
priv, pub = _keypair()
v = _verifier(pub, ["https://someone-else"]) # verifier expects a different aud
tok = _token(priv, aud=RESOURCE)
assert asyncio.run(v.verify_token(tok)) is None
def test_wrong_issuer_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, iss="https://evil")
assert asyncio.run(v.verify_token(tok)) is None
def test_signature_from_other_key_rejected():
priv, _ = _keypair()
_, other_pub = _keypair() # verifier gets a key that did NOT sign the token
v = _verifier(other_pub, [RESOURCE])
tok = _token(priv)
assert asyncio.run(v.verify_token(tok)) is None
def test_missing_required_claim_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
# drop exp -> options require ["exp",...] -> rejected
now = int(time.time())
tok = jwt.encode({"iss": ISSUER, "aud": RESOURCE, "iat": now}, priv, algorithm="RS256")
assert asyncio.run(v.verify_token(tok)) is None
def test_client_id_falls_back_to_resource_when_no_azp():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, azp=None)
del_claims = jwt.decode(tok, options={"verify_signature": False})
assert "azp" in del_claims # azp present but None
result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.client_id == RESOURCE # falls back to the aud/resource
# --------------------------------------------------------------------------- #
# _audience_variants
# --------------------------------------------------------------------------- #
def test_audience_variants_include_both_slash_forms_and_client_id():
variants = _audience_variants("https://res.example", "cid")
assert "https://res.example" in variants
assert "https://res.example/" in variants
assert "cid" in variants
def test_audience_variants_dedupe_and_no_client_id():
variants = _audience_variants("https://res.example/", None)
assert variants == ["https://res.example", "https://res.example/"]
# --------------------------------------------------------------------------- #
# build_auth
# --------------------------------------------------------------------------- #
def test_build_auth_disabled_without_env(monkeypatch):
for var in ("MCP_ISSUER", "MCP_RESOURCE", "MCP_JWKS_URI", "MCP_CLIENT_ID"):
monkeypatch.delenv(var, raising=False)
assert build_auth() == (None, None)
def test_build_auth_enabled_with_env(monkeypatch):
monkeypatch.setenv("MCP_ISSUER", ISSUER)
monkeypatch.setenv("MCP_RESOURCE", RESOURCE)
monkeypatch.setenv("MCP_JWKS_URI", "https://auth.example/jwks/")
monkeypatch.setenv("MCP_CLIENT_ID", "client-abc")
settings, verifier = build_auth()
assert settings is not None
assert isinstance(verifier, AuthentikTokenVerifier)
assert str(settings.issuer_url) == ISSUER
# audience carries both slash forms + client id
assert "client-abc" in verifier._audience # noqa: SLF001
+135
View File
@@ -0,0 +1,135 @@
"""
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(athlete_id="i1"))
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(athlete_id="i1"))
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, athlete_id="i1"))
# --------------------------------------------------------------------------- #
# 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", athlete_id="i1",
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", athlete_id="i1", 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", athlete_id="i1", 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", athlete_id="i1"))
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, athlete_id="i1", 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, athlete_id="i1", 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, athlete_id="i1"))
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, athlete_id="i1"))
+187
View File
@@ -0,0 +1,187 @@
"""
Tests for intervals_mcp_server.tools.events.
Verifies the request payloads sent to the API (create vs update, note vs workout),
the delete-by-range accounting, and the response/error handling branches — not just
that a happy-path string comes back.
"""
import asyncio
import pytest
from intervals_mcp_server.tools import events
from intervals_mcp_server.tools.events import _handle_event_response, _prepare_event_data
from intervals_mcp_server.utils.types import Step, WorkoutDoc
class _Recorder:
"""Fake make_intervals_request that records calls and returns canned data."""
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(events, "make_intervals_request", rec)
return rec
def _run(coro):
return asyncio.run(coro)
# --------------------------------------------------------------------------- #
# _prepare_event_data / _handle_event_response (pure helpers)
# --------------------------------------------------------------------------- #
def test_prepare_event_data_shapes_payload():
doc = WorkoutDoc(description="VO2", steps=[Step(duration=600)])
data = _prepare_event_data("Morning Ride", "", "2026-07-10", doc, 3600, 40000)
assert data["start_date_local"] == "2026-07-10T00:00:00"
assert data["category"] == "WORKOUT"
assert data["type"] == "Ride" # resolved from the name "Morning Ride"
assert data["moving_time"] == 3600
assert data["distance"] == 40000
assert "VO2" in data["description"]
def test_prepare_event_data_null_description_without_doc():
data = _prepare_event_data("Run", "Run", "2026-07-10", None, None, None)
assert data["description"] is None
@pytest.mark.parametrize(
"result,action,needle",
[
({"error": "x", "message": "boom"}, "creating", "Error creating event: boom"),
(None, "created", "No events created"),
({"id": "e42"}, "created", "Successfully created event id: e42"),
([{"id": 1}], "updated", "Event updated successfully"),
],
)
def test_handle_event_response_branches(result, action, needle):
assert needle in _handle_event_response(result, action, "i1", "2026-07-10")
# --------------------------------------------------------------------------- #
# get_events / get_event_by_id
# --------------------------------------------------------------------------- #
def test_get_events_sends_date_params_and_formats(monkeypatch):
rec = _patch(monkeypatch, lambda _k: [{"start_date_local": "2026-07-10", "id": "e1", "name": "Race", "race": True}])
out = _run(events.get_events(athlete_id="i1", start_date="2026-07-01", end_date="2026-07-31"))
call = rec.calls[0]
assert call["url"] == "/athlete/i1/events"
assert call["params"] == {"oldest": "2026-07-01", "newest": "2026-07-31"}
assert "Events:" in out and "Race" in out
def test_get_events_error_surfaced(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "rate limited"})
out = _run(events.get_events(athlete_id="i1"))
assert "Error fetching events: rate limited" in out
def test_get_events_empty(monkeypatch):
_patch(monkeypatch, lambda _k: [])
out = _run(events.get_events(athlete_id="i1"))
assert "No events found" in out
def test_get_event_by_id_invalid_format(monkeypatch):
_patch(monkeypatch, lambda _k: [1, 2, 3]) # list, not a dict
out = _run(events.get_event_by_id("e1", athlete_id="i1"))
assert "Invalid event format" in out
def test_get_event_by_id_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "nope"})
out = _run(events.get_event_by_id("e1", athlete_id="i1"))
assert "Error fetching event details: nope" in out
# --------------------------------------------------------------------------- #
# create / update
# --------------------------------------------------------------------------- #
def test_add_event_posts_when_no_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e99"})
out = _run(
events.add_or_update_event(
workout_type="Ride", name="Threshold", athlete_id="i1",
start_date="2026-07-10", moving_time=3600, distance=40000,
workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]),
)
)
call = rec.calls[0]
assert call["method"] == "POST"
assert call["url"] == "/athlete/i1/events"
assert call["data"]["name"] == "Threshold"
assert "Successfully created event id: e99" in out
def test_update_event_puts_when_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e5"})
_run(
events.add_or_update_event(
workout_type="Ride", name="Threshold", athlete_id="i1",
event_id="e5", start_date="2026-07-10",
)
)
call = rec.calls[0]
assert call["method"] == "PUT"
assert call["url"] == "/athlete/i1/events/e5"
def test_add_event_invalid_date_returns_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"id": "e1"})
out = _run(events.add_or_update_event(workout_type="Ride", name="X", athlete_id="i1", start_date="07/10/2026"))
assert out.startswith("Error:")
def test_add_note_uses_note_category_and_color(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "n1"})
_run(events.add_or_update_note(name="Sick day", description="rest", athlete_id="i1", start_date="2026-07-10", color="red"))
data = rec.calls[0]["data"]
assert data["category"] == "NOTE"
assert data["color"] == "red"
assert data["description"] == "rest"
# --------------------------------------------------------------------------- #
# delete
# --------------------------------------------------------------------------- #
def test_delete_event_requires_id(monkeypatch):
_patch(monkeypatch, lambda _k: {})
out = _run(events.delete_event("", athlete_id="i1"))
assert "No event ID provided" in out
def test_delete_event_uses_delete_method(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"deleted": True})
_run(events.delete_event("e7", athlete_id="i1"))
call = rec.calls[0]
assert call["method"] == "DELETE"
assert call["url"] == "/athlete/i1/events/e7"
def test_delete_by_range_counts_successes_and_failures(monkeypatch):
def handler(kwargs):
if kwargs.get("method") == "DELETE":
return {"error": True, "message": "no"} if kwargs["url"].endswith("/2") else {}
return [{"id": 1}, {"id": 2}] # the GET listing
_patch(monkeypatch, handler)
out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31", athlete_id="i1"))
assert "Deleted 1 events" in out
assert "Failed to delete 1 events: [2]" in out
def test_delete_by_range_fetch_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "boom"})
out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31", athlete_id="i1"))
assert "Error deleting events: boom" in out
+74
View File
@@ -0,0 +1,74 @@
"""
Tests for intervals_mcp_server.server_setup — transport selection and startup.
Verifies MCP_TRANSPORT parsing (including the http -> streamable-http mapping and
the invalid-value error) and that start_server dispatches to mcp.run with the
correct transport arguments for each mode.
"""
from unittest.mock import MagicMock
import pytest
from intervals_mcp_server import server_setup
from intervals_mcp_server.utils.types import TransportAliases
# --------------------------------------------------------------------------- #
# setup_transport
# --------------------------------------------------------------------------- #
def test_default_is_stdio(monkeypatch):
monkeypatch.delenv("MCP_TRANSPORT", raising=False)
assert server_setup.setup_transport() == TransportAliases.STDIO
@pytest.mark.parametrize(
"value,expected",
[
("sse", TransportAliases.SSE),
("http", TransportAliases.STREAMABLE_HTTP), # http is an alias for streamable-http
("streamable-http", TransportAliases.STREAMABLE_HTTP),
("STDIO", TransportAliases.STDIO), # case-insensitive
],
)
def test_transport_mapping(monkeypatch, value, expected):
monkeypatch.setenv("MCP_TRANSPORT", value)
assert server_setup.setup_transport() == expected
def test_invalid_transport_raises(monkeypatch):
monkeypatch.setenv("MCP_TRANSPORT", "carrier-pigeon")
with pytest.raises(ValueError, match="Unsupported MCP_TRANSPORT"):
server_setup.setup_transport()
# --------------------------------------------------------------------------- #
# start_server
# --------------------------------------------------------------------------- #
def _mock_mcp():
m = MagicMock()
m.settings.host = "0.0.0.0"
m.settings.port = 8080
m.settings.sse_path = "/sse"
m.settings.message_path = "/messages"
m.settings.streamable_http_path = "/mcp"
return m
def test_start_server_stdio():
m = _mock_mcp()
server_setup.start_server(m, TransportAliases.STDIO)
m.run.assert_called_once_with()
def test_start_server_streamable_http():
m = _mock_mcp()
server_setup.start_server(m, TransportAliases.STREAMABLE_HTTP)
m.run.assert_called_once_with(transport="streamable-http")
def test_start_server_sse_uses_mount_path(monkeypatch):
monkeypatch.setenv("MCP_SSE_MOUNT_PATH", "/custom")
m = _mock_mcp()
server_setup.start_server(m, TransportAliases.SSE)
m.run.assert_called_once_with(transport="sse", mount_path="/custom")
+300
View File
@@ -0,0 +1,300 @@
"""
Tests for the workout data model in intervals_mcp_server.utils.types.
These exercise the serialization logic that builds ``add_or_update_event`` payloads:
round-trips (to_dict/from_dict/to_json/from_json), enum conversion, the camelCase
API key mapping, recursive nested steps, and the human-readable ``__str__`` output.
A bug in any branch (wrong key, missed enum conversion, broken recursion) fails here.
"""
import json
import pytest
from intervals_mcp_server.utils.types import (
HrTarget,
Intensity,
PaceUnits,
SportSettings,
Step,
Value,
ValueUnits,
WorkoutDoc,
WorkoutTarget,
float_to_str,
)
# --------------------------------------------------------------------------- #
# float_to_str
# --------------------------------------------------------------------------- #
def test_float_to_str_drops_trailing_zero():
assert float_to_str(95.0) == "95"
assert float_to_str(1.5) == "1.5"
assert float_to_str(0.0) == "0"
# --------------------------------------------------------------------------- #
# Value
# --------------------------------------------------------------------------- #
def test_value_to_dict_only_includes_set_fields():
assert Value(value=200, units=ValueUnits.WATTS).to_dict() == {"value": 200, "units": "w"}
# unset fields must be omitted, not serialized as null
assert "start" not in Value(value=1).to_dict()
def test_value_roundtrip_dict_and_json():
original = Value(start=65, end=85, units=ValueUnits.PERCENT_FTP, target=HrTarget.THREE_SECOND)
assert Value.from_dict(original.to_dict()) == original
assert Value.from_json(original.to_json()) == original
def test_value_from_dict_converts_enums():
val = Value.from_dict({"value": 3, "units": "power_zone", "target": "lap"})
assert val.units is ValueUnits.POWER_ZONE
assert val.target is HrTarget.LAP
@pytest.mark.parametrize(
"value,units,expected",
[
(95.0, ValueUnits.PERCENT_FTP, "95% ftp"),
(200.0, ValueUnits.WATTS, "200W"),
(3.0, ValueUnits.POWER_ZONE, "Z3 W"),
(90.0, ValueUnits.CADENCE, "90rpm Cadence"),
(150.0, ValueUnits.PERCENT_HR, "150% HR"),
],
)
def test_value_str_formats_by_unit(value, units, expected):
assert str(Value(value=value, units=units)) == expected
def test_value_str_ramp_and_target():
assert str(Value(start=65, end=85, units=ValueUnits.PERCENT_FTP)) == "65%-85% ftp"
assert str(Value(value=150, target=HrTarget.LAP)) == "150 hr=lap"
# --------------------------------------------------------------------------- #
# Step
# --------------------------------------------------------------------------- #
def test_step_roundtrip_with_nested_steps_and_values():
"""Recursive round-trip: a repeat block containing a step with power/hr targets."""
original = Step(
reps=3,
intensity=Intensity.INTERVAL,
steps=[
Step(
duration=300,
power=Value(value=250, units=ValueUnits.WATTS),
hr=Value(start=150, end=165, units=ValueUnits.PERCENT_LTHR),
cadence=Value(value=90, units=ValueUnits.CADENCE),
),
Step(duration=60, freeride=True),
],
)
as_dict = original.to_dict()
# nested steps must serialize recursively, and enums become their .value
assert as_dict["intensity"] == "interval"
assert as_dict["steps"][0]["power"] == {"value": 250, "units": "w"}
assert isinstance(as_dict["steps"], list) and len(as_dict["steps"]) == 2
# full round-trip preserves the structure
assert Step.from_dict(as_dict) == original
assert Step.from_json(original.to_json()) == original
def test_step_to_dict_omits_unset_and_serializes_resolved_fields():
step = Step(duration=120, _power=Value(value=248, units=ValueUnits.WATTS), _distance=1000.0)
data = step.to_dict()
assert data == {"duration": 120, "_power": {"value": 248, "units": "w"}, "_distance": 1000.0}
assert "hr" not in data
@pytest.mark.parametrize(
"duration,expected",
[
(45, "45s"),
(120, "2m"),
(125, "2m5s"),
(3720, "1h2m"),
],
)
def test_step_format_duration(duration, expected):
assert Step(duration=duration)._format_duration() == expected # noqa: SLF001
@pytest.mark.parametrize("distance,expected", [(500, "500mtr"), (1500, "1.5km"), (2000, "2km")])
def test_step_format_distance(distance, expected):
assert Step(distance=distance)._format_distance() == expected # noqa: SLF001
def test_step_str_warmup_and_targets():
out = str(Step(warmup=True, duration=600, power=Value(value=150, units=ValueUnits.WATTS)))
assert "Warmup" in out
assert "- 10m" in out
assert "150W" in out
def test_step_str_reps_block_renders_children():
block = Step(reps=3, steps=[Step(duration=60, power=Value(value=100, units=ValueUnits.WATTS))])
out = str(block)
assert "3x" in out
assert "100W" in out
def test_step_str_nested_reps_raises():
"""A repeat inside a repeat is unsupported and must raise, not silently mis-render."""
with pytest.raises(ValueError, match="Nested steps not supported"):
Step(reps=2)._to_str(nested=True) # noqa: SLF001
# --------------------------------------------------------------------------- #
# SportSettings
# --------------------------------------------------------------------------- #
def test_sport_settings_roundtrip():
assert SportSettings().to_dict() == {}
assert SportSettings.from_dict({"anything": 1}) == SportSettings()
assert SportSettings.from_json(SportSettings().to_json()) == SportSettings()
# --------------------------------------------------------------------------- #
# WorkoutDoc
# --------------------------------------------------------------------------- #
def test_workout_doc_uses_camelcase_api_keys():
doc = WorkoutDoc(
description="Threshold 3x10",
sport_settings=SportSettings(),
zone_times=[600, 300, 120],
target=WorkoutTarget.POWER,
pace_units=PaceUnits.MINS_KM,
steps=[Step(duration=600, power=Value(value=240, units=ValueUnits.WATTS))],
)
data = doc.to_dict()
# API expects camelCase for these two specifically
assert "sportSettings" in data and "sport_settings" not in data
assert "zoneTimes" in data and "zone_times" not in data
assert data["target"] == "POWER"
assert data["pace_units"] == "MINS_KM"
assert data["steps"][0]["power"] == {"value": 240, "units": "w"}
def test_workout_doc_roundtrip_dict_and_json():
doc = WorkoutDoc(
description="desc",
duration=1800,
ftp=266,
target=WorkoutTarget.AUTO,
sport_settings=SportSettings(),
steps=[Step(reps=2, steps=[Step(duration=300, hr=Value(value=160, units=ValueUnits.HR_ZONE))])],
zone_times=[{"id": 1, "secs": 100}], # zone_times can be objects, not just ints
options={"pool_length": "25m"},
locales=["en"],
)
assert WorkoutDoc.from_dict(doc.to_dict()) == doc
assert WorkoutDoc.from_json(doc.to_json()) == doc
def test_workout_doc_from_dict_maps_camelcase_back():
doc = WorkoutDoc.from_dict(
{"description": "d", "sportSettings": {}, "zoneTimes": [1, 2], "target": "HR"}
)
assert doc.sport_settings == SportSettings()
assert doc.zone_times == [1, 2]
assert doc.target is WorkoutTarget.HR
def test_workout_doc_str_includes_description_and_steps():
doc = WorkoutDoc(description="Endurance", steps=[Step(duration=3600, freeride=True)])
out = str(doc)
assert out.startswith("Endurance")
assert "freeride" in out
def test_workout_doc_json_is_valid_and_minimal():
"""to_json must emit real JSON with only the set fields."""
payload = json.loads(WorkoutDoc(description="x", ftp=250).to_json())
assert payload == {"description": "x", "ftp": 250}
# --------------------------------------------------------------------------- #
# Exhaustive round-trips (exercise every optional-field branch in to/from_dict)
# --------------------------------------------------------------------------- #
def test_value_str_no_units():
assert str(Value(value=42)) == "42"
def test_step_all_fields_roundtrip():
step = Step(
text="Main set",
text_locale={"en": "Main set"},
duration=600,
distance=1000.0,
until_lap_press=True,
reps=4,
warmup=True,
cooldown=True,
intensity=Intensity.INTERVAL,
steps=[Step(duration=60, freeride=True)],
ramp=True,
freeride=False,
maxeffort=True,
power=Value(value=250, units=ValueUnits.WATTS),
hr=Value(value=160, units=ValueUnits.PERCENT_HR),
pace=Value(value=300, units=ValueUnits.MINS_KM),
cadence=Value(value=90, units=ValueUnits.CADENCE),
hidepower=True,
_power=Value(value=248, units=ValueUnits.WATTS),
_hr=Value(value=158, units=ValueUnits.PERCENT_HR),
_pace=Value(value=298, units=ValueUnits.MINS_KM),
_distance=995.0,
)
assert Step.from_dict(step.to_dict()) == step
def test_step_format_none_paths():
assert Step()._format_duration() == "" # noqa: SLF001
assert Step()._format_distance() == "" # noqa: SLF001
def test_step_str_distance_flags_and_text():
out = str(
Step(
cooldown=True,
distance=500,
maxeffort=True,
ramp=True,
hidepower=True,
intensity=Intensity.REST,
hr=Value(value=140, units=ValueUnits.PERCENT_HR),
pace=Value(value=300, units=ValueUnits.MINS_KM),
cadence=Value(value=85, units=ValueUnits.CADENCE),
text="hold steady",
)
)
assert "Cooldown" in out
assert "500mtr" in out
assert "maxeffort" in out and "ramp" in out and "hidepower" in out
assert "intensity=rest" in out
assert "140% HR" in out
assert "85rpm Cadence" in out
assert "hold steady" in out
def test_workout_doc_all_fields_roundtrip():
doc = WorkoutDoc(
description="Full",
description_locale={"en": "Full"},
duration=3600,
distance=40000.0,
ftp=266,
lthr=174,
threshold_pace=4.2,
pace_units=PaceUnits.MINS_MILE,
sport_settings=SportSettings(),
category="WORKOUT",
target=WorkoutTarget.PACE,
steps=[Step(duration=600, power=Value(value=240, units=ValueUnits.WATTS))],
zone_times=[600, 300],
options={"pool_length": "25m"},
locales=["en", "es"],
)
assert WorkoutDoc.from_dict(doc.to_dict()) == doc