This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+126
View File
@@ -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