From 43bbbb6bbbad2c601d065d69f3511c9b8cfdfae3 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 4 Jul 2026 18:39:20 -0400 Subject: [PATCH] test: raise coverage 64% -> 90% with behavior-focused tests + enforced gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/build.yaml | 17 ++ json | 1 + pyproject.toml | 4 +- tests/test_activities.py | 184 ++++++++++++++++++++++ tests/test_api_client.py | 204 ++++++++++++++++++++++++ tests/test_auth.py | 155 +++++++++++++++++++ tests/test_custom_items.py | 135 ++++++++++++++++ tests/test_events.py | 187 ++++++++++++++++++++++ tests/test_server_setup.py | 74 +++++++++ tests/test_types.py | 300 ++++++++++++++++++++++++++++++++++++ uv.lock | 87 +++++++++++ 11 files changed, 1346 insertions(+), 2 deletions(-) create mode 100644 json create mode 100644 tests/test_activities.py create mode 100644 tests/test_api_client.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_custom_items.py create mode 100644 tests/test_events.py create mode 100644 tests/test_server_setup.py create mode 100644 tests/test_types.py diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 009c1b4..8da9f21 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -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: diff --git a/json b/json new file mode 100644 index 0000000..69414ee --- /dev/null +++ b/json @@ -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":[]} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bde638c..ff4535a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_activities.py b/tests/test_activities.py new file mode 100644 index 0000000..a02935a --- /dev/null +++ b/tests/test_activities.py @@ -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 diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 0000000..e16ebb5 --- /dev/null +++ b/tests/test_api_client.py @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..ab838e2 --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_custom_items.py b/tests/test_custom_items.py new file mode 100644 index 0000000..2222977 --- /dev/null +++ b/tests/test_custom_items.py @@ -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")) diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..bb11428 --- /dev/null +++ b/tests/test_events.py @@ -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 diff --git a/tests/test_server_setup.py b/tests/test_server_setup.py new file mode 100644 index 0000000..0649f40 --- /dev/null +++ b/tests/test_server_setup.py @@ -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") diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..0fbb7c6 --- /dev/null +++ b/tests/test_types.py @@ -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 diff --git a/uv.lock b/uv.lock index d88a526..94ff525 100644 --- a/uv.lock +++ b/uv.lock @@ -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"