gabriel / musehub public
test_musehub_ui_topics.py python
447 lines 15.9 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Tests for the MuseHub topics browsing UI pages.
2
3 Covers:
4 Topics Index (/topics):
5 - test_topics_index_renders_200 — GET /topics returns 200 HTML
6 - test_topics_index_no_auth_required — page is accessible without authentication
7 - test_topics_index_json_content_negotiation — Accept: application/json returns JSON
8 - test_topics_index_format_param — ?format=json returns JSON without Accept header
9 - test_topics_index_json_schema — JSON has allTopics, curatedGroups, total keys
10 - test_topics_index_empty_state — no repos returns allTopics=[] total=0
11 - test_topics_index_counts_public_only — private repos excluded from counts
12 - test_topics_index_sorted_by_popularity — topics sorted by repo_count descending
13 - test_topics_index_html_has_page_mode — HTML body contains PAGE_MODE JS variable
14 - test_topics_index_html_has_curated_groups — HTML body references curated group labels
15 - test_topics_index_curated_groups_populated — curated groups carry correct repo counts
16
17 Single Topic Page (/topics/{tag}):
18 - test_topic_detail_renders_200 — GET /topics/{tag} returns 200 HTML
19 - test_topic_detail_no_auth_required — page is accessible without authentication
20 - test_topic_detail_json_response — Accept: application/json returns JSON
21 - test_topic_detail_json_schema — JSON has tag, repos, total, page, pageSize keys
22 - test_topic_detail_empty_topic — unknown tag returns 200 with empty repos
23 - test_topic_detail_filters_by_tag — only repos with that tag are returned
24 - test_topic_detail_private_excluded — private repos excluded from results
25 - test_topic_detail_sort_created — ?sort=created returns repos without error
26 - test_topic_detail_sort_updated — ?sort=updated accepted without error
27 - test_topic_detail_invalid_sort_fallback — invalid sort silently falls back to default
28 - test_topic_detail_pagination — ?page=2 returns next page
29 - test_topic_detail_tag_injected_in_js — tag slug passed as TOPIC_TAG JS variable
30 - test_topic_detail_sort_injected_in_js — sort passed as TOPIC_SORT JS variable
31 - test_topic_detail_html_has_breadcrumb — breadcrumb references Topics and tag slug
32 - test_topic_detail_html_references_api — HTML references the topics UI data endpoint
33 """
34 from __future__ import annotations
35
36 import pytest
37 from httpx import AsyncClient
38 from sqlalchemy.ext.asyncio import AsyncSession
39
40 from musehub.db.musehub_models import MusehubRepo
41
42 # ---------------------------------------------------------------------------
43 # Helpers
44 # ---------------------------------------------------------------------------
45
46
47 async def _make_repo(
48 db_session: AsyncSession,
49 *,
50 name: str = "test-jazz",
51 owner: str = "alice",
52 slug: str = "test-jazz",
53 tags: list[str] | None = None,
54 visibility: str = "public",
55 ) -> str:
56 """Seed a minimal repo and return its repo_id string."""
57 repo = MusehubRepo(
58 name=name,
59 owner=owner,
60 slug=slug,
61 visibility=visibility,
62 owner_user_id="00000000-0000-0000-0000-000000000001",
63 tags=tags or [],
64 )
65 db_session.add(repo)
66 await db_session.commit()
67 await db_session.refresh(repo)
68 return str(repo.repo_id)
69
70
71 _INDEX_URL = "/topics"
72 _DETAIL_URL = "/topics/jazz"
73
74
75 # ---------------------------------------------------------------------------
76 # Topics Index — HTML rendering
77 # ---------------------------------------------------------------------------
78
79
80 async def test_topics_index_renders_200(
81 client: AsyncClient,
82 db_session: AsyncSession,
83 ) -> None:
84 """GET /topics must return 200 HTML."""
85 response = await client.get(_INDEX_URL)
86 assert response.status_code == 200
87 assert "text/html" in response.headers["content-type"]
88
89
90 async def test_topics_index_no_auth_required(
91 client: AsyncClient,
92 db_session: AsyncSession,
93 ) -> None:
94 """Topics index must be accessible without an Authorization header."""
95 response = await client.get(_INDEX_URL)
96 assert response.status_code == 200
97
98
99 async def test_topics_index_html_has_page_mode(
100 client: AsyncClient,
101 db_session: AsyncSession,
102 ) -> None:
103 """HTML response must embed mode = 'index' in the page_json data block."""
104 response = await client.get(_INDEX_URL)
105 assert response.status_code == 200
106 body = response.text
107 assert '"mode"' in body
108 assert '"index"' in body
109
110
111 async def test_topics_index_html_has_curated_groups(
112 client: AsyncClient,
113 db_session: AsyncSession,
114 ) -> None:
115 """HTML shell must reference the topics data endpoint for client-side loading."""
116 response = await client.get(_INDEX_URL)
117 assert response.status_code == 200
118 body = response.text
119 # The JS references the UI endpoint for data loading
120 assert "/topics" in body
121
122
123 # ---------------------------------------------------------------------------
124 # Topics Index — JSON content negotiation
125 # ---------------------------------------------------------------------------
126
127
128 async def test_topics_index_json_content_negotiation(
129 client: AsyncClient,
130 db_session: AsyncSession,
131 ) -> None:
132 """Accept: application/json must return a JSON response."""
133 response = await client.get(_INDEX_URL, headers={"Accept": "application/json"})
134 assert response.status_code == 200
135 assert "application/json" in response.headers["content-type"]
136
137
138 async def test_topics_index_format_param(
139 client: AsyncClient,
140 db_session: AsyncSession,
141 ) -> None:
142 """?format=json must return JSON without an Accept header."""
143 response = await client.get(_INDEX_URL + "?format=json")
144 assert response.status_code == 200
145 assert "application/json" in response.headers["content-type"]
146
147
148 async def test_topics_index_json_schema(
149 client: AsyncClient,
150 db_session: AsyncSession,
151 ) -> None:
152 """JSON response must contain allTopics, curatedGroups, and total keys."""
153 await _make_repo(db_session, tags=["jazz"])
154 response = await client.get(_INDEX_URL + "?format=json")
155 assert response.status_code == 200
156 data = response.json()
157 assert "allTopics" in data
158 assert "curatedGroups" in data
159 assert "total" in data
160 assert isinstance(data["allTopics"], list)
161 assert isinstance(data["curatedGroups"], list)
162 assert isinstance(data["total"], int)
163
164
165 async def test_topics_index_empty_state(
166 client: AsyncClient,
167 db_session: AsyncSession,
168 ) -> None:
169 """With no repos, allTopics must be empty and total must be 0."""
170 response = await client.get(_INDEX_URL + "?format=json")
171 assert response.status_code == 200
172 data = response.json()
173 assert data["allTopics"] == []
174 assert data["total"] == 0
175
176
177 async def test_topics_index_counts_public_only(
178 client: AsyncClient,
179 db_session: AsyncSession,
180 ) -> None:
181 """Private repo tags must not appear in the topics index."""
182 await _make_repo(db_session, tags=["secret-tag"], visibility="private")
183 response = await client.get(_INDEX_URL + "?format=json")
184 assert response.status_code == 200
185 data = response.json()
186 topic_names = [t["name"] for t in data["allTopics"]]
187 assert "secret-tag" not in topic_names
188
189
190 async def test_topics_index_sorted_by_popularity(
191 client: AsyncClient,
192 db_session: AsyncSession,
193 ) -> None:
194 """Topics must be sorted by repo_count descending (most popular first)."""
195 await _make_repo(db_session, name="r1", slug="r1", tags=["jazz"])
196 await _make_repo(db_session, name="r2", slug="r2", tags=["jazz", "blues"])
197 await _make_repo(db_session, name="r3", slug="r3", tags=["blues"])
198 response = await client.get(_INDEX_URL + "?format=json")
199 assert response.status_code == 200
200 data = response.json()
201 topics = data["allTopics"]
202 # jazz: 2 repos, blues: 2 repos (tie) — both before any single-repo topic
203 counts = [t["repo_count"] for t in topics]
204 assert counts == sorted(counts, reverse=True), "Topics not sorted by repo_count desc"
205
206
207 async def test_topics_index_curated_groups_populated(
208 client: AsyncClient,
209 db_session: AsyncSession,
210 ) -> None:
211 """Curated groups must include Genres, Instruments, and Eras with topic items."""
212 await _make_repo(db_session, tags=["jazz", "piano"])
213 response = await client.get(_INDEX_URL + "?format=json")
214 assert response.status_code == 200
215 data = response.json()
216 group_labels = [g["label"] for g in data["curatedGroups"]]
217 assert "Genres" in group_labels
218 assert "Instruments" in group_labels
219 assert "Eras" in group_labels
220
221 # Jazz and piano should appear in their curated groups with repoCount > 0
222 genres_group = next(g for g in data["curatedGroups"] if g["label"] == "Genres")
223 jazz_item = next((t for t in genres_group["topics"] if t["name"] == "jazz"), None)
224 assert jazz_item is not None
225 assert jazz_item["repo_count"] == 1
226
227 instruments_group = next(g for g in data["curatedGroups"] if g["label"] == "Instruments")
228 piano_item = next((t for t in instruments_group["topics"] if t["name"] == "piano"), None)
229 assert piano_item is not None
230 assert piano_item["repo_count"] == 1
231
232
233 # ---------------------------------------------------------------------------
234 # Topic Detail — HTML rendering
235 # ---------------------------------------------------------------------------
236
237
238 async def test_topic_detail_renders_200(
239 client: AsyncClient,
240 db_session: AsyncSession,
241 ) -> None:
242 """GET /topics/{tag} must return 200 HTML."""
243 response = await client.get(_DETAIL_URL)
244 assert response.status_code == 200
245 assert "text/html" in response.headers["content-type"]
246
247
248 async def test_topic_detail_no_auth_required(
249 client: AsyncClient,
250 db_session: AsyncSession,
251 ) -> None:
252 """Topic detail page must be accessible without authentication."""
253 response = await client.get(_DETAIL_URL)
254 assert response.status_code == 200
255
256
257 async def test_topic_detail_tag_injected_in_js(
258 client: AsyncClient,
259 db_session: AsyncSession,
260 ) -> None:
261 """Tag slug must be passed in the page_json data block."""
262 response = await client.get(_DETAIL_URL)
263 assert response.status_code == 200
264 body = response.text
265 assert '"tag"' in body
266 assert '"jazz"' in body
267
268
269 async def test_topic_detail_sort_injected_in_js(
270 client: AsyncClient,
271 db_session: AsyncSession,
272 ) -> None:
273 """Sort param must be passed in the page_json data block."""
274 response = await client.get(_DETAIL_URL + "?sort=updated")
275 assert response.status_code == 200
276 body = response.text
277 assert '"sort"' in body
278 assert '"updated"' in body
279
280
281 async def test_topic_detail_html_has_breadcrumb(
282 client: AsyncClient,
283 db_session: AsyncSession,
284 ) -> None:
285 """HTML breadcrumb must reference Topics index and the current tag slug."""
286 response = await client.get(_DETAIL_URL)
287 assert response.status_code == 200
288 body = response.text
289 assert "Topics" in body
290 assert "jazz" in body
291
292
293 async def test_topic_detail_html_references_api(
294 client: AsyncClient,
295 db_session: AsyncSession,
296 ) -> None:
297 """HTML must reference the topics UI data endpoint for client-side data fetching."""
298 response = await client.get(_DETAIL_URL)
299 assert response.status_code == 200
300 body = response.text
301 assert "/topics" in body
302
303
304 # ---------------------------------------------------------------------------
305 # Topic Detail — JSON content negotiation
306 # ---------------------------------------------------------------------------
307
308
309 async def test_topic_detail_json_response(
310 client: AsyncClient,
311 db_session: AsyncSession,
312 ) -> None:
313 """Accept: application/json must return a JSON response."""
314 response = await client.get(_DETAIL_URL, headers={"Accept": "application/json"})
315 assert response.status_code == 200
316 assert "application/json" in response.headers["content-type"]
317
318
319 async def test_topic_detail_json_schema(
320 client: AsyncClient,
321 db_session: AsyncSession,
322 ) -> None:
323 """JSON response must contain tag, repos, total, and nextCursor keys."""
324 response = await client.get(_DETAIL_URL + "?format=json")
325 assert response.status_code == 200
326 data = response.json()
327 assert "tag" in data
328 assert "repos" in data
329 assert "total" in data
330 assert "nextCursor" in data
331 assert isinstance(data["repos"], list)
332 assert isinstance(data["total"], int)
333 assert data["tag"] == "jazz"
334
335
336 async def test_topic_detail_empty_topic(
337 client: AsyncClient,
338 db_session: AsyncSession,
339 ) -> None:
340 """Unknown tag must return 200 with an empty repos list (not 404)."""
341 response = await client.get("/topics/no-such-genre?format=json")
342 assert response.status_code == 200
343 data = response.json()
344 assert data["repos"] == []
345 assert data["total"] == 0
346
347
348 async def test_topic_detail_filters_by_tag(
349 client: AsyncClient,
350 db_session: AsyncSession,
351 ) -> None:
352 """Only repos that carry the requested tag must appear in the response."""
353 await _make_repo(db_session, name="jazz-repo", slug="jazz-repo", tags=["jazz", "piano"])
354 await _make_repo(db_session, name="blues-repo", slug="blues-repo", tags=["blues"])
355 response = await client.get(_DETAIL_URL + "?format=json")
356 assert response.status_code == 200
357 data = response.json()
358 assert data["total"] == 1
359 assert len(data["repos"]) == 1
360 assert data["repos"][0]["slug"] == "jazz-repo"
361
362
363 async def test_topic_detail_private_excluded(
364 client: AsyncClient,
365 db_session: AsyncSession,
366 ) -> None:
367 """Private repos tagged with the topic must not appear in results."""
368 await _make_repo(
369 db_session, name="private-jazz", slug="private-jazz",
370 tags=["jazz"], visibility="private"
371 )
372 response = await client.get(_DETAIL_URL + "?format=json")
373 assert response.status_code == 200
374 data = response.json()
375 assert data["total"] == 0
376 assert data["repos"] == []
377
378
379 async def test_topic_detail_sort_created(
380 client: AsyncClient,
381 db_session: AsyncSession,
382 ) -> None:
383 """?sort=created must return repos without error."""
384 await _make_repo(db_session, name="jazz-a", slug="jazz-a", tags=["jazz"])
385 await _make_repo(db_session, name="jazz-b", slug="jazz-b", tags=["jazz"])
386 response = await client.get(_DETAIL_URL + "?sort=created&format=json")
387 assert response.status_code == 200
388 data = response.json()
389 assert data["total"] == 2
390
391
392 async def test_topic_detail_sort_updated(
393 client: AsyncClient,
394 db_session: AsyncSession,
395 ) -> None:
396 """?sort=updated must be accepted and return repos without error."""
397 await _make_repo(db_session, name="jazz-recent", slug="jazz-recent", tags=["jazz"])
398 response = await client.get(_DETAIL_URL + "?sort=updated&format=json")
399 assert response.status_code == 200
400 data = response.json()
401 assert data["total"] == 1
402
403
404 async def test_topic_detail_invalid_sort_fallback(
405 client: AsyncClient,
406 db_session: AsyncSession,
407 ) -> None:
408 """An invalid ?sort value must silently fall back to stars — no 422."""
409 await _make_repo(db_session, name="jazz-x", slug="jazz-x", tags=["jazz"])
410 response = await client.get(_DETAIL_URL + "?sort=bogus&format=json")
411 assert response.status_code == 200
412 data = response.json()
413 assert data["total"] == 1
414
415
416 async def test_topic_detail_pagination(
417 client: AsyncClient,
418 db_session: AsyncSession,
419 ) -> None:
420 """Cursor pagination: first page returns nextCursor; following it yields remaining results."""
421 for i in range(3):
422 await _make_repo(
423 db_session,
424 name=f"jazz-{i}",
425 slug=f"jazz-{i}",
426 tags=["jazz"],
427 )
428 # Fetch first page with limit=2
429 response1 = await client.get(_DETAIL_URL + "?limit=2&format=json")
430 assert response1.status_code == 200
431 data1 = response1.json()
432 assert data1["total"] == 3
433 assert len(data1["repos"]) == 2
434 next_cursor = data1["nextCursor"]
435 assert next_cursor is not None
436
437 # Fetch second page using the cursor
438 from urllib.parse import quote
439 response2 = await client.get(
440 _DETAIL_URL + f"?limit=2&cursor={quote(next_cursor)}&format=json"
441 )
442 assert response2.status_code == 200
443 data2 = response2.json()
444 assert data2["total"] == 3
445 # 1 remaining result
446 assert len(data2["repos"]) == 1
447 assert data2["nextCursor"] is None
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago