43bbbb6bbb
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>
205 lines
6.8 KiB
Python
205 lines
6.8 KiB
Python
"""
|
|
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
|