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