gabriel / musehub public
test_musehub_negotiate.py python
173 lines 6.2 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Unit tests for the MuseHub content negotiation helper.
2
3 Covers — negotiate_response() dispatches HTML vs JSON based on
4 Accept header and ?format query param.
5
6 Tests:
7 - test_negotiate_wants_json_format_param — ?format=json → JSON path
8 - test_negotiate_wants_json_accept_header — Accept: application/json → JSON path
9 - test_negotiate_wants_html_by_default — no header/param → HTML path
10 - test_negotiate_wants_html_text_html_header — Accept: text/html → HTML path
11 - test_negotiate_json_uses_pydantic_by_alias — camelCase keys in JSON output
12 - test_negotiate_json_fallback_to_context — no json_data → context dict as JSON
13 - test_negotiate_accept_partial_match — mixed Accept header containing json
14 """
15 from __future__ import annotations
16
17 from typing import Any
18 from unittest.mock import AsyncMock, MagicMock
19
20 import pytest
21 from fastapi.responses import JSONResponse
22 from starlette.responses import Response
23
24 from musehub.api.routes.musehub.negotiate import _wants_json, negotiate_response
25 from musehub.models.base import CamelModel
26
27
28 # ---------------------------------------------------------------------------
29 # _wants_json unit tests (synchronous helper — no I/O)
30 # ---------------------------------------------------------------------------
31
32
33 def _make_request(accept: str = "", format_param: str | None = None) -> Any:
34 """Build a minimal mock Request with the given Accept header."""
35 req = MagicMock()
36 req.headers = {"accept": accept} if accept else {}
37 return req
38
39
40 def test_negotiate_wants_json_format_param() -> None:
41 """?format=json forces JSON regardless of Accept header."""
42 req = _make_request(accept="text/html")
43 assert _wants_json(req, format_param="json") is True
44
45
46 def test_negotiate_wants_json_accept_header() -> None:
47 """Accept: application/json triggers JSON path."""
48 req = _make_request(accept="application/json")
49 assert _wants_json(req, format_param=None) is True
50
51
52 def test_negotiate_wants_html_by_default() -> None:
53 """No Accept header and no format param → HTML (default)."""
54 req = _make_request()
55 assert _wants_json(req, format_param=None) is False
56
57
58 def test_negotiate_wants_html_text_html_header() -> None:
59 """Explicit Accept: text/html → HTML path."""
60 req = _make_request(accept="text/html,application/xhtml+xml")
61 assert _wants_json(req, format_param=None) is False
62
63
64 def test_negotiate_accept_partial_match() -> None:
65 """Mixed Accept containing application/json → JSON path."""
66 req = _make_request(accept="text/html, application/json;q=0.9")
67 assert _wants_json(req, format_param=None) is True
68
69
70 def test_negotiate_format_param_not_json_means_html() -> None:
71 """?format=html (or any non-json value) → HTML path."""
72 req = _make_request(accept="")
73 assert _wants_json(req, format_param="html") is False
74
75
76 # ---------------------------------------------------------------------------
77 # negotiate_response async tests (full response construction)
78 # ---------------------------------------------------------------------------
79
80
81 class _SampleModel(CamelModel):
82 """Minimal CamelModel for testing camelCase serialisation via by_alias=True."""
83
84 repo_id: str
85 star_count: int
86
87
88 @pytest.mark.anyio
89 async def test_negotiate_json_uses_pydantic_by_alias() -> None:
90 """JSON path serialises Pydantic model with camelCase keys (by_alias=True)."""
91 req = _make_request(accept="application/json")
92 templates = MagicMock()
93
94 model = _SampleModel(repo_id="abc-123", star_count=42)
95 resp = await negotiate_response(
96 request=req,
97 template_name="musehub/pages/repo.html",
98 context={"repo_id": "abc-123"},
99 templates=templates,
100 json_data=model,
101 format_param=None,
102 )
103 assert isinstance(resp, JSONResponse)
104 import json
105 body_bytes = bytes(resp.body) if isinstance(resp.body, memoryview) else resp.body
106 payload = json.loads(body_bytes)
107 assert "repoId" in payload, f"Expected camelCase 'repoId', got keys: {list(payload)}"
108 assert "starCount" in payload, f"Expected camelCase 'starCount', got keys: {list(payload)}"
109 assert payload["repoId"] == "abc-123"
110 assert payload["starCount"] == 42
111 templates.TemplateResponse.assert_not_called()
112
113
114 @pytest.mark.anyio
115 async def test_negotiate_json_fallback_to_context() -> None:
116 """When json_data is None, JSON path returns serialisable context values."""
117 req = _make_request(accept="application/json")
118 templates = MagicMock()
119
120 resp = await negotiate_response(
121 request=req,
122 template_name="musehub/pages/repo.html",
123 context={"owner": "alice", "repo_slug": "my-beats", "count": 3},
124 templates=templates,
125 json_data=None,
126 format_param=None,
127 )
128 assert isinstance(resp, JSONResponse)
129 import json
130 body_bytes = bytes(resp.body) if isinstance(resp.body, memoryview) else resp.body
131 payload = json.loads(body_bytes)
132 assert payload["owner"] == "alice"
133 assert payload["repo_slug"] == "my-beats"
134 assert payload["count"] == 3
135
136
137 @pytest.mark.anyio
138 async def test_negotiate_html_path_calls_template_response() -> None:
139 """HTML path delegates to templates.TemplateResponse."""
140 req = _make_request(accept="text/html")
141 mock_template_resp = MagicMock()
142 templates = MagicMock()
143 templates.TemplateResponse.return_value = mock_template_resp
144
145 resp = await negotiate_response(
146 request=req,
147 template_name="musehub/pages/repo.html",
148 context={"owner": "alice"},
149 templates=templates,
150 json_data=None,
151 format_param=None,
152 )
153 templates.TemplateResponse.assert_called_once_with(req, "musehub/pages/repo.html", {"owner": "alice"})
154 assert resp is mock_template_resp
155
156
157 @pytest.mark.anyio
158 async def test_negotiate_format_param_overrides_html_accept() -> None:
159 """?format=json forces JSON even when Accept: text/html."""
160 req = _make_request(accept="text/html")
161 templates = MagicMock()
162
163 model = _SampleModel(repo_id="xyz", star_count=0)
164 resp = await negotiate_response(
165 request=req,
166 template_name="musehub/pages/repo.html",
167 context={},
168 templates=templates,
169 json_data=model,
170 format_param="json",
171 )
172 assert isinstance(resp, JSONResponse)
173 templates.TemplateResponse.assert_not_called()