test: raise coverage 64% -> 90% with behavior-focused tests + enforced gate
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user