gabriel / musehub public
test_musehub_json_alternate.py python
287 lines 9.8 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for MuseHub JSON alternate content negotiation.
2
3 Verifies:
4 - Accept: application/json returns JSONResponse with data/meta envelope
5 - Accept: text/html (or no header) returns the HTML path
6 - Bot User-Agents receive X-MuseHub-JSON-Available header
7 - Non-bot User-Agents do NOT receive X-MuseHub-JSON-Available header
8 - Helper function behaviour in isolation
9 """
10 from __future__ import annotations
11
12 import pytest
13 from fastapi import FastAPI, Request
14 from fastapi.responses import HTMLResponse
15 from fastapi.testclient import TestClient
16 from starlette.responses import Response
17
18 from musehub.api.routes.musehub.json_alternate import (
19 add_json_available_header,
20 is_bot_user_agent,
21 json_or_html,
22 )
23
24
25 # ---------------------------------------------------------------------------
26 # Minimal test app that exercises json_or_html via a real ASGI route
27 # ---------------------------------------------------------------------------
28
29 _app = FastAPI()
30
31
32 @_app.get("/test-page")
33 async def _test_page(request: Request) -> Response:
34 """Minimal route exercising json_or_html."""
35 ctx = {"title": "Test", "value": 42}
36 return json_or_html(
37 request,
38 lambda: HTMLResponse(content="<html>test</html>"),
39 ctx,
40 )
41
42
43 @_app.get("/bot-header-test")
44 async def _bot_header_test(request: Request) -> Response:
45 """Route that exercises add_json_available_header."""
46 response = HTMLResponse(content="<html>ok</html>")
47 return add_json_available_header(response, request)
48
49
50 _client = TestClient(_app, raise_server_exceptions=True)
51
52
53 # ---------------------------------------------------------------------------
54 # json_or_html — content negotiation
55 # ---------------------------------------------------------------------------
56
57
58 class TestJsonOrHtml:
59 """json_or_html dispatches based on Accept header."""
60
61 def test_accept_json_returns_json_response(self) -> None:
62 resp = _client.get("/test-page", headers={"Accept": "application/json"})
63 assert resp.status_code == 200
64 assert resp.headers["content-type"].startswith("application/json")
65 body = resp.json()
66 assert "data" in body
67 assert "meta" in body
68
69 def test_json_data_envelope_contains_context(self) -> None:
70 resp = _client.get("/test-page", headers={"Accept": "application/json"})
71 data = resp.json()["data"]
72 assert data["title"] == "Test"
73 assert data["value"] == 42
74
75 def test_json_meta_contains_url(self) -> None:
76 resp = _client.get("/test-page", headers={"Accept": "application/json"})
77 meta = resp.json()["meta"]
78 assert "url" in meta
79 assert "test-page" in meta["url"]
80
81 def test_accept_html_returns_html_response(self) -> None:
82 resp = _client.get("/test-page", headers={"Accept": "text/html"})
83 assert resp.status_code == 200
84 assert resp.headers["content-type"].startswith("text/html")
85 assert b"<html>" in resp.content
86
87 def test_no_accept_header_returns_html(self) -> None:
88 resp = _client.get("/test-page")
89 assert resp.status_code == 200
90 assert resp.headers["content-type"].startswith("text/html")
91
92 def test_accept_star_returns_html(self) -> None:
93 resp = _client.get("/test-page", headers={"Accept": "*/*"})
94 assert resp.status_code == 200
95 assert resp.headers["content-type"].startswith("text/html")
96
97 def test_bot_ua_html_response_includes_discovery_header(self) -> None:
98 """json_or_html wires add_json_available_header into the HTML path."""
99 resp = _client.get(
100 "/test-page",
101 headers={"User-Agent": "claude-agent/1.0"},
102 )
103 assert resp.status_code == 200
104 assert resp.headers["content-type"].startswith("text/html")
105 assert resp.headers.get("x-musehub-json-available") == "true"
106
107 def test_browser_ua_html_response_omits_discovery_header(self) -> None:
108 """json_or_html does not add discovery header for browser User-Agents."""
109 resp = _client.get(
110 "/test-page",
111 headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14) AppleWebKit/537.36"},
112 )
113 assert resp.status_code == 200
114 assert "x-musehub-json-available" not in resp.headers
115
116 def test_accept_json_with_quality_returns_json(self) -> None:
117 resp = _client.get(
118 "/test-page",
119 headers={"Accept": "application/json;q=0.9, text/html"},
120 )
121 assert resp.status_code == 200
122 assert resp.headers["content-type"].startswith("application/json")
123
124
125 # ---------------------------------------------------------------------------
126 # is_bot_user_agent — User-Agent detection
127 # ---------------------------------------------------------------------------
128
129
130 async def test_is_bot_ua_detects_bot_keyword() -> None:
131 """is_bot_user_agent returns True for 'bot' User-Agents."""
132 from starlette.testclient import TestClient as _STC
133
134 app = FastAPI()
135
136 @app.get("/ua")
137 async def _ua(request: Request) -> Response:
138 result = "bot" if is_bot_user_agent(request) else "human"
139 return HTMLResponse(content=result)
140
141 client = _STC(app)
142 resp = client.get("/ua", headers={"User-Agent": "Googlebot/2.1"})
143 assert resp.text == "bot"
144
145
146 async def test_is_bot_ua_detects_claude() -> None:
147 app = FastAPI()
148
149 @app.get("/ua")
150 async def _ua(request: Request) -> Response:
151 result = "bot" if is_bot_user_agent(request) else "human"
152 return HTMLResponse(content=result)
153
154 client = TestClient(app)
155 resp = client.get("/ua", headers={"User-Agent": "claude-agent/1.0"})
156 assert resp.text == "bot"
157
158
159 async def test_is_bot_ua_detects_gpt() -> None:
160 app = FastAPI()
161
162 @app.get("/ua")
163 async def _ua(request: Request) -> Response:
164 result = "bot" if is_bot_user_agent(request) else "human"
165 return HTMLResponse(content=result)
166
167 client = TestClient(app)
168 resp = client.get("/ua", headers={"User-Agent": "OpenAI-GPT/4"})
169 assert resp.text == "bot"
170
171
172 async def test_is_bot_ua_detects_cursor() -> None:
173 app = FastAPI()
174
175 @app.get("/ua")
176 async def _ua(request: Request) -> Response:
177 result = "bot" if is_bot_user_agent(request) else "human"
178 return HTMLResponse(content=result)
179
180 client = TestClient(app)
181 resp = client.get("/ua", headers={"User-Agent": "Cursor/0.42"})
182 assert resp.text == "bot"
183
184
185 async def test_is_bot_ua_returns_false_for_browser() -> None:
186 app = FastAPI()
187
188 @app.get("/ua")
189 async def _ua(request: Request) -> Response:
190 result = "bot" if is_bot_user_agent(request) else "human"
191 return HTMLResponse(content=result)
192
193 client = TestClient(app)
194 resp = client.get(
195 "/ua",
196 headers={
197 "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14) AppleWebKit/537.36"
198 },
199 )
200 assert resp.text == "human"
201
202
203 # ---------------------------------------------------------------------------
204 # add_json_available_header
205 # ---------------------------------------------------------------------------
206
207
208 class TestAddJsonAvailableHeader:
209 """add_json_available_header attaches header only for bot UAs."""
210
211 def test_bot_ua_receives_header(self) -> None:
212 resp = _client.get(
213 "/bot-header-test", headers={"User-Agent": "claude-agent/1.0"}
214 )
215 assert resp.headers.get("x-musehub-json-available") == "true"
216
217 def test_browser_ua_does_not_receive_header(self) -> None:
218 resp = _client.get(
219 "/bot-header-test",
220 headers={
221 "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14) AppleWebKit/537.36"
222 },
223 )
224 assert "x-musehub-json-available" not in resp.headers
225
226 def test_no_ua_does_not_receive_header(self) -> None:
227 resp = _client.get("/bot-header-test")
228 assert "x-musehub-json-available" not in resp.headers
229
230 def test_agent_ua_receives_header(self) -> None:
231 resp = _client.get(
232 "/bot-header-test", headers={"User-Agent": "my-agent/2.0"}
233 )
234 assert resp.headers.get("x-musehub-json-available") == "true"
235
236
237 # ---------------------------------------------------------------------------
238 # Unit tests for is_bot_user_agent in isolation
239 # ---------------------------------------------------------------------------
240
241
242 class TestIsBotUserAgentUnit:
243 """Pure unit tests for the bot UA detection regex."""
244
245 def _make_request(self, ua: str) -> Request:
246 """Build a minimal Starlette Request with the given User-Agent."""
247 from starlette.datastructures import Headers
248 from starlette.types import Scope
249
250 scope: Scope = {
251 "type": "http",
252 "method": "GET",
253 "path": "/",
254 "query_string": b"",
255 "headers": Headers(headers={"user-agent": ua}).raw,
256 }
257 return Request(scope)
258
259 def test_bot_keyword_case_insensitive(self) -> None:
260 assert is_bot_user_agent(self._make_request("Moz-Bot/1.0")) is True
261 assert is_bot_user_agent(self._make_request("MOZ-BOT/1.0")) is True
262
263 def test_agent_keyword(self) -> None:
264 assert is_bot_user_agent(self._make_request("my-agent/1.0")) is True
265
266 def test_claude_keyword(self) -> None:
267 assert is_bot_user_agent(self._make_request("claude-code")) is True
268
269 def test_gpt_keyword(self) -> None:
270 assert is_bot_user_agent(self._make_request("gpt4-client")) is True
271
272 def test_cursor_keyword(self) -> None:
273 assert is_bot_user_agent(self._make_request("Cursor/0.42")) is True
274
275 def test_empty_ua(self) -> None:
276 assert is_bot_user_agent(self._make_request("")) is False
277
278 def test_regular_browser(self) -> None:
279 assert (
280 is_bot_user_agent(
281 self._make_request(
282 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) "
283 "AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36"
284 )
285 )
286 is False
287 )
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago