Files
intervalsicu-mcp/tests/test_api_client.py
T
Chris Farhood 43bbbb6bbb
build-image / test (push) Failing after 53s
build-image / build (push) Has been skipped
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>
2026-07-04 18:39:20 -04:00

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