test_musehub_htmx_helpers.py
python
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠ breaking
1 day 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
1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠
1 day ago