gabriel / musehub public
test_musehub_htmx_helpers.py python
181 lines 5.3 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 9 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.types.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 async def test_htmx_fragment_or_full_returns_fragment_on_htmx_request() -> None:
95 rendered: list[str] = []
96 req = _make_request({"HX-Request": "true"})
97 templates = _make_templates(rendered)
98 ctx = {}
99
100 await htmx_fragment_or_full(
101 req, templates, ctx,
102 full_template="pages/full.html",
103 fragment_template="fragments/part.html",
104 )
105
106 assert rendered == ["fragments/part.html"]
107
108
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 = {}
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 async def test_htmx_fragment_or_full_returns_full_when_no_fragment_template() -> None:
125 """Even an HTMX request must get the full page when no fragment_template is given."""
126 rendered: list[str] = []
127 req = _make_request({"HX-Request": "true"})
128 templates = _make_templates(rendered)
129 ctx = {}
130
131 await htmx_fragment_or_full(
132 req, templates, ctx,
133 full_template="pages/full.html",
134 fragment_template=None,
135 )
136
137 assert rendered == ["pages/full.html"]
138
139
140 # ---------------------------------------------------------------------------
141 # htmx_trigger
142 # ---------------------------------------------------------------------------
143
144
145 def test_htmx_trigger_sets_header_with_detail() -> None:
146 response = Response(content="ok")
147 htmx_trigger(response, "toast", {"message": "Issue closed", "type": "success"})
148
149 raw = response.headers["HX-Trigger"]
150 parsed = json.loads(raw)
151 assert parsed == {"toast": {"message": "Issue closed", "type": "success"}}
152
153
154 def test_htmx_trigger_sets_header_without_detail() -> None:
155 response = Response(content="ok")
156 htmx_trigger(response, "refresh")
157
158 raw = response.headers["HX-Trigger"]
159 parsed = json.loads(raw)
160 assert parsed == {"refresh": True}
161
162
163 def test_htmx_trigger_sets_header_with_none_detail() -> None:
164 response = Response(content="ok")
165 htmx_trigger(response, "ping", None)
166
167 raw = response.headers["HX-Trigger"]
168 parsed = json.loads(raw)
169 assert parsed == {"ping": True}
170
171
172 # ---------------------------------------------------------------------------
173 # htmx_redirect
174 # ---------------------------------------------------------------------------
175
176
177 def test_htmx_redirect_sets_hx_redirect_header() -> None:
178 response = htmx_redirect("/owner/repo")
179
180 assert response.status_code == 200
181 assert response.headers["HX-Redirect"] == "/owner/repo"
File History 14 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 9 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 12 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 51 days ago