test_musehub_ui_htmx_infra.py
python
| 1 | """Tests for HTMX infrastructure — static assets, base.html wiring, and helpers. |
| 2 | |
| 3 | Verifies that: |
| 4 | - htmx.min.js and alpinejs.min.js are present in the static directory. |
| 5 | - base.html includes the correct <script> tags and hx-boost attribute. |
| 6 | - musehub.js contains the HTMX JWT auth bridge and after-swap hook. |
| 7 | - The is_htmx() / is_htmx_boosted() helpers return the correct values. |
| 8 | - The static files are reachable via the /static/ HTTP endpoint. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | from pathlib import Path |
| 14 | |
| 15 | import pytest |
| 16 | from httpx import AsyncClient |
| 17 | |
| 18 | STATIC_DIR = Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "static" |
| 19 | BASE_HTML = Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "base.html" |
| 20 | MUSEHUB_JS = STATIC_DIR / "musehub.js" |
| 21 | |
| 22 | |
| 23 | # ── Static asset presence ──────────────────────────────────────────────────── |
| 24 | |
| 25 | def test_htmx_min_js_static_file_exists() -> None: |
| 26 | """htmx.min.js must be present in the MuseHub static directory.""" |
| 27 | assert (STATIC_DIR / "htmx.min.js").exists(), "htmx.min.js not found in static dir" |
| 28 | |
| 29 | |
| 30 | def test_alpinejs_min_js_static_file_exists() -> None: |
| 31 | """alpinejs.min.js must be present in the MuseHub static directory.""" |
| 32 | assert (STATIC_DIR / "alpinejs.min.js").exists(), "alpinejs.min.js not found in static dir" |
| 33 | |
| 34 | |
| 35 | # ── base.html wiring ───────────────────────────────────────────────────────── |
| 36 | |
| 37 | def test_base_html_includes_htmx_script() -> None: |
| 38 | """base.html must reference htmx.min.js so HTMX is available on every page.""" |
| 39 | content = BASE_HTML.read_text() |
| 40 | assert "htmx.min.js" in content, "htmx.min.js script tag missing from base.html" |
| 41 | |
| 42 | |
| 43 | def test_base_html_includes_alpinejs_script() -> None: |
| 44 | """base.html must reference alpinejs.min.js so Alpine.js is available on every page.""" |
| 45 | content = BASE_HTML.read_text() |
| 46 | assert "alpinejs.min.js" in content, "alpinejs.min.js script tag missing from base.html" |
| 47 | |
| 48 | |
| 49 | def test_base_html_hx_boost_on_container() -> None: |
| 50 | """The main container div must carry hx-boost so navigation links get SPA-feel transitions.""" |
| 51 | content = BASE_HTML.read_text() |
| 52 | assert 'hx-boost="true"' in content, 'hx-boost="true" missing from container div in base.html' |
| 53 | |
| 54 | |
| 55 | def test_base_html_htmx_loading_indicator() -> None: |
| 56 | """base.html must include the #htmx-loading progress bar element.""" |
| 57 | content = BASE_HTML.read_text() |
| 58 | assert 'id="htmx-loading"' in content, "#htmx-loading element missing from base.html" |
| 59 | |
| 60 | |
| 61 | # ── musehub.js HTMX hooks ──────────────────────────────────────────────────── |
| 62 | |
| 63 | def test_musehub_js_has_htmx_config_request_bridge() -> None: |
| 64 | """musehub.js must register an htmx:configRequest listener to inject the Bearer token.""" |
| 65 | content = MUSEHUB_JS.read_text() |
| 66 | assert "htmx:configRequest" in content, "htmx:configRequest listener missing from musehub.js" |
| 67 | |
| 68 | |
| 69 | def test_musehub_js_has_htmx_after_swap_hook() -> None: |
| 70 | """musehub.js must register an htmx:afterSwap listener to re-run initRepoNav after fragments swap.""" |
| 71 | content = MUSEHUB_JS.read_text() |
| 72 | assert "htmx:afterSwap" in content, "htmx:afterSwap listener missing from musehub.js" |
| 73 | |
| 74 | |
| 75 | # ── is_htmx() / is_htmx_boosted() helpers ─────────────────────────────────── |
| 76 | |
| 77 | def test_is_htmx_helper_true() -> None: |
| 78 | """is_htmx() must return True when the HX-Request: true header is present.""" |
| 79 | from unittest.mock import MagicMock |
| 80 | |
| 81 | from musehub.api.routes.musehub.htmx_helpers import is_htmx |
| 82 | |
| 83 | request = MagicMock() |
| 84 | request.headers = {"HX-Request": "true"} |
| 85 | assert is_htmx(request) is True |
| 86 | |
| 87 | |
| 88 | def test_is_htmx_helper_false() -> None: |
| 89 | """is_htmx() must return False when the HX-Request header is absent.""" |
| 90 | from unittest.mock import MagicMock |
| 91 | |
| 92 | from musehub.api.routes.musehub.htmx_helpers import is_htmx |
| 93 | |
| 94 | request = MagicMock() |
| 95 | request.headers = {} |
| 96 | assert is_htmx(request) is False |
| 97 | |
| 98 | |
| 99 | def test_is_htmx_boosted_helper_true() -> None: |
| 100 | """is_htmx_boosted() must return True when HX-Boosted: true is present.""" |
| 101 | from unittest.mock import MagicMock |
| 102 | |
| 103 | from musehub.api.routes.musehub.htmx_helpers import is_htmx_boosted |
| 104 | |
| 105 | request = MagicMock() |
| 106 | request.headers = {"HX-Boosted": "true"} |
| 107 | assert is_htmx_boosted(request) is True |
| 108 | |
| 109 | |
| 110 | def test_is_htmx_boosted_helper_false() -> None: |
| 111 | """is_htmx_boosted() must return False when the HX-Boosted header is absent.""" |
| 112 | from unittest.mock import MagicMock |
| 113 | |
| 114 | from musehub.api.routes.musehub.htmx_helpers import is_htmx_boosted |
| 115 | |
| 116 | request = MagicMock() |
| 117 | request.headers = {} |
| 118 | assert is_htmx_boosted(request) is False |
| 119 | |
| 120 | |
| 121 | # ── HTTP endpoint reachability ─────────────────────────────────────────────── |
| 122 | |
| 123 | @pytest.mark.anyio |
| 124 | async def test_htmx_min_js_served_over_http(client: AsyncClient) -> None: |
| 125 | """GET /static/htmx.min.js must return 200.""" |
| 126 | resp = await client.get("/static/htmx.min.js") |
| 127 | assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" |
| 128 | |
| 129 | |
| 130 | @pytest.mark.anyio |
| 131 | async def test_alpinejs_min_js_served_over_http(client: AsyncClient) -> None: |
| 132 | """GET /static/alpinejs.min.js must return 200.""" |
| 133 | resp = await client.get("/static/alpinejs.min.js") |
| 134 | assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" |