test_musehub_ui_search_ssr.py
python
| 1 | """SSR tests for Muse Hub search pages — issue #577. |
| 2 | |
| 3 | Verifies that global_search_page() and search_page() render results |
| 4 | server-side in Jinja2 templates without requiring JavaScript execution. |
| 5 | Tests assert on HTML content directly returned by the server. |
| 6 | |
| 7 | Covers GET /musehub/ui/search (global search): |
| 8 | - test_global_search_renders_results_server_side |
| 9 | - test_global_search_no_results_shows_empty_state |
| 10 | - test_global_search_short_query_shows_prompt |
| 11 | - test_global_search_htmx_fragment_path |
| 12 | - test_global_search_empty_query_shows_prompt |
| 13 | |
| 14 | Covers GET /musehub/ui/{owner}/{repo_slug}/search (repo-scoped search): |
| 15 | - test_repo_search_form_populated_server_side |
| 16 | - test_repo_search_short_query_shows_prompt |
| 17 | - test_repo_search_htmx_fragment_returns_no_html |
| 18 | - test_repo_search_no_results_shows_empty_state |
| 19 | - test_repo_search_results_rendered_server_side |
| 20 | """ |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | from datetime import datetime, timezone |
| 24 | |
| 25 | import pytest |
| 26 | from httpx import AsyncClient |
| 27 | from sqlalchemy.ext.asyncio import AsyncSession |
| 28 | |
| 29 | from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubRepo |
| 30 | from musehub.muse_cli import models as _cli_models # noqa: F401 — register tables |
| 31 | from musehub.muse_cli.db import insert_commit, upsert_snapshot |
| 32 | from musehub.muse_cli.models import MuseCliCommit |
| 33 | from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id |
| 34 | |
| 35 | |
| 36 | # --------------------------------------------------------------------------- |
| 37 | # Helpers |
| 38 | # --------------------------------------------------------------------------- |
| 39 | |
| 40 | |
| 41 | async def _make_repo( |
| 42 | db: AsyncSession, |
| 43 | owner: str = "search_ssr_artist", |
| 44 | slug: str = "search-ssr-album", |
| 45 | visibility: str = "public", |
| 46 | ) -> str: |
| 47 | """Seed a public repo and return its repo_id string.""" |
| 48 | repo = MusehubRepo( |
| 49 | name=slug, |
| 50 | owner=owner, |
| 51 | slug=slug, |
| 52 | visibility=visibility, |
| 53 | owner_user_id=f"uid-{owner}", |
| 54 | ) |
| 55 | db.add(repo) |
| 56 | await db.commit() |
| 57 | await db.refresh(repo) |
| 58 | return str(repo.repo_id) |
| 59 | |
| 60 | |
| 61 | async def _make_musehub_commit( |
| 62 | db: AsyncSession, |
| 63 | repo_id: str, |
| 64 | *, |
| 65 | commit_id: str, |
| 66 | message: str, |
| 67 | branch: str = "main", |
| 68 | author: str = "musician", |
| 69 | ) -> None: |
| 70 | """Seed a MusehubCommit (used by global_search via musehub_commits table).""" |
| 71 | commit = MusehubCommit( |
| 72 | commit_id=commit_id, |
| 73 | repo_id=repo_id, |
| 74 | branch=branch, |
| 75 | parent_ids=[], |
| 76 | message=message, |
| 77 | author=author, |
| 78 | timestamp=datetime.now(tz=timezone.utc), |
| 79 | ) |
| 80 | db_branch = MusehubBranch( |
| 81 | repo_id=repo_id, |
| 82 | name=branch, |
| 83 | head_commit_id=commit_id, |
| 84 | ) |
| 85 | db.add(commit) |
| 86 | db.add(db_branch) |
| 87 | await db.commit() |
| 88 | |
| 89 | |
| 90 | async def _make_cli_commit( |
| 91 | db: AsyncSession, |
| 92 | repo_id: str, |
| 93 | *, |
| 94 | message: str, |
| 95 | branch: str = "main", |
| 96 | author: str = "musician", |
| 97 | ) -> str: |
| 98 | """Seed a MuseCliCommit (used by in-repo search via muse_commits table). |
| 99 | |
| 100 | Returns the generated commit_id. |
| 101 | """ |
| 102 | manifest: dict[str, str] = {"track.mid": "deadbeef"} |
| 103 | snap_id = compute_snapshot_id(manifest) |
| 104 | await upsert_snapshot(db, manifest=manifest, snapshot_id=snap_id) |
| 105 | committed_at = datetime.now(tz=timezone.utc) |
| 106 | commit_id = compute_commit_id( |
| 107 | parent_ids=[], |
| 108 | snapshot_id=snap_id, |
| 109 | message=message, |
| 110 | committed_at_iso=committed_at.isoformat(), |
| 111 | ) |
| 112 | commit = MuseCliCommit( |
| 113 | commit_id=commit_id, |
| 114 | repo_id=repo_id, |
| 115 | branch=branch, |
| 116 | parent_commit_id=None, |
| 117 | snapshot_id=snap_id, |
| 118 | message=message, |
| 119 | author=author, |
| 120 | committed_at=committed_at, |
| 121 | ) |
| 122 | await insert_commit(db, commit) |
| 123 | await db.flush() |
| 124 | return commit_id |
| 125 | |
| 126 | |
| 127 | # --------------------------------------------------------------------------- |
| 128 | # Global search — GET /musehub/ui/search |
| 129 | # --------------------------------------------------------------------------- |
| 130 | |
| 131 | |
| 132 | @pytest.mark.anyio |
| 133 | async def test_global_search_renders_results_server_side( |
| 134 | client: AsyncClient, |
| 135 | db_session: AsyncSession, |
| 136 | ) -> None: |
| 137 | """Commit message appears in the HTML returned by the server (not injected by JS).""" |
| 138 | repo_id = await _make_repo(db_session) |
| 139 | await _make_musehub_commit( |
| 140 | db_session, |
| 141 | repo_id, |
| 142 | commit_id="aabbccddeeff00112233445566778899aabbccd1", |
| 143 | message="beatbox groove pattern in D minor", |
| 144 | ) |
| 145 | response = await client.get("/musehub/ui/search?q=beatbox") |
| 146 | assert response.status_code == 200 |
| 147 | assert "text/html" in response.headers["content-type"] |
| 148 | assert "beatbox" in response.text |
| 149 | |
| 150 | |
| 151 | @pytest.mark.anyio |
| 152 | async def test_global_search_no_results_shows_empty_state( |
| 153 | client: AsyncClient, |
| 154 | db_session: AsyncSession, |
| 155 | ) -> None: |
| 156 | """A query with no matches renders the empty-state block.""" |
| 157 | await _make_repo(db_session, owner="search_noresult", slug="noresult-album") |
| 158 | response = await client.get("/musehub/ui/search?q=zzznomatch") |
| 159 | assert response.status_code == 200 |
| 160 | assert "text/html" in response.headers["content-type"] |
| 161 | assert "No results" in response.text |
| 162 | |
| 163 | |
| 164 | @pytest.mark.anyio |
| 165 | async def test_global_search_short_query_shows_prompt( |
| 166 | client: AsyncClient, |
| 167 | db_session: AsyncSession, |
| 168 | ) -> None: |
| 169 | """A single-character query renders the 'Enter at least 2 characters' prompt.""" |
| 170 | response = await client.get("/musehub/ui/search?q=a") |
| 171 | assert response.status_code == 200 |
| 172 | assert "Enter at least 2 characters" in response.text |
| 173 | |
| 174 | |
| 175 | @pytest.mark.anyio |
| 176 | async def test_global_search_empty_query_shows_prompt( |
| 177 | client: AsyncClient, |
| 178 | db_session: AsyncSession, |
| 179 | ) -> None: |
| 180 | """An empty query renders the prompt without running any DB search.""" |
| 181 | response = await client.get("/musehub/ui/search") |
| 182 | assert response.status_code == 200 |
| 183 | assert "Enter at least 2 characters" in response.text |
| 184 | |
| 185 | |
| 186 | @pytest.mark.anyio |
| 187 | async def test_global_search_htmx_fragment_path( |
| 188 | client: AsyncClient, |
| 189 | db_session: AsyncSession, |
| 190 | ) -> None: |
| 191 | """HX-Request: true causes the handler to return only the fragment — no <html> shell.""" |
| 192 | repo_id = await _make_repo(db_session, owner="htmx_search_artist", slug="htmx-search-album") |
| 193 | await _make_musehub_commit( |
| 194 | db_session, |
| 195 | repo_id, |
| 196 | commit_id="aabbccddeeff00112233445566778899aabbccd2", |
| 197 | message="funky bassline in Eb", |
| 198 | ) |
| 199 | response = await client.get( |
| 200 | "/musehub/ui/search?q=funky", |
| 201 | headers={"HX-Request": "true"}, |
| 202 | ) |
| 203 | assert response.status_code == 200 |
| 204 | assert "<html" not in response.text |
| 205 | assert "funky" in response.text |
| 206 | |
| 207 | |
| 208 | # --------------------------------------------------------------------------- |
| 209 | # Repo-scoped search — GET /musehub/ui/{owner}/{repo_slug}/search |
| 210 | # --------------------------------------------------------------------------- |
| 211 | |
| 212 | |
| 213 | @pytest.mark.anyio |
| 214 | async def test_repo_search_form_populated_server_side( |
| 215 | client: AsyncClient, |
| 216 | db_session: AsyncSession, |
| 217 | ) -> None: |
| 218 | """The query value is rendered server-side into the search form input (not by JS).""" |
| 219 | await _make_repo(db_session, owner="repo_search_artist", slug="repo-search-album") |
| 220 | response = await client.get( |
| 221 | "/musehub/ui/repo_search_artist/repo-search-album/search?q=jazzcore" |
| 222 | ) |
| 223 | assert response.status_code == 200 |
| 224 | assert "text/html" in response.headers["content-type"] |
| 225 | # query value is SSR-populated into the input element |
| 226 | assert "jazzcore" in response.text |
| 227 | |
| 228 | |
| 229 | @pytest.mark.anyio |
| 230 | async def test_repo_search_short_query_shows_prompt( |
| 231 | client: AsyncClient, |
| 232 | db_session: AsyncSession, |
| 233 | ) -> None: |
| 234 | """A single-character query renders the 'Enter at least 2 characters' prompt.""" |
| 235 | await _make_repo( |
| 236 | db_session, owner="repo_search_short", slug="repo-search-short-album" |
| 237 | ) |
| 238 | response = await client.get( |
| 239 | "/musehub/ui/repo_search_short/repo-search-short-album/search?q=x" |
| 240 | ) |
| 241 | assert response.status_code == 200 |
| 242 | assert "Enter at least 2 characters" in response.text |
| 243 | |
| 244 | |
| 245 | @pytest.mark.anyio |
| 246 | async def test_repo_search_htmx_fragment_returns_no_html( |
| 247 | client: AsyncClient, |
| 248 | db_session: AsyncSession, |
| 249 | ) -> None: |
| 250 | """HX-Request: true causes the handler to return only the fragment — no <html> shell.""" |
| 251 | await _make_repo( |
| 252 | db_session, owner="htmx_repo_search", slug="htmx-repo-search-album" |
| 253 | ) |
| 254 | response = await client.get( |
| 255 | "/musehub/ui/htmx_repo_search/htmx-repo-search-album/search?q=zzznomatch", |
| 256 | headers={"HX-Request": "true"}, |
| 257 | ) |
| 258 | assert response.status_code == 200 |
| 259 | assert "<html" not in response.text |
| 260 | # The fragment contains the SSR-rendered empty state (no JS needed) |
| 261 | assert "No results" in response.text |
| 262 | |
| 263 | |
| 264 | @pytest.mark.anyio |
| 265 | async def test_repo_search_no_results_shows_empty_state( |
| 266 | client: AsyncClient, |
| 267 | db_session: AsyncSession, |
| 268 | ) -> None: |
| 269 | """A query with no matches renders the empty-state block server-side.""" |
| 270 | await _make_repo( |
| 271 | db_session, owner="repo_search_empty", slug="repo-search-empty-album" |
| 272 | ) |
| 273 | response = await client.get( |
| 274 | "/musehub/ui/repo_search_empty/repo-search-empty-album/search?q=zzznomatch" |
| 275 | ) |
| 276 | assert response.status_code == 200 |
| 277 | assert "No results" in response.text |
| 278 | |
| 279 | |
| 280 | @pytest.mark.anyio |
| 281 | async def test_repo_search_results_rendered_server_side( |
| 282 | client: AsyncClient, |
| 283 | db_session: AsyncSession, |
| 284 | ) -> None: |
| 285 | """Commit message appears in the HTML for in-repo keyword search (SSR via MuseCliCommit).""" |
| 286 | repo_id = await _make_repo( |
| 287 | db_session, owner="repo_search_ssr", slug="repo-search-ssr-album" |
| 288 | ) |
| 289 | await _make_cli_commit( |
| 290 | db_session, |
| 291 | repo_id, |
| 292 | message="soulful groove rhythm section unique term", |
| 293 | ) |
| 294 | response = await client.get( |
| 295 | "/musehub/ui/repo_search_ssr/repo-search-ssr-album/search?q=soulful+groove" |
| 296 | ) |
| 297 | assert response.status_code == 200 |
| 298 | assert "text/html" in response.headers["content-type"] |
| 299 | assert "soulful" in response.text |