gabriel / musehub public
test_musehub_ui_search_ssr.py python
299 lines 9.5 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """SSR tests for MuseHub 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 /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 /{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 /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("/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("/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("/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("/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 "/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 /{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 "/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 "/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 "/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 "/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 "/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