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