gabriel / musehub public
test_musehub_htmx_helpers.py python
182 lines 5.4 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for maestro.api.routes.musehub.htmx_helpers.
2
3 Covers HX-Request detection, HX-Boosted detection, fragment/full routing,
4 HX-Trigger header emission, and HX-Redirect response generation.
5 """
6
7 from __future__ import annotations
8
9 import json
10 from unittest.mock import AsyncMock, MagicMock, patch
11
12 import pytest
13 from starlette.datastructures import Headers
14 from starlette.responses import Response
15 from starlette.testclient import TestClient
16
17 from musehub.api.routes.musehub.htmx_helpers import (
18 htmx_fragment_or_full,
19 htmx_redirect,
20 htmx_trigger,
21 is_htmx,
22 is_htmx_boosted,
23 )
24
25
26 def _make_request(headers: dict[str, str] | None = None) -> MagicMock:
27 """Return a mock FastAPI Request with the given headers."""
28 req = MagicMock()
29 req.headers = Headers(headers=headers or {})
30 return req
31
32
33 def _make_templates(rendered_name: list[str]) -> MagicMock:
34 """Return a mock Jinja2Templates that records the template name used."""
35
36 templates = MagicMock()
37
38 def fake_response(request: object, name: str, ctx: object) -> Response:
39 rendered_name.append(name)
40 return Response(content=f"<rendered:{name}>", media_type="text/html")
41
42 templates.TemplateResponse = fake_response
43 return templates
44
45
46 # ---------------------------------------------------------------------------
47 # is_htmx
48 # ---------------------------------------------------------------------------
49
50
51 def test_is_htmx_returns_true_with_header() -> None:
52 req = _make_request({"HX-Request": "true"})
53 assert is_htmx(req) is True
54
55
56 def test_is_htmx_returns_false_without_header() -> None:
57 req = _make_request()
58 assert is_htmx(req) is False
59
60
61 def test_is_htmx_returns_false_wrong_value() -> None:
62 req = _make_request({"HX-Request": "false"})
63 assert is_htmx(req) is False
64
65
66 def test_is_htmx_returns_false_on_capitalised_value() -> None:
67 """Header value comparison is case-sensitive; 'True' ≠ 'true'."""
68 req = _make_request({"HX-Request": "True"})
69 assert is_htmx(req) is False
70
71
72 # ---------------------------------------------------------------------------
73 # is_htmx_boosted
74 # ---------------------------------------------------------------------------
75
76
77 def test_is_htmx_boosted_with_header() -> None:
78 req = _make_request({"HX-Boosted": "true"})
79 assert is_htmx_boosted(req) is True
80
81
82 def test_is_htmx_boosted_without_header() -> None:
83 req = _make_request()
84 assert is_htmx_boosted(req) is False
85
86
87 # ---------------------------------------------------------------------------
88 # htmx_fragment_or_full
89 # ---------------------------------------------------------------------------
90
91
92 @pytest.mark.anyio
93 async def test_htmx_fragment_or_full_returns_fragment_on_htmx_request() -> None:
94 rendered: list[str] = []
95 req = _make_request({"HX-Request": "true"})
96 templates = _make_templates(rendered)
97 ctx: dict[str, object] = {}
98
99 await htmx_fragment_or_full(
100 req, templates, ctx,
101 full_template="pages/full.html",
102 fragment_template="fragments/part.html",
103 )
104
105 assert rendered == ["fragments/part.html"]
106
107
108 @pytest.mark.anyio
109 async def test_htmx_fragment_or_full_returns_full_on_direct_request() -> None:
110 rendered: list[str] = []
111 req = _make_request() # no HX-Request header
112 templates = _make_templates(rendered)
113 ctx: dict[str, object] = {}
114
115 await htmx_fragment_or_full(
116 req, templates, ctx,
117 full_template="pages/full.html",
118 fragment_template="fragments/part.html",
119 )
120
121 assert rendered == ["pages/full.html"]
122
123
124 @pytest.mark.anyio
125 async def test_htmx_fragment_or_full_returns_full_when_no_fragment_template() -> None:
126 """Even an HTMX request must get the full page when no fragment_template is given."""
127 rendered: list[str] = []
128 req = _make_request({"HX-Request": "true"})
129 templates = _make_templates(rendered)
130 ctx: dict[str, object] = {}
131
132 await htmx_fragment_or_full(
133 req, templates, ctx,
134 full_template="pages/full.html",
135 fragment_template=None,
136 )
137
138 assert rendered == ["pages/full.html"]
139
140
141 # ---------------------------------------------------------------------------
142 # htmx_trigger
143 # ---------------------------------------------------------------------------
144
145
146 def test_htmx_trigger_sets_header_with_detail() -> None:
147 response = Response(content="ok")
148 htmx_trigger(response, "toast", {"message": "Issue closed", "type": "success"})
149
150 raw = response.headers["HX-Trigger"]
151 parsed = json.loads(raw)
152 assert parsed == {"toast": {"message": "Issue closed", "type": "success"}}
153
154
155 def test_htmx_trigger_sets_header_without_detail() -> None:
156 response = Response(content="ok")
157 htmx_trigger(response, "refresh")
158
159 raw = response.headers["HX-Trigger"]
160 parsed = json.loads(raw)
161 assert parsed == {"refresh": True}
162
163
164 def test_htmx_trigger_sets_header_with_none_detail() -> None:
165 response = Response(content="ok")
166 htmx_trigger(response, "ping", None)
167
168 raw = response.headers["HX-Trigger"]
169 parsed = json.loads(raw)
170 assert parsed == {"ping": True}
171
172
173 # ---------------------------------------------------------------------------
174 # htmx_redirect
175 # ---------------------------------------------------------------------------
176
177
178 def test_htmx_redirect_sets_hx_redirect_header() -> None:
179 response = htmx_redirect("/musehub/ui/owner/repo")
180
181 assert response.status_code == 200
182 assert response.headers["HX-Redirect"] == "/musehub/ui/owner/repo"