test_musehub_htmx_helpers.py
python
| 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" |