test_musehub_ui_search_ssr.py
python
| 1 | """SSR tests for MuseHub global search page. |
| 2 | |
| 3 | Verifies that global_search_page() renders results server-side in Jinja2 |
| 4 | templates without requiring JavaScript execution. |
| 5 | Tests assert on HTML content directly returned by the server. |
| 6 | |
| 7 | Covers GET /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 | |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | from datetime import datetime, timezone |
| 18 | |
| 19 | import pytest |
| 20 | from httpx import AsyncClient |
| 21 | from sqlalchemy.ext.asyncio import AsyncSession |
| 22 | |
| 23 | from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubRepo |
| 24 | from musehub.muse_cli import models as _cli_models # noqa: F401 — register tables |
| 25 | from musehub.muse_cli.db import insert_commit, upsert_snapshot |
| 26 | from musehub.muse_cli.models import MuseCliCommit |
| 27 | from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id |
| 28 | |
| 29 | |
| 30 | # --------------------------------------------------------------------------- |
| 31 | # Helpers |
| 32 | # --------------------------------------------------------------------------- |
| 33 | |
| 34 | |
| 35 | async def _make_repo( |
| 36 | db: AsyncSession, |
| 37 | owner: str = "search_ssr_artist", |
| 38 | slug: str = "search-ssr-album", |
| 39 | visibility: str = "public", |
| 40 | ) -> str: |
| 41 | """Seed a public repo and return its repo_id string.""" |
| 42 | repo = MusehubRepo( |
| 43 | name=slug, |
| 44 | owner=owner, |
| 45 | slug=slug, |
| 46 | visibility=visibility, |
| 47 | owner_user_id=f"uid-{owner}", |
| 48 | ) |
| 49 | db.add(repo) |
| 50 | await db.commit() |
| 51 | await db.refresh(repo) |
| 52 | return str(repo.repo_id) |
| 53 | |
| 54 | |
| 55 | async def _make_musehub_commit( |
| 56 | db: AsyncSession, |
| 57 | repo_id: str, |
| 58 | *, |
| 59 | commit_id: str, |
| 60 | message: str, |
| 61 | branch: str = "main", |
| 62 | author: str = "musician", |
| 63 | ) -> None: |
| 64 | """Seed a MusehubCommit (used by global_search via musehub_commits table).""" |
| 65 | commit = MusehubCommit( |
| 66 | commit_id=commit_id, |
| 67 | repo_id=repo_id, |
| 68 | branch=branch, |
| 69 | parent_ids=[], |
| 70 | message=message, |
| 71 | author=author, |
| 72 | timestamp=datetime.now(tz=timezone.utc), |
| 73 | ) |
| 74 | db_branch = MusehubBranch( |
| 75 | repo_id=repo_id, |
| 76 | name=branch, |
| 77 | head_commit_id=commit_id, |
| 78 | ) |
| 79 | db.add(commit) |
| 80 | db.add(db_branch) |
| 81 | await db.commit() |
| 82 | |
| 83 | |
| 84 | async def _make_cli_commit( |
| 85 | db: AsyncSession, |
| 86 | repo_id: str, |
| 87 | *, |
| 88 | message: str, |
| 89 | branch: str = "main", |
| 90 | author: str = "musician", |
| 91 | ) -> str: |
| 92 | """Seed a MuseCliCommit (used by in-repo search via muse_commits table). |
| 93 | |
| 94 | Returns the generated commit_id. |
| 95 | """ |
| 96 | manifest: dict[str, str] = {"track.mid": "deadbeef"} |
| 97 | snap_id = compute_snapshot_id(manifest) |
| 98 | await upsert_snapshot(db, manifest=manifest, snapshot_id=snap_id) |
| 99 | committed_at = datetime.now(tz=timezone.utc) |
| 100 | commit_id = compute_commit_id( |
| 101 | parent_ids=[], |
| 102 | snapshot_id=snap_id, |
| 103 | message=message, |
| 104 | committed_at_iso=committed_at.isoformat(), |
| 105 | ) |
| 106 | commit = MuseCliCommit( |
| 107 | commit_id=commit_id, |
| 108 | repo_id=repo_id, |
| 109 | branch=branch, |
| 110 | parent_commit_id=None, |
| 111 | snapshot_id=snap_id, |
| 112 | message=message, |
| 113 | author=author, |
| 114 | committed_at=committed_at, |
| 115 | ) |
| 116 | await insert_commit(db, commit) |
| 117 | await db.flush() |
| 118 | return commit_id |
| 119 | |
| 120 | |
| 121 | # --------------------------------------------------------------------------- |
| 122 | # Global search — GET /search |
| 123 | # --------------------------------------------------------------------------- |
| 124 | |
| 125 | |
| 126 | @pytest.mark.anyio |
| 127 | async def test_global_search_renders_results_server_side( |
| 128 | client: AsyncClient, |
| 129 | db_session: AsyncSession, |
| 130 | ) -> None: |
| 131 | """Commit message appears in the HTML returned by the server (not injected by JS).""" |
| 132 | repo_id = await _make_repo(db_session) |
| 133 | await _make_musehub_commit( |
| 134 | db_session, |
| 135 | repo_id, |
| 136 | commit_id="aabbccddeeff00112233445566778899aabbccd1", |
| 137 | message="beatbox groove pattern in D minor", |
| 138 | ) |
| 139 | response = await client.get("/search?q=beatbox") |
| 140 | assert response.status_code == 200 |
| 141 | assert "text/html" in response.headers["content-type"] |
| 142 | assert "beatbox" in response.text |
| 143 | |
| 144 | |
| 145 | @pytest.mark.anyio |
| 146 | async def test_global_search_no_results_shows_empty_state( |
| 147 | client: AsyncClient, |
| 148 | db_session: AsyncSession, |
| 149 | ) -> None: |
| 150 | """A query with no matches renders the empty-state block.""" |
| 151 | await _make_repo(db_session, owner="search_noresult", slug="noresult-album") |
| 152 | response = await client.get("/search?q=zzznomatch") |
| 153 | assert response.status_code == 200 |
| 154 | assert "text/html" in response.headers["content-type"] |
| 155 | assert "No results" in response.text |
| 156 | |
| 157 | |
| 158 | @pytest.mark.anyio |
| 159 | async def test_global_search_short_query_shows_prompt( |
| 160 | client: AsyncClient, |
| 161 | db_session: AsyncSession, |
| 162 | ) -> None: |
| 163 | """A single-character query renders the tips/idle state (no results run).""" |
| 164 | response = await client.get("/search?q=a") |
| 165 | assert response.status_code == 200 |
| 166 | assert "Global Search" in response.text |
| 167 | |
| 168 | |
| 169 | @pytest.mark.anyio |
| 170 | async def test_global_search_empty_query_shows_prompt( |
| 171 | client: AsyncClient, |
| 172 | db_session: AsyncSession, |
| 173 | ) -> None: |
| 174 | """An empty query renders the search page without running any DB search.""" |
| 175 | response = await client.get("/search") |
| 176 | assert response.status_code == 200 |
| 177 | assert "Global Search" in response.text |
| 178 | |
| 179 | |
| 180 | @pytest.mark.anyio |
| 181 | async def test_global_search_htmx_fragment_path( |
| 182 | client: AsyncClient, |
| 183 | db_session: AsyncSession, |
| 184 | ) -> None: |
| 185 | """HX-Request: true causes the handler to return only the fragment - no <html> shell.""" |
| 186 | repo_id = await _make_repo(db_session, owner="htmx_search_artist", slug="htmx-search-album") |
| 187 | await _make_musehub_commit( |
| 188 | db_session, |
| 189 | repo_id, |
| 190 | commit_id="aabbccddeeff00112233445566778899aabbccd2", |
| 191 | message="funky bassline in Eb", |
| 192 | ) |
| 193 | response = await client.get( |
| 194 | "/search?q=funky", |
| 195 | headers={"HX-Request": "true"}, |
| 196 | ) |
| 197 | assert response.status_code == 200 |
| 198 | assert "<html" not in response.text |
| 199 | assert "funky" in response.text |
| 200 | |
| 201 |