Files
Chris Farhood f067f9639a
build-image / test (push) Successful in 10s
build-image / build (push) Successful in 42s
feat(multi-tenant): resolve per-caller credentials in every tool
All 20 tools now drop the athlete_id/api_key parameters and instead resolve the
authenticated caller's stored, enabled credentials via
credentials.resolve_caller_credentials() (get_access_token().subject -> store).

Security: there is no tool parameter a caller can pass to supply a key, so a
disabled/unapproved user cannot bypass the admin-approval gate — each tool
returns a helpful "not approved / set up your credentials" message instead.
Gear resolution now uses the caller's athlete id rather than an env var.

Tests: conftest autouse fixture runs tool tests as an enabled user; a
parametrized test asserts every tool refuses when unauthorized; existing tool
tests updated (no more athlete_id/api_key kwargs). 221 passing at 91.5%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:28:27 -04:00

185 lines
6.8 KiB
Python

"""
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())
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(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(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