359cb24d9a
- client.py: async httpx wrapper, FableAPIError, stream_get() SSE generator, singleton init_client()/get_client() and _reset_client() for tests - tools/: notes, tasks, projects, milestones, search, chat — thin async functions that accept FableClient and call Fable REST endpoints - server.py: FastMCP entry point with 20 tools registered via @mcp.tool(), each opening a fresh FableClient context per call; validates env vars at startup - tests: 34 tests covering client HTTP behaviour, error handling, singleton, SSE streaming, and all tool modules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
4.8 KiB
Python
143 lines
4.8 KiB
Python
"""Tests for FableClient."""
|
|
import os
|
|
import pytest
|
|
import respx
|
|
import httpx
|
|
from unittest.mock import patch
|
|
|
|
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_singleton():
|
|
"""Reset the module-level client singleton between tests."""
|
|
_reset_client()
|
|
yield
|
|
_reset_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def base_env(monkeypatch):
|
|
monkeypatch.setenv("FABLE_URL", "http://fable.test")
|
|
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
|
|
|
|
|
|
class TestFableClientInit:
|
|
def test_missing_fable_url_raises(self, monkeypatch):
|
|
monkeypatch.delenv("FABLE_URL", raising=False)
|
|
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
|
|
with pytest.raises(ValueError, match="FABLE_URL"):
|
|
FableClient()
|
|
|
|
def test_missing_api_key_raises(self, monkeypatch):
|
|
monkeypatch.setenv("FABLE_URL", "http://fable.test")
|
|
monkeypatch.delenv("FABLE_API_KEY", raising=False)
|
|
with pytest.raises(ValueError, match="FABLE_API_KEY"):
|
|
FableClient()
|
|
|
|
def test_trailing_slash_stripped(self, base_env):
|
|
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
|
|
client = FableClient()
|
|
assert client.base_url == "http://fable.test"
|
|
|
|
def test_auth_header_set(self, base_env):
|
|
client = FableClient()
|
|
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
|
|
|
|
|
|
class TestFableClientHTTP:
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_get_returns_json(self, base_env):
|
|
respx.get("http://fable.test/api/notes").mock(
|
|
return_value=httpx.Response(200, json={"notes": []})
|
|
)
|
|
async with FableClient() as client:
|
|
data = await client.get("/api/notes")
|
|
assert data == {"notes": []}
|
|
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_non_2xx_raises_fable_api_error(self, base_env):
|
|
respx.get("http://fable.test/api/notes").mock(
|
|
return_value=httpx.Response(401, json={"error": "Unauthorized"})
|
|
)
|
|
async with FableClient() as client:
|
|
with pytest.raises(FableAPIError) as exc_info:
|
|
await client.get("/api/notes")
|
|
assert exc_info.value.status_code == 401
|
|
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_post_sends_json(self, base_env):
|
|
respx.post("http://fable.test/api/notes").mock(
|
|
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
|
|
)
|
|
async with FableClient() as client:
|
|
data = await client.post("/api/notes", json={"title": "Test"})
|
|
assert data["id"] == 1
|
|
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_patch_sends_json(self, base_env):
|
|
respx.patch("http://fable.test/api/notes/1").mock(
|
|
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
|
|
)
|
|
async with FableClient() as client:
|
|
data = await client.patch("/api/notes/1", json={"title": "Updated"})
|
|
assert data["title"] == "Updated"
|
|
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_delete_returns_none_on_204(self, base_env):
|
|
respx.delete("http://fable.test/api/notes/1").mock(
|
|
return_value=httpx.Response(204)
|
|
)
|
|
async with FableClient() as client:
|
|
result = await client.delete("/api/notes/1")
|
|
assert result is None
|
|
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_fable_api_error_includes_message(self, base_env):
|
|
respx.get("http://fable.test/api/notes").mock(
|
|
return_value=httpx.Response(404, json={"error": "Not found"})
|
|
)
|
|
async with FableClient() as client:
|
|
with pytest.raises(FableAPIError) as exc_info:
|
|
await client.get("/api/notes")
|
|
assert "Not found" in str(exc_info.value)
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
class TestSingleton:
|
|
def test_get_client_before_init_raises(self):
|
|
with pytest.raises(RuntimeError, match="init_client"):
|
|
get_client()
|
|
|
|
def test_init_client_returns_instance(self, base_env):
|
|
client = init_client()
|
|
assert isinstance(client, FableClient)
|
|
|
|
def test_get_client_after_init_returns_same(self, base_env):
|
|
init_client()
|
|
c1 = get_client()
|
|
c2 = get_client()
|
|
assert c1 is c2
|
|
|
|
|
|
class TestStreamGet:
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_stream_get_yields_lines(self, base_env):
|
|
sse_body = b"data: line1\n\ndata: line2\n\n"
|
|
respx.get("http://fable.test/api/stream").mock(
|
|
return_value=httpx.Response(200, content=sse_body)
|
|
)
|
|
lines = []
|
|
async with FableClient() as client:
|
|
async for line in client.stream_get("/api/stream"):
|
|
lines.append(line)
|
|
assert "data: line1" in lines
|
|
assert "data: line2" in lines
|