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:
@@ -10,7 +10,24 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
API_KEY: test
|
||||
ATHLETE_ID: i1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install (with dev extras)
|
||||
run: pip install --quiet ".[dev]"
|
||||
- name: Test + coverage gate (>=90%)
|
||||
run: pytest
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"id":89,"owner":{"id":16,"login":"farhoodlabs","login_name":"","source_id":0,"full_name":"Farhood Labs","email":"","avatar_url":"https://git.farh.net/avatars/460b032d00536bd5638f3c0913c6a9d5","html_url":"https://git.farh.net/farhoodlabs","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2026-05-16T13:56:14Z","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"farhoodlabs"},"name":"intervalsicu-mcp","full_name":"farhoodlabs/intervalsicu-mcp","description":"Intervals.icu MCP server (FastMCP, native OAuth) — farhoodlabs build","empty":false,"private":true,"fork":false,"template":false,"mirror":false,"size":169,"language":"Python","languages_url":"https://git.farh.net/api/v1/repos/farhoodlabs/intervalsicu-mcp/languages","html_url":"https://git.farh.net/farhoodlabs/intervalsicu-mcp","url":"https://git.farh.net/api/v1/repos/farhoodlabs/intervalsicu-mcp","link":"","ssh_url":"git@git.farh.net:farhoodlabs/intervalsicu-mcp.git","clone_url":"https://git.farh.net/farhoodlabs/intervalsicu-mcp.git","original_url":"","website":"","stars_count":0,"forks_count":0,"watchers_count":8,"branch_count":1,"open_issues_count":0,"open_pr_counter":0,"release_counter":0,"default_branch":"main","archived":false,"created_at":"2026-07-04T17:57:36Z","updated_at":"2026-07-04T19:12:59Z","archived_at":"1970-01-01T00:00:00Z","permissions":{"admin":true,"push":true,"pull":true},"has_code":true,"has_issues":true,"internal_tracker":{"enable_time_tracker":true,"allow_only_contributors_to_track_time":true,"enable_issue_dependencies":true},"has_wiki":true,"has_pull_requests":true,"has_projects":true,"projects_mode":"all","has_releases":true,"has_packages":true,"has_actions":true,"ignore_whitespace_conflicts":false,"allow_merge_commits":true,"allow_rebase":true,"allow_rebase_explicit":true,"allow_squash_merge":true,"allow_fast_forward_only_merge":true,"allow_rebase_update":true,"allow_manual_merge":false,"autodetect_manual_merge":false,"default_delete_branch_after_merge":false,"default_merge_style":"merge","default_allow_maintainer_edit":true,"avatar_url":"","internal":false,"mirror_interval":"","object_format_name":"sha1","mirror_updated":"0001-01-01T00:00:00Z","topics":[],"licenses":[]}
|
||||
+2
-2
@@ -31,7 +31,7 @@ keywords = ["intervals", "cycling", "running", "mcp", "ai"]
|
||||
"Upstream" = "https://github.com/mvilanova/intervals-mcp-server"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.3.5", "mypy>=1.0.0", "ruff>=0.1.0", "pytest-asyncio>=0.21", "pre-commit", "hatch", "pytest-mock==3.12.0"]
|
||||
dev = ["pytest>=8.3.5", "mypy>=1.0.0", "ruff>=0.1.0", "pytest-asyncio>=0.21", "pre-commit", "hatch", "pytest-mock==3.12.0", "pytest-cov>=5.0", "cryptography>=42.0"]
|
||||
|
||||
[tool.hatch.build]
|
||||
include = ["server.py", "utils/*.py", "README.md", ".env.example"]
|
||||
@@ -114,7 +114,7 @@ default.unicode = true
|
||||
default.locale = "en-us"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
addopts = "-q --cov=src/intervals_mcp_server --cov-report=term-missing --cov-fail-under=90"
|
||||
testpaths = ["tests"]
|
||||
python_files = "test_*.py"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
@@ -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
|
||||
@@ -136,6 +136,75 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
@@ -350,17 +419,20 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "hatch" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", marker = "extra == 'dev'", specifier = ">=42.0" },
|
||||
{ name = "hatch", marker = "extra == 'dev'" },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.28.1" },
|
||||
@@ -369,6 +441,7 @@ requires-dist = [
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
|
||||
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = "==3.12.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
@@ -806,6 +879,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.12.0"
|
||||
|
||||
Reference in New Issue
Block a user