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