v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
@@ -0,0 +1,126 @@
|
||||
"""Tests for emby-mcp (run with emby-mcp/.venv: needs mcp 1.x FastMCP).
|
||||
|
||||
emby-mcp\\.venv\\Scripts\\python.exe -m pytest tests/test_emby_mcp.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp.server.fastmcp")
|
||||
pytest.importorskip("emby_mcp")
|
||||
|
||||
import httpx # noqa: E402
|
||||
|
||||
from emby_mcp import server as srv # noqa: E402
|
||||
from emby_mcp.client import EmbyClient # noqa: E402
|
||||
from emby_mcp.config import Settings # noqa: E402
|
||||
|
||||
SESSIONS = [
|
||||
{"Id": "s-tv", "DeviceName": "Living Room TV", "Client": "Emby Theater", "SupportsRemoteControl": True},
|
||||
{"Id": "s-srv", "DeviceName": "Tracearr Server", "Client": "Tracearr", "SupportsRemoteControl": False},
|
||||
]
|
||||
|
||||
|
||||
def _client(handler) -> tuple[EmbyClient, list[httpx.Request]]:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def _record(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return handler(request)
|
||||
|
||||
client = EmbyClient(Settings(host="emby.local:8096", api_key="k"))
|
||||
client._http = httpx.AsyncClient(base_url=client.settings.base_url,
|
||||
transport=httpx.MockTransport(_record))
|
||||
return client, seen
|
||||
|
||||
|
||||
def _ctx(client: EmbyClient):
|
||||
return types.SimpleNamespace(
|
||||
request_context=types.SimpleNamespace(lifespan_context=srv.AppContext(emby=client)))
|
||||
|
||||
|
||||
def test_base_url_defaults_to_http_and_accepts_ports_and_urls():
|
||||
assert Settings(host="10.0.0.5:9096", api_key="k").base_url == "http://10.0.0.5:9096"
|
||||
assert Settings(host="10.0.0.5", port=8096, api_key="k").base_url == "http://10.0.0.5:8096"
|
||||
assert Settings(host="10.0.0.5:9096", port=443, api_key="k").base_url == "http://10.0.0.5:9096"
|
||||
assert Settings(host="https://emby.example.org/", api_key="k").base_url == "https://emby.example.org"
|
||||
assert Settings(host="emby.example.org", scheme="https", api_key="k").base_url == "https://emby.example.org"
|
||||
|
||||
|
||||
def test_ids_cannot_reach_other_endpoints():
|
||||
client, seen = _client(lambda r: httpx.Response(200, json={}))
|
||||
|
||||
async def run():
|
||||
await client.get_user("../System/Restart")
|
||||
await client.get_playback_info("123/Delete")
|
||||
await client.play_on_session("s/../../System/Shutdown", ["1"])
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
paths = [r.url.raw_path.decode().split("?")[0] for r in seen]
|
||||
assert paths == ["/Users/..%2FSystem%2FRestart", "/Items/123%2FDelete/PlaybackInfo",
|
||||
"/Sessions/s%2F..%2F..%2FSystem%2FShutdown/Playing"]
|
||||
|
||||
|
||||
def test_search_is_server_side():
|
||||
client, seen = _client(lambda r: httpx.Response(200, json={"Items": [
|
||||
{"Id": "1", "Name": "Stranger Things", "Type": "Series", "ProductionYear": 2016,
|
||||
"RunTimeTicks": 30_000_000_000}]}))
|
||||
hits = asyncio.run(srv.search_items(_ctx(client), "Stranger Things"))
|
||||
params = seen[0].url.params
|
||||
assert params["SearchTerm"] == "Stranger Things"
|
||||
assert "Movie" in params["IncludeItemTypes"] and "Person" not in params["IncludeItemTypes"]
|
||||
assert hits[0].name == "Stranger Things" and hits[0].year == 2016 and hits[0].runtime_minutes == 50
|
||||
|
||||
|
||||
def test_get_item_uses_items_query():
|
||||
client, seen = _client(lambda r: httpx.Response(200, json={"Items": [{"Id": "42", "Name": "X"}]}))
|
||||
item = asyncio.run(srv.get_item(_ctx(client), "42"))
|
||||
assert item["Name"] == "X"
|
||||
assert seen[0].url.path == "/Items" and seen[0].url.params["Ids"] == "42"
|
||||
empty, _ = _client(lambda r: httpx.Response(200, json={"Items": []}))
|
||||
with pytest.raises(LookupError):
|
||||
asyncio.run(srv.get_item(_ctx(empty), "nope"))
|
||||
|
||||
|
||||
def test_play_item_targets_a_controllable_session():
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/Sessions":
|
||||
return httpx.Response(200, json=SESSIONS)
|
||||
return httpx.Response(204)
|
||||
|
||||
client, seen = _client(handler)
|
||||
result = asyncio.run(srv.play_item(_ctx(client), "42"))
|
||||
assert result["session_id"] == "s-tv"
|
||||
play = seen[-1]
|
||||
assert play.method == "POST" and play.url.path == "/Sessions/s-tv/Playing"
|
||||
assert play.url.params["ItemIds"] == "42" and play.url.params["PlayCommand"] == "PlayNow"
|
||||
|
||||
with pytest.raises(ValueError, match="No matching"):
|
||||
asyncio.run(srv.play_item(_ctx(client), "42", device="Kitchen"))
|
||||
with pytest.raises(ValueError, match="No matching"):
|
||||
asyncio.run(srv.play_item(_ctx(client), "42", session_id="s-srv"))
|
||||
|
||||
|
||||
def test_list_devices_unwraps_items():
|
||||
client, _ = _client(lambda r: httpx.Response(200, json={"Items": [{"Id": "d", "Name": "Phone"}]}))
|
||||
assert asyncio.run(srv.list_devices(_ctx(client))) == [
|
||||
{"id": "d", "name": "Phone", "app": None, "last_user": None, "last_active": None}]
|
||||
|
||||
|
||||
def test_playback_url_has_no_credentials():
|
||||
client, _ = _client(lambda r: httpx.Response(200, json={"Items": [
|
||||
{"Id": "7", "Name": "Song", "MediaType": "Audio"}]}))
|
||||
out = asyncio.run(srv.playback_url(_ctx(client), "7"))
|
||||
assert out["url"] == "http://emby.local:8096/Audio/7/stream?static=true"
|
||||
assert "api_key" not in out["url"] and "k" not in out["url"].split("/")[-1]
|
||||
|
||||
|
||||
def test_tool_annotations():
|
||||
tools = {t.name: t for t in asyncio.run(srv.mcp.list_tools())}
|
||||
assert tools["play_item"].annotations.readOnlyHint is False
|
||||
for name in ("search_items", "get_item", "list_sessions", "playback_url"):
|
||||
assert tools[name].annotations.readOnlyHint is True, name
|
||||
Reference in new issue
Block a user